Use mobx in strict mode
for state management and follow the flux architecture. This yields a unidirectional data flow:
state -> view -> action -> state
This creates a natural separation between view and state, which simplifies testing and makes the flow of code easier to follow.
All state is stored in a single container AppState.ts
(aka. the state root
).
This is explained further in the
Declaring state
section down below.
The AppState
instance is created at the entry point of the application main.tsx
(or tests) and only exists in that scope. It is never exported.
This is to avoid
global state
, which is strictly prohibited.
However, to give easy access to state (which is the primary reason why global state is commonly used), it is instead passed down with the Provider/Consumer pattern using createContext.
Use this for fine grained control of where to consume state
// MyComponent.tsx
import {AppContext} from 'state/AppContext';
@observer // Remember the mobx observer decorator if you want to re-render on state changes
class MyComponent extends React.Component {
render () {
return (
<AppContext.Consumer>
{(context) => (
<span>{context.state.foo.bar}</span>
)}
</AppContext.Consumer>
);
}
}
Use this when state is needed in the entire component and not just in parts of the render structure.
// MyComponent.tsx
import {AppContext, AppContextProps} from 'state/AppContext';
import {consume} from 'lib/consume';
@consume(AppContext.Consumer)
class MyComponent extends React.Component<AppContextProps> {
render () {
return (
<span>{this.props.state.foo.bar}</span>
);
}
}
@observer
must be the innermost decorator to function properly.
// MyComponent.tsx
import {AppContext, AppContextProps} from 'state/AppContext';
import {consume} from 'lib/consume';
@consume(AppContext.Consumer)
@observer // Must be the innermost decorator
class MyComponent extends React.Component<AppContextProps> {
render () {
return (
<span>{this.props.state.foo.bar}</span>
);
}
}
Basic unit test:
it('can render with special state', () => {
const foo = new FooState();
foo.updateBar('baz');
expect(foo.bar).toBe('baz');
})
Testing App.tsx
can be done effortlessly:
it('can render with specific state', () => {
const state = new AppState();
modifyStateForTest(state);
render(<App state={state}/>);
});
- State is declared by mobx classes (aka.
stores
) insrc/state/
. - They each follow the naming convention
<name>Store.ts
actions
exist directly on the mobx classes.- Every
store
is instantiated in the root state containerAppState.ts
(and only here)
Example:
// state/AppState.ts
export class AppState {
foo = new FooStore();
// Add more stores here
}
// state/FooStore.ts
export class FooStore {
@observable count: number = 0;
@action
updateCount (newCount: number) {
this.count = newCount;
}
}
- A
store
may in turn manage subsequent state classes. - These are regarded as
models
, and exist insrc/state/models
. - They follow the naming convention
<name>.ts
.
Example:
// state/BarStore.ts
export class BarStore {
@observable bars: Bar[] = [];
@action
addBar (bar: Bar) {
this.bars.push(bar);
}
}
// state/models/Bar.ts
export class Bar {
@observable name: string = '';
@action
updateName (newName: string) {
this.name = name;
}
}
If a model needs batch updates of properties use the following pattern:
export class MyModel {
@observable foo: string;
@observable bar: string;
@action
update (props: Partial<MyModel>) {
Object.assign(this, props);
return this; // For chainability
}
}
const model = new MyModel();
model.update({foo: 'hello', bar: 'world'});
Most (if not all) models are Data Transfer Objects and should have a 1 to 1 property representation of their backend API counterpart.
We use serializr for serialization and deserialization of DTOs.
Some state is managed by complex processes. This is arbitrary code consuming the state APIs.
This concept exists to promote code separation and avoid spreading state management code arbitrarily around the code base (ie. in views).
- We call these
behaviors
and store them instate/behaviors/
. - They follow the naming convention
<name>Behavior.ts
. - They export a function with the signature
<name>Behavior(<store dependencies>): Function
. See example below for explanation. - They only consume the state APIs. Do not put actual state/stores in
state/behaviors/
.
Example:
// state/behaviors/linkBehavior.ts
export function linkBehavior (a: FooStore, b: OtherStore) {
const stopReaction1 = reaction(() => foo.count > 5, (yes) => yes && b.recordHigh());
const stopReaction2 = reaction(() => foo.count < 5, (yes) => yes && b.recordLow());
// Return a function that will stop the behavior when called
return () => {
stopReaction1();
stopReaction2();
};
}
Some behaviors are global
and should be active as long as the application is active, while others are situational
, ie. active while a component is mounted. Since behaviors are essentially arbitrary code, the decision is up to the developer, case by case.
Put global
behaviors in state/behaviors/appBehavior.ts
:
appBehavior.ts
is regular behavior except for the fact that we have by convention decided to put global behaviors in it, and that it is called in App.tsx to make the behavior active as long as the app is running.
// state/behaviors/appBehavior.ts
export function appBehavior (state: AppState) {
const behaviorStoppers = [
globalBehavior1(state.foo),
globalBehavior2(state.bar),
// Add more global behaviors here
];
return () => {
while (behaviorStoppers.length) {
behaviorStoppers.pop()();
}
};
}
Use situational
behaviors where necessary, ie. views, or as part of another behavior:
// state/AppState.ts
class SomeView extends AppContextComponent {
private stopBehavior = thirdBehavior(this.props.state.third);
componentWillUnmount () {
this.stopBehavior();
}
}
Some times it's useful to have one store
depend on another.
Warning: This sometimes indicates a messy architecture. It is recommended to be solved by having stores communicate with each other through
actions
, rather than dependencies. You can automate this usingbehaviors
.
Example:
// state/AppState.ts
export class AppState {
base = new BaseStore();
super = new SuperStore(this.base);
}
// state/FooStore.ts
export class SuperStore {
@observable multiplier = 1;
@computed get superValue () {
return this.base.count * this.multiplier;
}
// Dependencies must be private.
// (This is to avoid a confusing structure where
// one store could be accessed via another)
constructor (
private base: BaseStore
) {}
@action
updateMultiplier (newMultiplier: number) {
this.multiplier = newMultiplier;
}
}