Minimalist React state management, the missing kind

2025-09 Alexander Tkačenko

Over the years, React developers have shifted from the ubiquitous heavyweight state management setup with Redux (which, in fact, wasn't meant to be that ubiquitous) to more lightweight approaches, like Zustand and Jotai. Redux itself has followed the trend by transforming into a more streamlined variety, Redux Toolkit.

With the relative abundance of existing approaches to state management in React encompassing a spectrum of complexity, dealing with state management still feels cumbersome.

What exactly feels off? Let's see what a minimalistic approach could mean to a React dev. With the React's local state mental model of useState() in mind, a React dev could expect that a shared state management approach:
1. has a simple API introducing a minimal set of new concepts,
2. requires minimal code changes to move local state to shared state,
3. straightforwardly supports SSR without workaround APIs.

These qualities matter even with AI-generated code, since leaner code (that these points are ultimately about) is easier to review and debug. The mainstream approaches to React state management appear to dodge these points either entirely or partially.

Redux Toolkit and MobX

Redux Toolkit and MobX follow their own distinct paths. These libs come with their own extensive sets of concepts: store, actions, reducers, slices, selectors, thunks in RTK, and stores, observables, observers, actions, reactions, computeds in MobX. Moving local state to shared state requires a complete rewrite of state handling. Even without the actual need for rewrites of local state, these approaches require a major paradigm shift compared to the local state mental model that React devs are initially familiar with. Whatever their motives are, RTK and MobX obviously don't offer a minimal path to shared state evading all the points listed above.

Zustand

The Zustand's API is pretty minimalistic, so the first point from the list above is fulfilled. Yet the hook store API is significantly different from the React's local state, which complicates migration from local state to shared state and, again, introduces a substantial paradigm shift. So the second point is unmet, despite the apparent conciseness of the API.

The Zustand's SSR setup requires using vanilla stores instead of the hook stores used otherwise. Special-casing of SSR introduces yet another pattern of state management to the code. So the third point from the list above is unmet.

On top of that, personally, I'm not particularly fond of the Zustand's pattern of calling methods on a hook, like useSomeStore.getState() or useSomeStore.subscribe(), which is unidiomatic in React. Otherwise, Zustand is relatively comfortable to work with.

Jotai

The Jotai's useState-like API is the closest match to the criteria outlined above, the first two points are immediately fulfilled. But the special workaround hook required with an SSR setup is disheartening.

Also, the core atom() function is pretty loaded with wired-in features. They won't be tree-shaken even if unused, which is a bit concerning from the minimalist perspective.

Minimalist approach

To varying degrees, all state management libs I've seen would depart from the criteria of a minimalist approach listed above. So this niche remains vacant, and trying to fill it looks like an interesting exercise promising a real benefit of leaner and clearer code.

Let's start with a local state setup and lift the initial value to a shared location:

  // Non-interactive shared initial state
+ const AppContext = createContext(0);

  const CounterButton = () => {
    // Local state, invisible to other components
-   const [count, setCount] = useState(0);
+   const [count, setCount] = useState(useContext(AppContext));

    const handleClick = () => {
      setCount((value) => value + 1);
    };
    
    return <button onClick={handleClick}>[+] {count}</button>;
  };

The initial state value can now be seen by other components via the Context, but the state setter setCount() won't affect the shared value yet.

To make the shared state interactive we'll pack it into a container object, we'll call it State, exposing a state setter, so we don't have to manually add a value setter to the Context. To unpack the value from the State instance and subscribe to its changes inside the component we'll add a self-explanatory hook useExternalState(). With the state instance and the hook, we'll get a full-fledged shared state setup:

  // Full-fledged interactive shared state
+ const AppContext = createContext(new State(0));
- const AppContext = createContext(0);

  const Counter = () => {
+   const [count, setCount] = useExternalState(useContext(AppContext));
-   const [count, setCount] = useState(useContext(AppContext));

    const handleClick = () => {
      setCount((value) => value + 1);
    };

    return <button onClick={handleClick}>[+] {count}</button>;
  };

Now, calling setCount() will update the shared count value. However, setCount() doesn't modify the state instance itself, so the state updates don't cause re-renders in the entire Context holding the state. Only the components subscribed to the particular state instance by means of the useExternalState() hook will be notified to re-render when the state is updated. So we've got what we were after: an interactive shared state with targeted subscription to updates.

Note how little has changed in the example above compared to the React's useState() setup: we've got the same API for reading and updating the count value, so most of the code remains unaffected.

Aligned with the React's own local state model, this shared state setup, in contrast to many other approaches, keeps to the minimalist spirit and abides by the Occam's razor principle: it doesn't introduce new concepts and reuses existing patterns (both with CSR and SSR). Having a single set of patterns for both local and shared state greatly reduces the cognitive load to process the code and facilitates common scenarios like migrating from local state to shared state (or the other way around, if necessary).

To complete this exercise, I created an NPM package, react-statepack, that exports such State and useExternalState() as discussed above. The lib's description covers their common use cases including a single-instance and multi-instance state setup (both being perfectly legit), rendering optimizations such as filtering state updates, and provides some live examples.