6 July 2019

cover

Four different approaches to non-Redux global state libraries

From the consuming perspective
Share on:

Introduction

Since React hooks landed, there has been many libraries proposed for global state. Some of them are simple wrappers around context. Whereas, some of them are full featured state management systems.

Technically, there are several implementations how to store state and notify changes. We don’t go in detail in this post, but just note two axes.

  1. whether context based or external store
  2. whether subscriptions based or context propagation

In this post, we focus on API design of hooks at the consumer end. In my observation, there are four approaches to the API design. Let’s see each approach by example in pseudo code. As a simple example, we assume an app that has the followings.

  • two global counters,
  • two counter components, and
  • an action to increment both counters.

Note that it is implementation agnostic at the provider end. So, <Provider> doesn’t necessarily imply React context.

Approach 1: Multiple contexts

const App = () => (
  <Counter1Provider initialState={0}>
    <Counter2Provider initialState={0}>
      <Counter1 />
      <Counter2 />
    </Counter2Provider>
  </Counter1Provider>
);

const Counter1 = () => {
  const [count1, dispatch1] = useCounter1();
  const [, dispatch2] = useCounter2();
  const incrementBoth = () => {
    dispatch1({ type: 'increment' });
    dispatch2({ type: 'increment' });
  };
  return (
    <div>
      <div>Count1: {count1}</div>
      <button onClick={incrementBoth}>Increment both</button>
    </div>
  );
};

const Counter2 = () => {
  const [, dispatch1] = useCounter1();
  const [count2, dispatch2] = useCounter2();
  const incrementBoth = () => {
    dispatch1({ type: 'increment' });
    dispatch2({ type: 'increment' });
  };
  return (
    <div>
      <div>Count2: {count2}</div>
      <button onClick={incrementBoth}>Increment both</button>
    </div>
  );
};

This approach is probably the most idiomatic. One could easily implement this approach with React context and useContext.

The libraries with this approach: constate and unstated-next

Approach 2: Select by property names (or paths)

const App = () => (
  <Provider initialState={{ count1: 0, count2: 0 }}>
    <Counter1 />
    <Counter2 />
  </Provider>
);

const Counter1 = () => {
  const count1 = useGlobalState('count1');
  const dispatch = useDispatch();
  const incrementBoth = () => {
    dispatch({ type: 'incrementBoth' });
  };
  return (
    <div>
      <div>Count1: {count1}</div>
      <button onClick={incrementBoth}>Increment both</button>
    </div>
  );
};

const Counter2 = () => {
  const count2 = useGlobalState('count2');
  const dispatch = useDispatch();
  const incrementBoth = () => {
    dispatch({ type: 'incrementBoth' });
  };
  return (
    <div>
      <div>Count2: {count2}</div>
      <button onClick={incrementBoth}>Increment both</button>
    </div>
  );
};

This approach is to put more values in a single store. A single store allows to dispatch one action to change multiple values. You specify a property name to get a corresponding value. It’s simple to specify by a name, but somewhat limited in a complex case.

The libraries with this approach: react-hooks-global-state and shareon

Approach 3: Select by selector functions

const App = () => (
  <Provider initialState={{ count1: 0, count2: 0 }}>
    <Counter1 />
    <Counter2 />
  </Provider>
);

const Counter1 = () => {
  const count1 = useSelector(state => state.count1); // changed
  const dispatch = useDispatch();
  const incrementBoth = () => {
    dispatch({ type: 'incrementBoth' });
  };
  return (
    <div>
      <div>Count1: {count1}</div>
      <button onClick={incrementBoth}>Increment both</button>
    </div>
  );
};

const Counter2 = () => {
  const count2 = useSelector(state => state.count2); // changed
  const dispatch = useDispatch();
  const incrementBoth = () => {
    dispatch({ type: 'incrementBoth' });
  };
  return (
    <div>
      <div>Count2: {count2}</div>
      <button onClick={incrementBoth}>Increment both</button>
    </div>
  );
};

Only two lines are changed from the previous code. Selector functions are more flexible than property names. So flexible that it may be misused like doing expensive computations. Most importantly, performance optimization often requires to keep object referential equality.

The libraries with this approach: zustand and react-sweet-state

Approach 4: State usage tracking

const App = () => (
  <Provider initialState={{ count1: 0, count2: 0 }}>
    <Counter1 />
    <Counter2 />
  </Provider>
);

const Counter1 = () => {
  const state = useTrackedState();
  const dispatch = useDispatch();
  const incrementBoth = () => {
    dispatch({ type: 'incrementBoth' });
  };
  return (
    <div>
      <div>Count1: {state.count1}</div>
      <button onClick={incrementBoth}>Increment both</button>
    </div>
  );
};

const Counter2 = () => {
  const state = useTrackedState();
  const dispatch = useDispatch();
  const incrementBoth = () => {
    dispatch({ type: 'incrementBoth' });
  };
  return (
    <div>
      <div>Count2: {state.count2}</div>
      <button onClick={incrementBoth}>Increment both</button>
    </div>
  );
};

Notice the state part is changed from the previous code. The dispatch part is not changed. This approach eliminates selector functions, and it’s hardly misused. One big concern is performance optimization. It’s out of the scope of this post, but according to some benchmarks, it’s actually fairly good. Please checkout the other post if you are interested.

The libraries with this approach: react-tracked

Closing notes

The example might be too artificial, but I hope it explains the differences. Personally, I would use any approaches depending on cases and their requirements.

As a final note, the second purpose of this post is to let readers know the last approach, “state usage tracking.” I hope you get it.

comments powered by Disqus