How to Manage Global State Without a Library

You don't always need Zustand or Redux. Before reaching for a state management library, understand what the platform gives you — useSyncExternalStore is React's built-in primitive for subscribing to any external store. Here's how to use it to build a lightweight global state system from scratch.
In React Context vs Zustand the argument was: context for low-frequency shared values, Zustand for high-frequency state that multiple components subscribe to selectively.
There's a third option that article didn't cover — one that sits between the two. A plain JavaScript module as a store, wired to React with useSyncExternalStore. No library. No context. No provider to wrap your app in. Just a module that holds state and a hook that subscribes to it.
This isn't the right tool for every situation. But understanding it teaches you what Zustand is actually doing under the hood — and gives you a zero-dependency option for apps that don't need a full library.
The module singleton pattern
JavaScript modules are singletons. A module is initialised once when first imported and that same instance is shared across every import. This makes a module a natural place to store global state.
The store is three pieces:
- State — the module-level variables that hold the data
- Mutations — functions that update the state and call
notify() - Subscription —
subscribeadds a listener that gets called on every change, returns an unsubscribe function
This works completely independently of React. You could call addToast from anywhere — an event handler, a utility function, a fetch response — and the state updates.
Wiring it to React with useSyncExternalStore
useSyncExternalStore is React's built-in hook for subscribing to external stores. It takes three arguments:
subscribe— a function that subscribes to the store and returns an unsubscribe functiongetSnapshot— a function that returns the current stategetServerSnapshot— a function that returns the server-side state (for SSR)
That's the complete hook. When notify() is called in the store, React re-renders every component that called useToasts(). When they re-render, they call getToasts() to read the latest state.
This is exactly what Zustand does internally — it wraps your store with useSyncExternalStore. Understanding this hook means understanding the foundation every external state library is built on.
Why useSyncExternalStore instead of useState
The naive approach to wiring an external store to React is a useEffect that adds a listener and a useState that holds a local copy:
This appears to work but has two problems:
Tearing. In React's concurrent mode, a component might render multiple times during a single update. If the store changes between those renders, different renders might read different state — causing visual inconsistencies. useSyncExternalStore is designed to prevent this.
Stale reads on mount. If the store changes between the first render and when the useEffect runs, the initial state is already stale. useSyncExternalStore reads state synchronously during render, so this gap doesn't exist.
For non-concurrent React these issues are theoretical. For React 18 with useTransition and useDeferredValue, they're real. useSyncExternalStore is the correct primitive.
Building something real: a global toast system
Here's a complete, production-usable toast notification system — no library, no provider, no context. Just a module store and a hook.
Mount ToastContainer once at the app root:
Fire toasts from anywhere — no imports of context, no hooks required at the call site:
No provider. No hook needed at the call site. The toast.success() call updates the store, notify() fires, every useToasts() subscriber re-renders, ToastContainer renders the new toast.
The limits: when to use a library instead
This pattern works well for:
- Simple, well-defined state — a toast queue, a global loading indicator, a shopping cart
- Low-to-medium complexity mutations — add, remove, clear, update by ID
- Apps that want to avoid dependencies — or want to understand what they're depending on
It breaks down when you need:
- Devtools — Zustand and Redux have browser extensions for inspecting state and replaying actions. The module store has nothing
- Middleware — logging, persistence, undo/redo — these require infrastructure that Zustand provides out of the box
- Complex selectors — selecting a derived value from state requires you to implement the memoisation yourself
- Multiple independent store instances — the module singleton is global by definition. Testing components in isolation with different store state requires extra setup
The pattern is a foundation. Zustand builds on the same foundation with more features. If you need those features, use Zustand. If you don't, this is all you need.
The upgrade path
If you start with this pattern and outgrow it, the migration to Zustand is straightforward. The concepts map directly:
Zustand's set is notify() wrapped with an immutable update. The subscription model is the same. The devtools, middleware, and selector system are what you're adding when you make the switch.
Up next: Strict mode in React 18 — what it breaks and why that's good — the development tool that enforces the patterns this series has been teaching.
Related: React Context vs Zustand — when each one actually makes sense — the comparison article that explains when this pattern fits and when you genuinely need a library.
Alex Chen is a senior frontend engineer who writes about React patterns, JavaScript internals, and the decisions that separate maintainable codebases from ones that fight back. Opinionated by design.
Photo by Quinten de Graaf on Unsplash

