Skip to content

Instantly share code, notes, and snippets.

@kasper573
Created April 24, 2018 19:51
Show Gist options
  • Save kasper573/0fb096354213cd136019232f66654e56 to your computer and use it in GitHub Desktop.
Save kasper573/0fb096354213cd136019232f66654e56 to your computer and use it in GitHub Desktop.

State management

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.

Using state

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.

Example: Using context consumer

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>
    );
  }
}

Example: Using context connection

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>
    );
  }
}

Gotcha: Using context connection together with mobx observer

@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>
    );
  }
}

Example: Using state in unit tests

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}/>);
});

Declaring state

  • State is declared by mobx classes (aka. stores) in src/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 container AppState.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 in src/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;
  }
}

Model batch updates

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'});

Data transfer and Serialization

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.

Advanced: State behavior

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 in state/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();
  };
}

Using behaviors

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();
  }
}

Advanced: Composite state

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 using behaviors.

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;
  }
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment