Alex Vipond
SHIFT + D

The simplest way to share React state without Context

Published 11 months ago

In React, you should use Context to share state between distant components. Relentlessly lean into first-party tools and patterns until you have a good reason not to.

Recently, I had a good reason not to.

A good reason not to use Context

I was recently helping build an experimental new feature, tucked behind a feature flag.

This particular feature is part of a complex, business-critical multi-step form, which defines tons of reactive state in React Context and wires up all kinds of state change, event tracking, other experimental features, network requests, etc. One iffy useEffect dependency list can badly break (and has badly broken 😬) the UI.

The experiment needed to add two buttons that could update some reactive state in that complex Context.

One of the buttons is inside the multi-step form, so that's a cut-and-dry case of just using existing Context state. The other button, though, is in the top navigation bar, which is part of an entirely separate component tree:

Button #2
Button #1

The correct solution here is to lift the reactive state up to a common ancestor of the navigation bar and the multi-step form, and provide it via Context:

Context
Button #2
Button #1

In this case though, that solution was tough to recommend, for a few reasons:

  • This is an experimental feature. If it loses A/B tests, it will be removed, and the need to refactor will be gone.
  • The UI is high-risk. It has a history of misconfigured useEffect timing, type unsafety, hydration errors due to incorrect state management, and other devious bugs.
  • The UI is high-value. It cannot break.

So now we have a challenge: how do you allow two distant components to read and write reactive state without using Context?

The key is that you have to externalize your reactive state. In other words, you have to use an external system to notify distant components when state changes.

External system
Button #2
Button #1

How to externalize React state with no third-party dependencies

There are lots of tools that can help you externalize reactive state, like Zustand or even @vue/reactivity. But for an experimental feature, it's hard to justify adding a new third-party dependency.

Instead, I came up with an ~80-line implementation of a DOM-event-driven state management library, with few cool design decisions:

  • Creating externalized state should look like useState
  • Scheduling side effects of externalized state change should look like useEffect
  • Setting externalized state from distant component tree branches should look like setState
  • Type safety is non-negotiable

For creating externalized state, I wrote a hook called useExternalized:

function MyComponent () {
  // From a DX perspective, `useExternalized` is just `useState`, 
  // but with an additional string to identify this externalized
  // state. The string must be unique across the entire app.
  //
  // In this branch of the component tree, `count` and `setCount`
  // behave exactly as if they were defined with `useState`.
  const [count, setCount] = useExternalized('count', 0)
}

For scheduling side effects, I wrote useExternalizedEffect:

function DistantComponent () {
  // `useExternalizedEffect` is just `useEffect`, but with an
  // additional string to identify the externalized state you're
  // interested in.
  //
  // This effect will run whenever `count` changes, just like
  // a normal `useEffect` would run whenever a normal `useState`
  // changes.
  //
  // Your callback can optionally return a cleanup function,
  // just like `useEffect`. `useExternalizedEffect`'s
  // implementation will prevent stale closures, even without
  // a `useEffect` dependency list.
  //
  // Bonus: inspired by `watch` in Vue, your callback receives
  // parameters for both the current and previous values of the
  // externalized state.
  useExternalizedEffect('count', (current, previous) => {
    console.log('count changed!', current, previous)
  })
}

For setting externalized state from distant component tree branches, I wrote setExternalized:

function DistantComponent () {
  // `setExternalized` is a function that takes the ID of the
  // externalized state you want to set, and the new value.
  //
  // Just like `setState`, the new value can also be a function
  // that receives the current value of the externalized state,
  // and returns the new value.
  const increment = () => {
    setExternalized('count', count => count + 1)
  }
}

To tie these things together with type safety, I implemented the concept of an "externalized registry". This is a TypeScript type that lists all externalized state in the app and defines its shape:

interface ExternalizedRegistry {
  count: number,
  message: string,
  isDarkMode: boolean,
}

useExternalized, useExternalizedEffect, and setExternalized take the state ID that you pass in as the first parameter, look it up in the ExternalizedRegistry, and infer the type of state from there.

ExternalizedRegistry makes it impossible to accidentally duplicate state IDs. It also serves as a central place where you can keep track of all externalized state in your app.

This centralized registry is actually a powerful feature—externalizing state is a funky, non-ideal solution, and it's a good idea to keep tabs on where you're using it, so you can refactor to Context when possible!

Usage

The source code is small, simple, and stable enough that you can just copy/paste it, and customize as needed. It's the shadcn/ui of React state management libraries 😆

After you copy/paste it, put ExternalizedRegistry anywhere in your app, and start filling it up with IDs and types.

Here's the full source, and a live demo:

ON THIS PAGE

The simplest way to share React state without ContextA good reason not to use ContextHow to externalize React state with no third-party dependenciesUsage