Stores: Making State Global Without React's Context API

23 February 2021

tldr; If you just wanna see how to implement a store, check out the demo here. If you plan on utilizing them in your React app, check out zustand.

I often come across application code catering to the needs of React's Context API to make state globally accessible throughout an application. However, just because something is provided to us by the React team doesn't mean it is immune to having use cases it's built for and others for which it is not.

Lately, you may have noticed a second generation of Store-like libraries for React that are deceivingly simple and take inspiration from older approaches, similar to what MobX and unstated did for React in the past. Among these libraries are zustand and valtio, which promise to bring reactive state encapsulation to React, just like it's seen in Svelte or Vue 3.

In this article we're going to explore some drawbacks of context-based state in typical application architecture, then explore an alternative construct, Stores, which should ultimately help us write cleaner code and increase our in-app performance. First, though, let's dive into how I typically see React's Context API used in application code.

This Old Song and Dance?

So we have a component that needs to get and alter some data in our application. Great, let's use a useState hook to hold and access state:

const Foo = () => {
  const [count, setCount] = React.useState(0);
  return (
    <>
      <h1>{count}</h1>
      <button onClick={() => setCount(v => v + 1)}>Click Me!</button>
    </>
  );
};

Ope, looks like we need to reference that same data in a component that will be rendered as a distant relative. All right, let's hoist the state to a common ancestor and make it a provider so we don't have to do so much prop drilling.

🙈 In a less contrived example, they'd be far apart. Let's imagine we're already passing a lot of API data around and are sharing some UI state across our app:

const CountContext = React.createContext(null);
const CountProvider = ({ children }) => {
  const value = React.useState(0);
  return (
    <Context.Provider value={value}>
      {children}
    </Context.Provider>
  );
};

const Foo = () => {
  const [count, setCount] = React.useContext(CountContext);
  return (
    <>
      <h1>{count}</h1>
      <button onClick={() => setCount(v => v + 1)}>Click Me!</button>
    </>
  );
};

const Bar = () => {
  const [count] = React.useContext(CountContext);
  return <h2>{count % 2 ? 'Even' : 'Odd'}</h2>
};

const Buz = () => (
  <CountProvider>
      <SomeComponentThanContainsFoo />
      <SomeComponentThanContainsBar />
  </CountProvider>
);

... and while we're at it, might as well move our provider to the top level so it's easy to track and make available to any components that wish to utilize it.

const Buz = () => (
    <>
      <SomeComponentThanContainsFoo />
      <SomeComponentThanContainsBar />
    </>
);

const Layout = (
  ...
  <Buz />
  ...
);

const App = () => (
  <CountProvider>
    <Layout />
  </CountProvider>
);

Rinse and repeat this process several times over the course of multiple years and multiple team members with new features and growing demands. Pretty soon you'll have some nasty index file that looks like this. 🤮

const App = () => (
  <Context1Provider>
    <Context2Provider>
      <Context3Provider>
        <Context4Provider>
          <Context5Provider>
            <Context6Provider>
              <Context7Provider>
                <Context8Provider>
                  <Context9Provider>
                    <Context10Provider>
                      <Layout />
                    </Context10Provider>
                  </Context9Provider>
                </Context8Provider>
              </Context7Provider>
            </Context6Provider>
          </Context5Provider>
        </Context4Provider>
      </Context3Provider>
    </Context2Provider>
  </Context1Provider>
);

What a Bummer 👎

Along with the faint taste of stomach bile we get just from looking this, we're likely to run into other issues. Worse issues. Issues that'll make you wanna ponch your computer right in the face 👊.

Symptoms which may include, but are not limited to:

  • Implicit dependencies between providers
  • Implicit provider dependencies for storybook implementations and unit tests
  • Performance bottlenecks from provider value updates
  • Excessive application wide re-renders
  • Excessive stomach discomfort

But hey, these are reasonable measures, right? We can separate out our dispatch and state into separate providers. Make sure context values are memoized. Heck, maybe even take a bump of Aspirin and a shot of Pepto Bismol before we start on our feature work for the day.

If you've worked on a large-scale React project, you've probably seen this exact scenario play out. Along with its blemishes, chances are it has consistently and reliably pushed out a sufficient project for you and your stakeholders time and time again. After all, Don't hate. Appreciate 🙏

But that Sh*t is Urgly.

Hell yeah it is. Maybe the reason we're coming across all this code stank is that we are trying to use something in a way it's not intrinsically meant to be used. Maybe the Context API is great for hoisted state, but not to share local state? Maybe the API encourages us to take a suboptimal approach for a simple problem?

With all these questions weighing so deeply on our conscience, let's take a sec and meditate. Think about our life, love, and deepest desires. ...

....

... Woof. That was nice.

So What Do We Want?

const SomeComponent = () => {
  // Ooo doesn't this feel magical?
  const { someAction, anotherAction, someState } = useSomeGlobalState();
  ...
}

👆 This. This is all we want.

Remember, with Great Power Comes Great Foot Guns.

Don't get me wrong, I think Context is a great tool, but I'm not so sure we're often using it for its intended purpose.

Context's wondrous power is gained from being tied to markup. State living hand-in-hand within various subtrees of markup that change compositionally or ephemerally. The issue is, we're living with the tradeoffs of coupling our state to our element tree's structure, but we seldom utilize the potential for which we are handicapping ourselves.

But if we shouldn't use Context to share state across our app, what should we use? Let's look to Bhagavan Sebastian of the Twitter u-verse for guidance:

A Store, You Say? Let's Build It.

Okay, so first things first. To build a store we're going to need to do a few things:

  • Hold data somewhere once
  • Provide a way to interact with this data
  • Schedule renders properly such that you're not rendering stale data

Let's start with holding data somewhere. We need to hold it once, and it needs to be extensible to each use case. Sounds like a nice opportunity to use a factory function to create a hook that can access the data in its closure:

const createStore = (store) => {
  const useStore = () => {
    return store;
  };
  return useStore;
};

Okay, so this is nice. Holds our state in a single place. However, there's no way to interact with it. Let's give it the ability of introspection by utilizing inversion of control. We'll pass it an initialization function instead of an initial store object, and that initialization function will take methods to introspect on itself, then return our initial store:

const createStore = (init) => {
  let store = null;
  const get = () => store;
  const set = (operation) => (store = operation(store));

  // Beautiful. Our store now has a way to get
  // and set itself when we initialize it
  store = init(get, set);

  const useStore = () => {
    return store;
  };
  return useStore;
};

However, this still isn't meeting our third requirement: Scheduling renders correctly in React. With the current implementation, if we were to call an action provided to us by the store, React would have no idea to re-render this hook in another component using the same global store.

// This button updates our global store on click
const Increment = () => {
  const { increment } = useStore();
  return <button onClick={increment}>increment</button>
};

// This button updates our global store on click
const Decrement = () => {
  const { decrement } = useStore();
  return <button onClick={decrement}>decrement</button>
};

// Our store gets updated, but React has no way
// of knowing when this happens. So it renders
// some stale store data here.
const Count = () => {
  const { count } = useStore();
  return <h1>{count}</h1>
};

const App = () => (
  <div>
    <Count />
    <Increment />
    <Decrement />
  </div>
)

To fix the code above, we need to force renders from our generated hook, such that a render occurs when the global store gets updated. Let's use an emitter for our notification system, create a cloned, local version of the global store in state, and reflect any changes to the global store in our local instances of it.

const createEmitter = () => {
  const subscriptions = new Map();
  return {
    emit: (v) => subscriptions.forEach(fn => fn(v)),
    subscribe: (fn) => {
      const key = Symbol();
      subscriptions.set(key, fn);
      return () => subscriptions.delete(key);
    },
  }
};

const createStore = (init) => {
  // create an emitter
  const emitter = createEmitter();

  let store = null;
  const get = () => store;
  const set = (op) => (
    store = op(store),
    // notify all subscriptions when the store updates
    emitter.emit(store)
  );
  store = init(get, set);

  const useStore = () => {
    // intitialize component with latest store
    const [localStore, setLocalStore] = useState(get());

    // update our local store when the global
    // store updates. 
    //
    // emitter.subscribe returns a cleanup 
    // function, so react will clean this
    // up on unmount.
    useEffect(() => emitter.subscribe(setLocalStore), []);
    return localStore;
  };
  return useStore;
};

Let's walk through those three major additions real quick to grasp exactly what we just did. First is updating our set method to perform emissions here:

const set = (op) => (
  store = op(store),
  // notify all subscriptions when the store updates
  emitter.emit(store)
);

This essentially makes our set method act like a dispatch call. It allows all subscriptions to be notified whenever a change has occurred.

Next we create our local clone of the store here:

// intitialize component with latest store
const [localStore, setLocalStore] = useState(get());

Copying our store into state provides us with an update mechanism. This allows us to notify our consuming components to re-render when the localStore updates.

Lastly, we have to reflect changes to the global store in our local store:

// update our local store when the global
// store updates. 
//
// emitter.subscribe returns a cleanup 
// function, so React will clean this
// up on unmount.
useEffect(() => emitter.subscribe(setLocalStore), []);

This effect runs once on the initial mount and basically taps our local store into the global store. Any changes that occur in the global store, through its set method, will now notify all consumers of this hook to to update their local store and reflect the changes.

Oh Hot Damn. Did We Just Create Global State Without Context?

Why yes. Yes we did.

const useCountStore = createStore((get, set) => {
  count: 0,
  increment: () => set(store => ({ ...store, count: store.count + 1 })),
  decrement: () => set(store => ({ ...store, count: store.count - 1 }))
});

const Increment = () => {
  const { increment } = useCountStore();
  return <button onClick={increment}>increment</button>
};

const Decrement = () => {
  const { decrement } = useCountStore();
  return <button onClick={decrement}>decrement</button>
};

