---
title: "Do we need state management libraries anymore?"
publishDate: 2026-07-30T00:00:00.000Z
excerpt: "Let's find out by exploring how the most popular libraries are doing it under the hood and then build our own."
category: "react"
tags: ["javascript", "react", "state-management", "zustand", "redux", "jotai", "valtio", "mobx"]
canonical: https://neciudan.dev/do-we-need-state-management-libraries
---

Recently, I recorded a Seniors at Scale episode with Dan Abramov, creator of Readux (I will link it here once it's published), and mid-conversation, he told me that state management libraries are no longer necessary.

I was shocked or flabbergasted (I finally got to use this word).

But after thinking about it for a bit, it kinda makes sense. 

We needed State Management libraries to avoid multiple re-renders that occur with Context, but we actually want to be more precise about how we handle our State.

I wrote about this before in my [component communication patterns article](https://neciudan.dev/component-communication-patterns-in-react), that there are different types of state.

**UI state** is local. A modal being open, a dropdown selection, an input value. `useState` handles this.

**Server state** is a cached copy of data that lives on a backend. It goes stale, it refetches, and is reused by your components as a cache layer. TanStack Query owns this category now (read a gentle intro to TanStack [here](https://neciudan.dev/a-gentle-introduction-to-tanstack-query)).

**URL state** is a snapshot of your app at a given time, which you want to share so you store it in the URL. Usually with libraries like Nuqs. 

**Global state** is state that does not change during the users session, like app theme, currency, location, language, mostly configuration. This should live in React Context.

**Complex shared client state** is the leftover slice which I recommended in my article to use a small State Management library like Zustand.

So if we correctly split out the state into its corresponding buckets and set guardrails to make sure we dont mess up, do we still need a State Management library for that last tiny (or not-so-tiny) slice? 

Let's explore how the famous state libraries do it themselves under the hood, then build our own version and find out.  

## The OG: Redux

Redux is basically a pub/sub store.

Pub/sub is short for publish/subscribe: interested parties register a callback (they subscribe), and when the value changes, the store calls every registered callback (it publishes).

On top of the pub/sub core, Redux adds one rule: the state can only change through a reducer. 

A reducer is a pure function: the same inputs always produce the same output, and nothing outside the function is touched (no side effects). 

The core looks like this:

```ts
function createStore(reducer, initialState) {
  let state = initialState;
  const listeners = new Set<() => void>();

  function getState() {
    return state;
  }

  function dispatch(action) {
    state = reducer(state, action);
    listeners.forEach((listener) => listener());
    return action;
  }

  function subscribe(listener) {
    listeners.add(listener);
    return () => listeners.delete(listener);
  }

  return { getState, dispatch, subscribe };
}
```

I want to be clear that this is not a metaphor for Redux. 

This is, minus edge-case handling, the actual mechanism: `dispatch` runs your reducer to compute the next state, then notifies every listener; `subscribe` registers a listener and returns a function that removes it.

`createStore` runs, declares a `state` variable, and returns three functions; those functions keep access to `state` even after `createStore` has finished (a closure). 

Using it looks like this:

```ts
function counterReducer(state = { count: 0 }, action) {
  switch (action.type) {
    case 'counter/incremented':
      return { ...state, count: state.count + 1 };
    default:
      return state;
  }
}

const store = createStore(counterReducer, { count: 0 });

store.dispatch({ type: 'counter/incremented' });
store.getState(); // { count: 1 }
```

And on the React side, `react-redux` gives you hooks that connect components to that store:

```tsx
function Counter() {
  const count = useSelector((state) => state.count);
  const dispatch = useDispatch();

  return (
    <button onClick={() => dispatch({ type: 'counter/incremented' })}>
      {count}
    </button>
  );
}
```

The `useSelector` hook solves the Context problem by reducing re-renders. 

You pass a function that picks out the slice you care about, and the component only re-renders when that slice changes.

So why bother describing every change as an action object instead of just setting the state?

Because action objects are serializable, meaning they can be turned into plain JSON and back. 

Your app's entire history becomes a log of plain objects, and since reducers are pure functions, replaying that log always produces the same state.

That's what makes the Redux DevTools possible, including time-travel debugging (which was the original reason Dan built Redux in the first place): the DevTools record every dispatched action, and stepping "back in time" just means replaying the log up to an earlier point.

But the downside with old school Redux was that if you wanted to add one boolean toggle meant touching an action type constant, an action creator, a reducer case, and a selector:

```ts
// constants.ts
export const SIDEBAR_TOGGLED = 'ui/sidebarToggled';

// actions.ts
export const toggleSidebar = () => ({ type: SIDEBAR_TOGGLED });

// reducer.ts
case SIDEBAR_TOGGLED:
  return { ...state, sidebarOpen: !state.sidebarOpen };

// selectors.ts
export const selectSidebarOpen = (state) => state.ui.sidebarOpen;
```

Four files for one boolean. Multiply by every field in a large app, and you understand why an entire generation of developers has an emotional reaction to the word "reducer."

The modern answer is Redux Toolkit, which collapses those four files into a single `createSlice` call and generates the actions for you. 

Redux itself now marks `createStore` as deprecated in its docs and points everyone at Redux Toolkit's `configureStore`;

One more thing about `react-redux` before we move on. Since version 8, `useSelector` is built on a React hook called `useSyncExternalStore`, which subscribes to the store, reads your selected slice, and re-renders the component when that slice changes.

Remember that hook name. It's going to keep showing up.

## Zustand

Zustand is what happens when you keep the Redux mechanism but delete its conventions. 

The name is German for "state," and it comes from the Poimandres collective, the same group behind Jotai and Valtio (Daishi Kato is the author, and I have a cool podcast episode with him [check it out here](https://www.youtube.com/watch?v=ns8ith5cu-U&list=PLeeGnEj5psFIwWJfpCwnedMsFApK6CvRr&index=26&t=6s).

The real `createStore` from the Zustand source, lightly simplified:

```ts
const createStore = (createState) => {
  let state;
  const listeners = new Set<() => void>();

  const setState = (partial) => {
    const nextState = typeof partial === 'function' ? partial(state) : partial;

    if (!Object.is(nextState, state)) {
      state = Object.assign({}, state, nextState);
      listeners.forEach((listener) => listener());
    }
  };

  const getState = () => state;

  const subscribe = (listener) => {
    listeners.add(listener);
    return () => listeners.delete(listener);
  };

  const api = { setState, getState, subscribe };
  state = createState(setState, getState, api);
  return api;
};
```

It's basically the same as Redux, except that `setState` replaces `dispatch` and the reducer. 

You update the state directly with a partial object rather than describing the update as an action, so there is no action log and therefore no time-travel utilities.

`Object.is` is JavaScript's strictest equality comparison, and for objects it answers one question: are these two references *the same object in memory*? 

Two objects that look identical but were created separately are not `Object.is`-equal.

Zustand uses it here in case you set the same state reference again, nobody gets notified.

The clever part is the last two lines of `createStore`. 

Your `createState` function receives `set` and `get` as arguments, which is why a Zustand store can define its actions inline, next to the state they modify:

```ts
const useBearStore = create((set) => ({
  bears: 0,
  increase: () => set((state) => ({ bears: state.bears + 1 })),
}));

function BearCounter() {
  const bears = useBearStore((state) => state.bears);
  const increase = useBearStore((state) => state.increase);

  return <button onClick={increase}>{bears} bears</button>;
}
```

If you're wondering how `increase` can receive `set` before `state` even exists (look at the source again: `state` is only assigned on the last line, *after* `createState` runs), the answer is that nothing runs yet. 

Your actions only capture a reference to the `set` function; by the time anyone clicks the button and `increase` actually executes, `state` has long been assigned.

The `create` function wraps `createStore` and returns a hook. The hook subscribes to the store with `useSyncExternalStore` (again), reads `selector(getState())`, and re-renders when the selected value changes; the same selector idea as `useSelector`, without the Provider, actions, or reducer.

The whole library weighs around a kilobyte gzipped. 

## Jotai

Redux and Zustand are top-down: there's one store, and components select slices from it. Jotai inverts this. 

Instead of one store you select from, you have many small atoms you compose together, and the state graph is built bottom-up.

The idea is not original. 

Meta released Recoil in 2020, the first atom-based library for React, and Recoil required every atom to have a globally unique string key. 

Those string keys collided across code-split bundles, leaked between tests, and generally caused the kind of pain string-based identity always does. 

Meta stopped developing Recoil and archived the repository in early 2025.

Jotai took the atom idea and fixed the identity problem in one move: the atom object itself is the key, and an atom holds no state.

```ts
export function atom(read) {
  if (typeof read === 'function') {
    return { read };
  }
  return { init: read };
}
```

`atom(0)` returns `{ init: 0 }`. A config object. 

You can call `atom()` at the module level, in a component, inside another atom, and all you've created is a small object that describes a piece of state.

The values live elsewhere, in a `WeakMap` inside the store, keyed by the atom config itself:

```ts
const atomStateMap = new WeakMap();

function getAtomState(atom) {
  let atomState = atomStateMap.get(atom);

  if (!atomState) {
    atomState = {
      value: atom.init,
      listeners: new Set(),
      dependents: new Set(),
    };
    atomStateMap.set(atom, atomState);
  }

  return atomState;
}
```

Atoms are identified by reference, so there are no string keys to collide, and when an atom is no longer imported anywhere, its state gets garbage collected along with it.

The interesting machinery kicks in with derived state:

```ts
const countAtom = atom(0);
const doubleAtom = atom((get) => get(countAtom) * 2);
```

How does Jotai know that `doubleAtom` depends on `countAtom`? It doesn't parse your function or ask you to declare anything. 

It finds out by *running* the function and watching what it asks for:

```ts
function readAtom(atom) {
  const atomState = getAtomState(atom);

  // primitive atom: just return the stored value
  if (!atom.read) {
    return atomState.value;
  }

  // derived atom: run the read function with a tracked `get`
  const get = (dependency) => {
    getAtomState(dependency).dependents.add(atom);
    return readAtom(dependency);
  };

  return atom.read(get);
}
```

The `get` that Jotai hands to your read function is being watched (Big Brother style). 

Every atom you `get` during the evaluation records the current atom as its dependent, so after `doubleAtom` runs once, `countAtom` knows that `doubleAtom` needs to hear about its changes.

```ts
function writeAtom(atom, newValue) {
  const atomState = getAtomState(atom);
  atomState.value = newValue;

  atomState.listeners.forEach((listener) => listener());

  atomState.dependents.forEach((dependent) => {
    recomputeAtom(dependent); // re-run its read, then notify ITS dependents
  });
}
```

When you update `countAtom` the store walks the dependents graph: re-evaluate `doubleAtom`, notify anything listening to `doubleAtom`, then anything depending on *that*, cascading through the affected branch of the graph while the rest stays untouched.

And because dependencies are re-recorded on every read, they can be conditional. 

An atom that only calls `get(userAtom)` when `get(loggedInAtom)` is true simply stops depending on `userAtom` when the condition changes.

In a component, atoms feel almost identical to `useState`:

```tsx
function Counter() {
  const [count, setCount] = useAtom(countAtom);
  const double = useAtomValue(doubleAtom);

  return (
    <button onClick={() => setCount((c) => c + 1)}>
      {count} doubled is {double}
    </button>
  );
}
```

`useAtom` returns a value and a setter, deliberately mirroring `useState`; `useAtomValue` is the read-only half for when a component only displays a value and has no business updating it.

Btw `useAtom` is the one integration in this list that is *not* built on `useSyncExternalStore`. We will discuss why a little later.

## MobX

It's been around since 2015, just like Redux, and the two represented opposite philosophies from day one. 

Redux said, "Make every change explicit." MobX said, "Just mutate, we'll figure out who cares."

This model has three pieces. 

Observables are your state. Computed values are derived from observables. Reactions are side effects that rerun when the observables they depend on change.

```tsx
import { makeAutoObservable } from 'mobx';
import { observer } from 'mobx-react-lite';

class TimerStore {
  seconds = 0;

  constructor() {
    makeAutoObservable(this);
  }

  get minutes() {
    return Math.floor(this.seconds / 60);
  }

  tick() {
    this.seconds += 1;
  }
}

const timer = new TimerStore();

const TimerView = observer(() => <span>{timer.minutes}:{timer.seconds % 60}</span>);
```

`makeAutoObservable` walks the instance and converts everything: fields become observables, getters become computed values (cached, recomputed only when their inputs change), and methods become actions, which batch their mutations so that observers are notified once at the end rather than after every assignment.

The `observer` wrapper is where the magic happens:

```ts
let currentListener: (() => void) | null = null;

function observable(target) {
  const listenersByProp: Record<string, Set<() => void>> = {};

  return new Proxy(target, {
    get(obj, prop) {
      if (currentListener) {
        // ??= creates the Set for this property if it doesn't exist yet
        (listenersByProp[prop] ??= new Set()).add(currentListener);
      }
      return obj[prop];
    },
    set(obj, prop, value) {
      obj[prop] = value;
      listenersByProp[prop]?.forEach((listener) => listener());
      return true;
    },
  });
}

function autorun(fn) {
  currentListener = fn;
  fn();
  currentListener = null;
}
```

Check it out, it's using proxies.

A `Proxy` is a built-in JavaScript object that wraps another object and intercepts operations on it; the `get` trap fires on every property read, and the `set` trap fires on every property write.

Let's walk through what happens. `autorun` sets the global `currentListener` to your function and runs it. While it runs, every observable property it reads fires the `get` trap, which sees a listener is active and records it *for that specific property*. 

When the function finishes, tracking switches off.

Later, when someone changes a property, the `set` trap fires and notifies only the listeners who read that property, not anyone else.

Twenty lines, and you have automatic per-property dependency tracking, without selectors, dependency arrays, or declarations of any kind. 

The system knows what you depend on because it watched you read it.

MobX reacts to any observable property that is *read during the execution of a tracked function*.

Here are 2 bugs that can appear if you do not follow the rule:

```tsx
// Bug 1: destructuring outside a tracked function copies a plain value
const { seconds } = timer; // read happens HERE, module level, nothing listening

const TimerView = observer(() => {
  return <span>{seconds}</span>; // renders a plain copied number, never updates
});
```

Destructuring *inside* the observer's render is fine, because the read happens while tracking is on. 

But destructure at the module level, or in a parent component that isn't an observer, and you've copied a number into a variable; the observable read already happened where no listener was recording, so the component will never hear about updates.

```ts
// Bug 2: console.log is async about formatting objects
autorun(() => {
  console.log(message); // logs the object, but never READS message. title
});

message.title = 'new title'; // autorun does not re-run
```

The autorun never dereferenced `.title`, so it doesn't depend on it. 

The console showing the updated title later is the browser lazily formatting the object, which makes you think the tracking is broken.

Since React 18, `mobx-react-lite` also uses `useSyncExternalStore` to deliver notifications, keeping a global version number that bumps with each mutation so React can detect changes safely during concurrent rendering.

## Valtio

Valtio (also by Daishi Kato) is what you get when you take MobX's read-tracking idea and rebuild it around immutable snapshots, which happen to be the one thing React is happiest rendering.

The API:

```tsx
import { proxy, useSnapshot } from 'valtio';

const state = proxy({ count: 0, text: 'hello' });

function Counter() {
  const snap = useSnapshot(state);
  return <button onClick={() => ++state.count}>{snap.count}</button>;
}
```

There are no actions, no setters, and no selectors; you write `++state.count` in an event handler and the right components update.

Three mechanisms make this work, stacked on top of each other.

**The write proxy.** `proxy()` wraps your object in a `Proxy` whose `set` trap fires on every mutation, recursively wrapping nested objects so that `state.user.address.city = 'Berlin'` is caught too. 

**Snapshots.** React can't safely render a mutable object that might change mid-render, so Valtio maintains a second representation. `snapshot(state)` produces an immutable, frozen copy.

**The read proxy.** `useSnapshot` doesn't hand you the snapshot directly. It wraps it in *another* proxy, a read-tracking one from a package called `proxy-compare`, and while your component renders, that proxy records every property path you touch, so it knows the component read `snap.count` and never went near `snap.text`. 

On the next store change, Valtio compares old and new snapshots only at the paths you accessed, and if nothing you read changed, your component doesn't re-render.

The two-object design does create Valtio's number one problem. 

There's a live proxy for writing and a snapshot for reading, and mixing them up can backfire:

```tsx
function Counter() {
  const snap = useSnapshot(state);

  return (
    <button
      onClick={() => {
        // WRONG: snap is frozen, this throws (or silently fails)
        // snap.count++;

        // RIGHT: mutate the proxy, read the snapshot
        if (state.count < 10) state.count++;
      }}
    >
      {snap.count}
    </button>
  );
}
```

The rule of thumb is to read from the snapshot in render, read, and write through the proxy everywhere else. 

And underneath it all, `useSnapshot` subscribes to the write proxy and passes the snapshots to React through the same hook as everyone else: `useSyncExternalStore`.

## Every road leads to useSyncExternalStore

Let's line up the five libraries and their three philosophies.

Redux and Zustand are pub/sub stores with selectors; you tell them which part of the state you read. 

Jotai is a dependency graph of atoms; you subscribe to the pieces directly, and derivation is tracked by running your code. 

Valtio and MobX are proxy-based; they watch which properties you read and build your subscriptions for you.

But how does the store find out which part you're interested in?

And with the one Jotai asterisk from earlier, they all hand their answer to React through the same public React hook.

`useSyncExternalStore` shipped with React 18, and as its name implies, it syncs any "External store", which means any source of state that lives outside React's own `useState` and `useReducer`.

Its contract has three functions:

```ts
const value = useSyncExternalStore(
  subscribe,          // how to listen for changes
  getSnapshot,        // how to read the current value
  getServerSnapshot,  // what to return during server rendering
);
```

React calls `subscribe` with a callback, and the store promises to invoke that callback whenever anything changes. 

React then calls `getSnapshot`, compares the result to the previous one with `Object.is`, and re-renders the component if the value changed.

The third argument is for server-side rendering. 

The server renders your app to HTML with no browser and no live store; the browser then downloads that HTML, and React performs hydration, attaching event handlers to the existing markup while re-rendering the tree in memory and checking that it matches. (For more on hydration, check out my in-depth article [Different hydration strategies](https://neciudan.dev/hydration-and-rendering-strategies)

But the real reason this hook exists, the problem you cannot cleanly solve from your own code outside React, is **tearing**.

Since React 18, rendering is concurrent, meaning React can pause a render mid-tree to handle something more urgent and resume later. 

Now, imagine we have a store with `count = 5`. React starts rendering, your `<Header>` reads the store and renders "5". React pauses. During the pause, an event fires, and the store updates to `6`. React renders `<Sidebar>` from the store, which displays "6".

The commit finishes, and your user is looking at a screen where the header says 5 and the sidebar says 6, rendered from the same store in the same pass. 

That's tearing. 

With React state, this can't happen because React snapshots its own state per render; an external store offers no such guarantee.

`useSyncExternalStore` fixes this. It checks whether the store changed while a render was in flight, and if so, forces a synchronous re-render with the latest, consistent value before the user can see the torn frame.

But concurrent React offers two headline features: time slicing, which breaks a large render into chunks so the browser stays responsive in between, and transitions, which mark an update as non-urgent with `startTransition` so React can keep the current screen interactive while preparing the next one in the background.

Updates that come through an external store get neither. They must be applied synchronously to preserve the consistency guarantee we just described, so React can't slice or deprioritize them. 

External stores are safe in concurrent React, but they're second-class citizens of it.

State that lives *inside* React can be interrupted, deprioritized, and sliced, and state outside it cannot. 

The React team will tell you this is a reason to keep state in React whenever possible.

And now Jotai's footnote from earlier makes sense: this is the trade it refused. By subscribing through `useReducer` instead of this hook, Jotai keeps transitions and time slicing working, at the price of tolerating the brief inconsistency the hook exists to prevent.

## Putting on the builder hat

We saw the examples of how each library works. Let's write our own.

The store comes first, and by now you could probably write it from memory:

```ts
export function createStore<T>(initialState: T) {
  let state = initialState;
  const listeners = new Set<() => void>();

  return {
    getState: () => state,
    setState: (partial: Partial<T>) => {
      state = { ...state, ...partial };
      listeners.forEach((listener) => listener());
    },
    subscribe: (listener: () => void) => {
      listeners.add(listener);
      return () => listeners.delete(listener);
    },
  };
}

export const store = createStore({ count: 0, user: null as User | null });
```

This already works outside React. You can call the store.setState({ count: 1 })` from anywhere, an event handler, a WebSocket callback, a test, and every listener hears about it.

Now the React side:

```tsx
function useStore() {
  // the reducer state is a throwaway counter; incrementing it
  // is just the standard trick to force React to re-render
  const [, forceRender] = useReducer((c) => c + 1, 0);

  useEffect(function subscribeToStore() {
    return store.subscribe(forceRender);
  }, []);

  return store.getState();
}
```

Subscribe on mount, force a re-render on every store change, and read the state during render. 

It works in the happy path, but it has two problems, one obvious and one sneaky.

The sneaky one is a race condition. `useEffect` runs after the browser paints, so there is a window of time between your component rendering and the subscription existing. 

If the store updates inside that window, from another component's effect, for example, this component has already rendered the old state and will never be told about the new one.

It's stuck with stale state until something else happens to update the store again.

### Selectors

We know the fix from Redux and Zustand: let the component declare what it reads.

```tsx
function useStore<T>(selector: (state: StoreState) => T): T {
  const [selected, setSelected] = useState(() => selector(store.getState()));

  useEffect(function subscribeToStore() {
    return store.subscribe(() => {
      setSelected(selector(store.getState()));
    });
  }, []);

  return selected;
}

// usage
const count = useStore((state) => state.count);
```

`useState` bails out of re-rendering when the new value is `Object.is`-equal to the old one, so a `count` change no longer re-renders the `user` component. 

One problem down.

Except I just introduced a bug. 

The effect has an empty dependency array but uses `selector` from the render scope, so the subscription captures the *first* selector this component ever rendered with, forever.

Pass an inline arrow that closes over a prop, and the store will keep calling the stale version long after the prop has changed:

```tsx
// `filter` changes, but the subscription still filters by the old value
const todos = useStore((state) =>
  state.todos.filter((todo) => todo.status === filter)
);
```

Fine, then let's add `selector` to the dependency array. 

But an inline arrow is a brand-new function on every render, so now the effect tears down and resubscribes on every render of the component. 

We traded a stale closure for subscription churn.

You can escape with a ref that always holds the latest selector, and that's roughly what libraries did for years before React 18. 

And notice what we still haven't solved: the mount race from the previous section is still there, and we have no defense against tearing, because our hook reads `store.getState()` whenever React happens to call it, and concurrent React makes no promises about when that is.

We have arrived, step by step, at the point where the React team said, "Stop, we'll do this part."

## useSyncExternalStore fixes all three

Replace the whole dance:

```tsx
function useStore<T>(selector: (state: StoreState) => T): T {
  return useSyncExternalStore(
    store.subscribe,
    () => selector(store.getState()),
    () => selector(store.getState()),
  );
}
```

The subscription race is gone because React verifies the snapshot after subscribing and re-renders if it has changed in the gap. 

The stale selector is gone because `getSnapshot` is a fresh closure on every render, so it always sees the current `selector` and the current props. 

And tearing is handled because that's the hook's raison d'être.

Our custom store is now concurrent-safe, selector-based, and usable from any component.

So obviously, the next step is to break it again.

## The infinite loop

Select two values at once, the way any reasonable person eventually will:

```tsx
const { count, user } = useStore((state) => ({
  count: state. count,
  user: state.user,
}));
```

Open the console, and you get `Maximum update depth exceeded`, or a React warning that the result of `getSnapshot` should be cached. 

The component is re-rendering in an infinite loop.

Our selector returns a fresh object literal on every call. 

React compares snapshots using `Object.is`, and, as we established in the Zustand section, `Object.is` checks whether two values are the same object in memory. 

A new `{ count, user }` literal is never identical, no matter how similar it looks.

So React sees a "changed" snapshot, re-renders, calls `getSnapshot` again, receives yet another new object, concludes the store changed again, and around we go until React pulls the emergency brake.

This isn't a bug in the hook so much as a strict requirement in its contract: snapshots must be referentially stable, the same object back unless something actually changed.

Zustand's answer is an equality function; you opt into shallow comparison with `useShallow`, which says "compare the object one level deep instead of by reference." 

We can build the same thing by caching the last result and returning it when nothing inside has changed:

```tsx
function useStore<T>(
  selector: (state: StoreState) => T,
  equalityFn: (a: T, b: T) => boolean = Object.is,
): T {
  // wrap the cache in an object so a selector legitimately
  // returning `undefined` doesn't look like an empty cache
  const cache = useRef<{ value: T } | null>(null);

  const getSnapshot = () => {
    const next = selector(store.getState());

    if (cache.current && equalityFn(cache.current.value, next)) {
      return cache.current.value;
    }

    cache.current = { value: next };
    return next;
  };

  return useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
}
```

This is, almost line for line, what Zustand's `useShallow` does: it wraps your selector and keeps returning the previous result while the shallow comparison holds. 

One last step. Zustand doesn't make you define the store and the hook separately, so neither will we. 

```tsx
export function create<T>(createState: (set, get) => T) {
  let state: T;
  const listeners = new Set<() => void>();

  const setState = (partial) => {
    const next = typeof partial === 'function' ? partial(state) : partial;
    state = { ...state, ...next };
    listeners.forEach((listener) => listener());
  };

  const getState = () => state;

  const subscribe = (listener: () => void) => {
    listeners.add(listener);
    return () => listeners.delete(listener);
  };

  state = createState(setState, getState);

  function useBoundStore<U>(
    // the default selector returns the whole state; the double cast
    // exists only to satisfy TypeScript, which can't know that U
    // is the same as T when no selector is passed
    selector: (state: T) => U = (s) => s as unknown as U,
    equalityFn: (a: U, b: U) => boolean = Object.is,
  ) {
    const cache = useRef<{ value: U } | null>(null);

    const getSnapshot = () => {
      const next = selector(getState());

      if (cache.current && equalityFn(cache.current.value, next)) {
        return cache.current.value;
      }

      cache.current = { value: next };
      return next;
    };

    return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
  }

  return Object.assign(useBoundStore, { getState, setState, subscribe });
}
```

```tsx
const useCounterStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
}));

function Counter() {
  const count = useCounterStore((state) => state.count);
  const increment = useCounterStore((state) => state.increment);

  return <button onClick={increment}>{count}</button>;
}
```

That's Zustand, in about fifty lines, with no dependencies, safe under concurrent rendering.

Fair warning about the sharp edges we left in: our `setState` always shallow-merges objects, so it can't hold non-object state like a plain number, and there's no `getInitialState` for server rendering. 

The real Zustand handles both.

## So, do we need them?

So, do we need state management libraries anymore? 

For the average app, the honest answer is that server cache, URL state, local state, and fifty lines you understand completely will cover you. 

For apps that truly live in that last slice, collaborative editors, design tools, anything where complex client state is the product, the libraries still justify their install because of how they handle complex edge cases.

Dan built the most successful state management library ever in 2015, and in 2026 told me we dont need it anymore. 

And I think he is right.

## References

- [Zustand](https://github.com/pmndrs/zustand)
- [Redux: createStore](https://redux.js.org/api/createstore) and [Redux Toolkit](https://redux-toolkit.js.org/)
- [Dan Abramov: You Might Not Need Redux](https://medium.com/@dan_abramov/you-might-not-need-redux-be46360cf367)
- [Jotai: Core internals](https://jotai.org/docs/guides/core-internals)
- [Daishi Kato: Why useSyncExternalStore Is Not Used in Jotai](https://blog.axlight.com/posts/why-use-sync-external-store-is-not-used-in-jotai/)
- [Announcing Zustand v5](https://pmnd.rs/blog/announcing-zustand-v5/)
- [Daishi Kato: How Valtio Proxy State Works](https://blog.axlight.com/posts/how-valtio-proxy-state-works-react-part/)
- [MobX: Understanding reactivity](https://mobx.js.org/understanding-reactivity.html)
- [Photoroom: Picking a state management library for a React app used by millions](https://www.photoroom.com/inside-photoroom/picking-a-state-management-library-why-we-went-with-mobx-)
- [React docs: useSyncExternalStore](https://react.dev/reference/react/useSyncExternalStore)
- [TC39 Signals proposal](https://github.com/tc39/proposal-signals)
- [Component communication patterns in React](https://neciudan.dev/component-communication-patterns-in-react)