const Count = () => {
  const { count } = useCountStore();
  return <h1>{count}</h1>
};

const App = () => (
  <div>
    <Count />
    <Increment />
    <Decrement />
  </div>
)

Beautiful! Brings a tear to my eye 😿. Now let's go through some of the benefits of our shiny new tool.

The Fruits of Our Labor 🍑

In creating a solution that only tries to do what we need, and nothing more, we're gonna see some noticeable improvements in the following areas:

Co-Location

First off, Hooks were built with co-location in mind. Keeping related code closer together. With a store, if we decide we need some state to persist globally, it's easy to add it right then and there with its associated logic. No more navigating to a file containing markup to make state globally accessible.

Untied from Markup

Since we're no longer tied to our markup, we can connect multiple parts of the app (components) without deciding on an arbitrary grouping. Sure, we can distribute this via Context, but as zustand and others show (like MobX did before), we don't need a Context parent to connect state in our application from A to B. We don't need to necessarily decide where to hoist state to if we can teleport it from site to site.

With stores, state is truly shareable with any component. The state is there, scoped to a module and waiting to be accessed at your component's leisure. Since it is instantiated when you are creating the hook to access it, you no longer have to consider behavior where your global state is inaccessible because of the markup you render.

Avoiding Needless Renders

This is honestly my biggest reason for advocating the necessity of stores in a codebase: I know a lot of us are able to create applications with perfectly fine performance metrics using Context, but the web is advancing, and demands are growing.

As we evolve into the future, stakeholders are going to start asking for more motion-rich user experiences. Entrance and exit animations for components as we switch routes or as parts of our state update.

If you have a large-scale application using context-based state, chances are that most top-level context updates cause React's reconciliation to eat up the 100ms budget you have before there's a noticeable lag to the human eye. With this in mind, even low-cost animated transitions would shine a light on noticeable jank pre-existing in your application.

With stores, as global state updates occur, only nodes subscribed to that store and their children actually get diffed by React. This allows React to remain performant and interactive as we keep global state subscriptions closer to the leaf elements of our applications.

Implementation-Agnostic

Stores are unopinionated on how you manage your state. They simply don't care how you do what you do. This isn't really an improvement from Context (since it also doesn't care how you manage your state), but it is still worth mentioning.

Here's a quick example with a reducer for reference:

const initial = 0;

const reduce = (state, action) => {
	switch (action.type) {
		case 'increment':
			return state + 1;
		case 'decrement':
			return state - 1;
		default:
			return state;
	}
}

const useStore = createStore(
	(get, set) => ([
		state: initial,
		dispatch: (action) => set(store => ([
			reduce(store.state, action),
			store.dispatch
		])),
	])
);

const Counter = () => {
  const [state, dispatch] = useStore();
  return (
		<>
			<h1>{state}</h1>
			<button onClick={() => dispatch('increment')}>increment</button>
			<button onClick={() => dispatch('decrement')}>decrement</button>
		</>
	);
}

Now that we've gone through some exquisite benefits, let's look at a couple existing implementations of stores out in the wild.

Prior Art 🖼️

Coming in at <1kB compressed and gzipped, zustand is gonna be hard beat. It provides a robust API, and some nice-to-haves, like the ability to compose middleware, memoization with selectors and TypeScript support, to name a few. It has widespread adoption and is how I came into contact with the concept of a bare-bones store. 🐻.

It's worth pointing out another article, similar to this one, by @dai-shi the state management lead of the Poimandres collective, which currently runs zustand, valtio, & jotai. Check it out if you want some extra technical details and considerations (like playing nice with concurrent mode).

Okay, So I Shouldn't Use Context?

No, you absolutely should. Like I said, it's a great tool. However, the question we should ask ourselves is "Do I need to use context for this use case?" This can be answered by seeing if our data is related to our markup.

For instance, let's say we have a theme provider in our application and a sub portion of the application mutates the theme for its consumption while other parts of the application do not:

const theme = {
  fg: "palevioletred",
  bg: "white"
};

// This theme swaps `fg` and `bg`
const invertTheme = ({ fg, bg }) => ({
  fg: bg,
  bg: fg
});
...
render(
  <ThemeProvider theme={theme}>
    <div>
      <DefaultThemePortionOfApp />
      <ThemeProvider theme={invertTheme}>
	      <InvertedThemePortionOfApp />
      </ThemeProvider>
    </div>
  </ThemeProvider>
);

https://styled-components.com/docs/advanced#function-themes

This is a great example of state being tied to your markup and compositionally dependent on the subtree it's rendered in.

🎵 So long, farewell, to you my friends. Goodbye, for now, until we meet again 🎵

If you've made it this far, many thanks! I hope you've walked away with the understandings, use cases, and benefits of a store conceptually in the React component model.

If you appreciated listening to my witless banter and slightly coherent ramblings, follow me on twitter @maxyinger. Have a splendid day, and as always, remember to think out of the box 🎁.

Special thanks to my Reviewers:

References

Related Posts

Check out more of Max's blog posts