createGlobalState(initialValue) returns a hook that behaves like useState, except every component calling it shares one value. You call the factory once at module scope; the value it holds lives in a closure, outside React, and any component can read it and write it without a prop or a provider in sight.
That last part is the whole problem. React re-renders in response to its own state setters. A variable in a module closure can change a thousand times and nothing on screen moves, because nothing told React. So a store outside React needs three things wired by hand: a way to read the current value, a way to subscribe to changes, and a way to tell React that a subscribed component is now out of date.
This is the smallest question in the catalog that has all three. No selectors, no reducers, no atoms — one value, shared.
function createGlobalState<S>(initialValue: S): () => [
S,
(next: S | ((prev: S) => S)) => void,
];
Call the factory once, at module scope, and export the hook:
// store.js
const useCount = createGlobalState(0);
Then any component uses it like useState — with no provider and no props:
function Counter() {
const [count, setCount] = useCount();
return <button onClick={() => setCount((n) => n + 1)}>{count}</button>;
}
// A sibling. Nothing connects it to Counter.
function Badge() {
const [count] = useCount();
return <span>{count}</span>; // clicking the button moves this
}
createGlobalState calls are two independent values. The factory is the store; the hook is a window onto it.useState's contract. setValue takes a value or an updater (prev) => next, and its identity is stable, so it is safe in a dependency array.You'll build a useState that lives outside React, and then do the only hard part: teach React to notice.
Sharing one value between two components that are not related is the oldest problem in React. The official answers all move the value up: lift it to a common parent, or wrap the tree in a context provider. Both work, and both make you restructure your app around where the data lives.
The other answer is to put the value in a module-scope variable and let anyone import it. That takes about four lines and no restructuring, and it has exactly one flaw: nothing re-renders. You can change that variable all day and the screen never moves.
That is not a bug in your code. It is what React is. React re-renders in response to its own state setters, and a variable in a closure is not one of those. So the store is the easy half — a let and a Set — and the entire question is the wire from the store back to React.
React does not know about anything it did not render.
Look at what is not happening in that picture. No error. No warning. The store is correct — count really is 6, and any console.log proves it. The components are simply rendering the last value they were told about, which is the job they have always had.
So a store outside React needs three wires run to it by hand:
Wire 3 is the interesting one, because React gives you no public function called re-render.
So you improvise one. Every component gets a throwaway useState, and the store's job is to poke its setter:
function createGlobalState(initialValue) {
let state = initialValue;
const listeners = new Set();
return function useGlobalState() {
const [value, setValue] = useState(initialValue);
useEffect(() => {
listeners.add(setValue);
return () => listeners.delete(setValue);
}, []);
const setGlobal = (next) => {
state = typeof next === 'function' ? next(state) : next;
listeners.forEach((listener) => listener(state));
};
return [value, setGlobal];
};
}
That local useState holds no state of its own. It is a lever: calling setValue is the only reliable way a non-React caller can make React re-render a component, so every subscriber keeps one just to be pulled.
And it very nearly works. Measured against this question's suite it passes eleven of fourteen tests — two components genuinely do share one value, updaters work, unmounting genuinely unsubscribes. Three fail, and they come from only two bugs:
useState(initialValue) seeds from the argument, not the store. This one costs two tests. Mount a component after the count reaches 42 and it renders 0, then sits there — its setter is subscribed, so it will catch the next write and jump straight from 0 to 43, having never shown 42. It is also why a fresh subscriber mounted after every other one has gone reads 0: measured, the store is still holding 99 that entire time. The new component simply never asks.setGlobal is redefined on every render, so it is a new function each time and useless in a dependency array.Notice what is not on that list. The store is fine. let state and a Set in the factory closure survive every render and every unmount, exactly as intended — the store was always the easy half. Both bugs are in the binding, and the bigger one is a component reading an argument when it should have asked the store.
Fix both and you have a genuinely good hook. You also still have a component keeping a fake useState around for the sole purpose of tricking React into re-rendering.
const { useSyncExternalStore } = require('react');
function createGlobalState(initialValue) {
// The store. It is a closure variable and a Set — that is the whole thing,
// and none of it is React. This code runs happily with nothing mounted.
let state = initialValue;
const listeners = new Set();
// WIRE 1 — read. What should a component render right now? Ask the store,
// never the argument. This is the late-mounter bug fixed at the source.
//
// It must return a CACHED value: React calls it on every render and compares
// the result with Object.is, so a fresh object each call means React sees a
// change every time and loops. Returning `state` itself is what caching
// looks like when the store already holds one immutable value.
const getSnapshot = () => state;
// WIRE 2 — subscribe. React hands us onStoreChange, a callback meaning
// exactly one thing: this component is out of date. Declared out here, once,
// so its identity is stable — a new subscribe function on a re-render makes
// React tear the subscription down and set it up again.
const subscribe = (onStoreChange) => {
listeners.add(onStoreChange);
return () => listeners.delete(onStoreChange);
};
// WIRE 3 — write, then say so. Still no React API in sight.
const setState = (next) => {
const resolved = typeof next === 'function' ? next(state) : next;
// React bails on an unchanged snapshot by itself, so this is not what
// stops the re-render — it just avoids waking every subscriber to find
// that out. Update state BEFORE notifying, or listeners read the old one.
if (Object.is(resolved, state)) return;
state = resolved;
// Copy before iterating. Mutating a Set mid-iteration is fully specified
// and surprising: a listener added during the round would fire in it.
for (const listener of [...listeners]) listener();
};
return function useGlobalState() {
// getSnapshot serves as getServerSnapshot too. This store holds the same
// value on both sides, so there is nothing separate to hand the server —
// but omit the third argument entirely and server rendering throws.
const value = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
return [value, setState];
};
}
module.exports = { createGlobalState };
The hook is now one line, and the shape of the fix is worth saying out loud: the improvised version had React state mirroring a store, two copies of one value kept in step by hand. This version has no copy at all. There is one value, in one place, and useSyncExternalStore reads it during render like any other value the component derives.
setState never touches React, which is why the store still works with nothing mounted — and why the identity test passes for free. It is defined once per createGlobalState call, so it is the same function forever.
This is the honest accounting of what useSyncExternalStore did. It did not simplify getSnapshot or subscribe — you write those by hand in both versions, and they are identical. It took over the third wire, the improvised one, the fake useState that existed only to make React do something. In exchange it asks for the two wires it cannot guess.
const useCount = createGlobalState(0) in store.js, imported by a Counter and a Badge that are siblings and share no props.
useSyncExternalStore calls subscribe(onStoreChange) — the listeners Set now holds React's callback for Counter — and calls getSnapshot(), which returns 0. Counter renders 0.subscribe, a different onStoreChange (it is a different component instance). listeners now holds two. getSnapshot() returns 0. Badge renders 0.setCount((n) => n + 1). Inside setState: resolved is 1, Object.is(1, 0) is false so we continue, state = 1. The store is now correct and the screen is still wrong — this is the exact moment of the first diagram, and it lasts one line.onStoreChange takes no arguments. It means you are out of date, nothing more.getSnapshot() again, gets 1, compares with the 0 it rendered last time, and schedules a re-render. Counter and Badge both render 1. Nobody passed the number anywhere; both components simply asked the store again.setCount(1) again. Object.is(1, 1) is true, so setState returns immediately. No listener is called and nothing re-renders. Delete that guard and the listeners would fire, React would re-read getSnapshot, get 1, compare with 1 and bail — same screen, more work.subscribe returned, which deletes Badge's callback from the Set. Counter keeps working; the store keeps its 1. Unmount Counter too and listeners is empty — and state is still 1, because it was never React's to throw away.Fix those two bugs and the improvised version passes all fourteen tests. So does this one. The tests cannot tell them apart — and that is not a gap in the tests, it is the honest situation. If your app never renders concurrently, the hand-rolled hook is fine, and the projects shipping it are not wrong.
Here is the one thing that separates them.
That is measured, not argued. Force the store to move partway through one render pass and React 19.2.7 commits Header showing 0 next to Badge showing 1 — the real react-use hook, the shape above, and every variant of it. Swap in useSyncExternalStore and both show 1: React re-reads getSnapshot when it finishes, notices the value it rendered with is no longer the value the store holds, throws the pass away and renders again. The recovery costs one extra render of Header and logs nothing.
Two honest caveats, because this is the claim the whole question rests on:
act() React flushes synchronously and never yields, so a startTransition write lands cleanly and both versions agree. Measured, and it is why this test is not in the suite: it would fail a correct hand-rolled implementation for a reason no test in jsdom can honestly show.Worth knowing: React's own documentation page never says tearing. It sells the hook as a way to integrate third-party stores and browser APIs. The reason it exists is in the RFC.
getSnapshot looks like the boring wire. It has the sharpest edge in the API:
// Every call returns a NEW object. Object.is says it changed. Every time.
const getSnapshot = () => ({ ...state });
React calls getSnapshot on every render and compares the result with the last one. A new object always differs, so React re-renders, calls it again, gets another new object, re-renders... Measured, this does not degrade — it crashes, with React's own diagnosis:
Warning: The result of getSnapshot should be cached to avoid an infinite loop
Uncaught Error: Maximum update depth exceeded.
The fix is in the name: cache it. Returning state directly is already cached, which is why the solution above never has to think about it. You hit this the moment you get ambitious — the first thing anyone tries after this question is useGlobalState(selector), and () => selector(state) returning { name, age } is a fresh object per call and an instant crash. That is not a reason to avoid selectors; it is the reason real store libraries take an equality function.
The third argument is not optional here. Measured under renderToString:
Missing getServerSnapshot, which is required for server-rendered content.
Will revert to client rendering.
Passing getSnapshot twice is right for this store, because a module-scope value is genuinely the same on both sides. It stops being right the moment the store reads something the server does not have — window.matchMedia, localStorage, a cookie. Then getSnapshot and getServerSnapshot must be different functions, and the server one must return what the client will find at hydration, or React swaps a correct server render for a client re-render.
This is the one place the improvised version is simpler: react-use's hook server-renders with no third argument and no thought, because a useState seeded from a module variable already works everywhere.
The interesting one is createGlobalState itself, because it is react-use's hook and it is a gift to this question.
react-use 17.6.1 — the current release — does not use useSyncExternalStore. It ships the improvised shape, verbatim: a module-scope store object, a setters array, a useState per subscriber, and a setState that loops the array calling each setter. It is the pre-18 pattern, still shipping, in a library with millions of weekly downloads.
It is also a careful version of it, and the differences from the naive attempt above are exactly those two bugs:
| naive | react-use 17.6.1 | this hook | |
|---|---|---|---|
| initial read | useState(initialValue) | useState(store.state) | getSnapshot() |
| setter identity | new each render | store.setState, stable | setState, stable |
| wire 3 | a useState per subscriber | a useState per subscriber | onStoreChange |
| tears | yes (measured) | yes (measured) | no (measured) |
Run this question's fourteen tests against the real react-use export and all fourteen pass. It is a good hook. Its useIsomorphicLayoutEffect subscribe even closes the render-to-effect gap more tightly than a plain useEffect would.
So the recommendation, stated plainly: use useSyncExternalStore. Not because the alternative is broken — it demonstrably is not — but because you are writing wires 1 and 2 either way, wire 3 is the only part you were improvising, and React now ships it. The improvised version's ceiling is tearing, and the thing about tearing is that you cannot test for it, cannot reproduce it on demand, and will meet it as a screenshot from a user showing two numbers that disagree.
And if you are on React 17, or your app has no concurrent features at all: react-use's version is fine. Say that honestly rather than performing ceremony.
useState(initialValue) reads what the factory was handed, not what the store holds. A component mounting after the count reaches 42 renders 0, and stays at 0 until the next write jumps it to 43 — a value the user never saw. Fix: every read asks the store. That is what getSnapshot is.getSnapshot. () => ({ ...state }) differs from itself by Object.is on every call, so React re-renders forever and throws Maximum update depth exceeded. Fix: return a cached value — the stored object itself — and recompute only when the store changes.subscribe defined inside the hook. A new subscribe identity on each render makes React unsubscribe and resubscribe every render. Fix: define it once in the factory closure, as above, where React's docs tell you to put it.listeners.forEach(...) then state = resolved tells everyone to re-read a value that has not changed yet, so nothing updates and the next write appears to lag one behind. Fix: assign, then notify.[...listeners] — a one-shot snapshot, and O(n) was the cost of the loop anyway.createGlobalState inside a component. It is a factory, not a hook. Call it in a render body and every render builds a brand-new store with a brand-new empty listener set, so nothing shares anything and the value resets constantly. Fix: call it once at module scope. The lint rule cannot catch this one — the name does not start with use.getServerSnapshot. Omit the third argument and server rendering throws Missing getServerSnapshot. Fix: pass one. For a module-scope store it is getSnapshot; for anything that reads a browser API it is a different function that must agree with what hydration will find.useGlobalState((s) => s.user.name) so a component only re-renders when its slice changes is the natural next question, and it is where the getSnapshot trap bites: a selector returning a fresh object crashes. Real stores answer it with useSyncExternalStoreWithSelector and a custom equality function. Zustand's whole API is this hook plus that.useSyncExternalStore is itself implementable in userland — that is what the use-sync-external-store shim package is, and how libraries supported React 17. Writing it is the inverse of this question: you get subscribe and getSnapshot, and you have to improvise wire 3 again, correctly.getState/subscribe core and stop exactly where this one starts. Their subscribe is wire 2 and their getState is wire 1, so binding them to React is now a four-line hook. That is all React-Redux is, plus selectors.storage event only fires in other tabs. A module-scope listener set — this exact store — is the standard fix, and the same trick closes the same gap in useUrlState.localStorage in the factory and write it in setState. Ten lines, and it is worth doing to feel where the seams are: the store is a plain closure, so persistence is a concern that never touches the React binding at all.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
createGlobalState(initialValue) returns a hook that behaves like useState, except every component calling it shares one value. You call the factory once at module scope; the value it holds lives in a closure, outside React, and any component can read it and write it without a prop or a provider in sight.
That last part is the whole problem. React re-renders in response to its own state setters. A variable in a module closure can change a thousand times and nothing on screen moves, because nothing told React. So a store outside React needs three things wired by hand: a way to read the current value, a way to subscribe to changes, and a way to tell React that a subscribed component is now out of date.
This is the smallest question in the catalog that has all three. No selectors, no reducers, no atoms — one value, shared.
function createGlobalState<S>(initialValue: S): () => [
S,
(next: S | ((prev: S) => S)) => void,
];
Call the factory once, at module scope, and export the hook:
// store.js
const useCount = createGlobalState(0);
Then any component uses it like useState — with no provider and no props:
function Counter() {
const [count, setCount] = useCount();
return <button onClick={() => setCount((n) => n + 1)}>{count}</button>;
}
// A sibling. Nothing connects it to Counter.
function Badge() {
const [count] = useCount();
return <span>{count}</span>; // clicking the button moves this
}
createGlobalState calls are two independent values. The factory is the store; the hook is a window onto it.useState's contract. setValue takes a value or an updater (prev) => next, and its identity is stable, so it is safe in a dependency array.You'll build a useState that lives outside React, and then do the only hard part: teach React to notice.
Sharing one value between two components that are not related is the oldest problem in React. The official answers all move the value up: lift it to a common parent, or wrap the tree in a context provider. Both work, and both make you restructure your app around where the data lives.
The other answer is to put the value in a module-scope variable and let anyone import it. That takes about four lines and no restructuring, and it has exactly one flaw: nothing re-renders. You can change that variable all day and the screen never moves.
That is not a bug in your code. It is what React is. React re-renders in response to its own state setters, and a variable in a closure is not one of those. So the store is the easy half — a let and a Set — and the entire question is the wire from the store back to React.
React does not know about anything it did not render.
Look at what is not happening in that picture. No error. No warning. The store is correct — count really is 6, and any console.log proves it. The components are simply rendering the last value they were told about, which is the job they have always had.
So a store outside React needs three wires run to it by hand:
Wire 3 is the interesting one, because React gives you no public function called re-render.
So you improvise one. Every component gets a throwaway useState, and the store's job is to poke its setter:
function createGlobalState(initialValue) {
let state = initialValue;
const listeners = new Set();
return function useGlobalState() {
const [value, setValue] = useState(initialValue);
useEffect(() => {
listeners.add(setValue);
return () => listeners.delete(setValue);
}, []);
const setGlobal = (next) => {
state = typeof next === 'function' ? next(state) : next;
listeners.forEach((listener) => listener(state));
};
return [value, setGlobal];
};
}
That local useState holds no state of its own. It is a lever: calling setValue is the only reliable way a non-React caller can make React re-render a component, so every subscriber keeps one just to be pulled.
And it very nearly works. Measured against this question's suite it passes eleven of fourteen tests — two components genuinely do share one value, updaters work, unmounting genuinely unsubscribes. Three fail, and they come from only two bugs:
useState(initialValue) seeds from the argument, not the store. This one costs two tests. Mount a component after the count reaches 42 and it renders 0, then sits there — its setter is subscribed, so it will catch the next write and jump straight from 0 to 43, having never shown 42. It is also why a fresh subscriber mounted after every other one has gone reads 0: measured, the store is still holding 99 that entire time. The new component simply never asks.setGlobal is redefined on every render, so it is a new function each time and useless in a dependency array.Notice what is not on that list. The store is fine. let state and a Set in the factory closure survive every render and every unmount, exactly as intended — the store was always the easy half. Both bugs are in the binding, and the bigger one is a component reading an argument when it should have asked the store.
Fix both and you have a genuinely good hook. You also still have a component keeping a fake useState around for the sole purpose of tricking React into re-rendering.
const { useSyncExternalStore } = require('react');
function createGlobalState(initialValue) {
// The store. It is a closure variable and a Set — that is the whole thing,
// and none of it is React. This code runs happily with nothing mounted.
let state = initialValue;
const listeners = new Set();
// WIRE 1 — read. What should a component render right now? Ask the store,
// never the argument. This is the late-mounter bug fixed at the source.
//
// It must return a CACHED value: React calls it on every render and compares
// the result with Object.is, so a fresh object each call means React sees a
// change every time and loops. Returning `state` itself is what caching
// looks like when the store already holds one immutable value.
const getSnapshot = () => state;
// WIRE 2 — subscribe. React hands us onStoreChange, a callback meaning
// exactly one thing: this component is out of date. Declared out here, once,
// so its identity is stable — a new subscribe function on a re-render makes
// React tear the subscription down and set it up again.
const subscribe = (onStoreChange) => {
listeners.add(onStoreChange);
return () => listeners.delete(onStoreChange);
};
// WIRE 3 — write, then say so. Still no React API in sight.
const setState = (next) => {
const resolved = typeof next === 'function' ? next(state) : next;
// React bails on an unchanged snapshot by itself, so this is not what
// stops the re-render — it just avoids waking every subscriber to find
// that out. Update state BEFORE notifying, or listeners read the old one.
if (Object.is(resolved, state)) return;
state = resolved;
// Copy before iterating. Mutating a Set mid-iteration is fully specified
// and surprising: a listener added during the round would fire in it.
for (const listener of [...listeners]) listener();
};
return function useGlobalState() {
// getSnapshot serves as getServerSnapshot too. This store holds the same
// value on both sides, so there is nothing separate to hand the server —
// but omit the third argument entirely and server rendering throws.
const value = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
return [value, setState];
};
}
module.exports = { createGlobalState };
The hook is now one line, and the shape of the fix is worth saying out loud: the improvised version had React state mirroring a store, two copies of one value kept in step by hand. This version has no copy at all. There is one value, in one place, and useSyncExternalStore reads it during render like any other value the component derives.
setState never touches React, which is why the store still works with nothing mounted — and why the identity test passes for free. It is defined once per createGlobalState call, so it is the same function forever.
This is the honest accounting of what useSyncExternalStore did. It did not simplify getSnapshot or subscribe — you write those by hand in both versions, and they are identical. It took over the third wire, the improvised one, the fake useState that existed only to make React do something. In exchange it asks for the two wires it cannot guess.
const useCount = createGlobalState(0) in store.js, imported by a Counter and a Badge that are siblings and share no props.
useSyncExternalStore calls subscribe(onStoreChange) — the listeners Set now holds React's callback for Counter — and calls getSnapshot(), which returns 0. Counter renders 0.subscribe, a different onStoreChange (it is a different component instance). listeners now holds two. getSnapshot() returns 0. Badge renders 0.setCount((n) => n + 1). Inside setState: resolved is 1, Object.is(1, 0) is false so we continue, state = 1. The store is now correct and the screen is still wrong — this is the exact moment of the first diagram, and it lasts one line.onStoreChange takes no arguments. It means you are out of date, nothing more.getSnapshot() again, gets 1, compares with the 0 it rendered last time, and schedules a re-render. Counter and Badge both render 1. Nobody passed the number anywhere; both components simply asked the store again.setCount(1) again. Object.is(1, 1) is true, so setState returns immediately. No listener is called and nothing re-renders. Delete that guard and the listeners would fire, React would re-read getSnapshot, get 1, compare with 1 and bail — same screen, more work.subscribe returned, which deletes Badge's callback from the Set. Counter keeps working; the store keeps its 1. Unmount Counter too and listeners is empty — and state is still 1, because it was never React's to throw away.Fix those two bugs and the improvised version passes all fourteen tests. So does this one. The tests cannot tell them apart — and that is not a gap in the tests, it is the honest situation. If your app never renders concurrently, the hand-rolled hook is fine, and the projects shipping it are not wrong.
Here is the one thing that separates them.
That is measured, not argued. Force the store to move partway through one render pass and React 19.2.7 commits Header showing 0 next to Badge showing 1 — the real react-use hook, the shape above, and every variant of it. Swap in useSyncExternalStore and both show 1: React re-reads getSnapshot when it finishes, notices the value it rendered with is no longer the value the store holds, throws the pass away and renders again. The recovery costs one extra render of Header and logs nothing.
Two honest caveats, because this is the claim the whole question rests on:
act() React flushes synchronously and never yields, so a startTransition write lands cleanly and both versions agree. Measured, and it is why this test is not in the suite: it would fail a correct hand-rolled implementation for a reason no test in jsdom can honestly show.Worth knowing: React's own documentation page never says tearing. It sells the hook as a way to integrate third-party stores and browser APIs. The reason it exists is in the RFC.
getSnapshot looks like the boring wire. It has the sharpest edge in the API:
// Every call returns a NEW object. Object.is says it changed. Every time.
const getSnapshot = () => ({ ...state });
React calls getSnapshot on every render and compares the result with the last one. A new object always differs, so React re-renders, calls it again, gets another new object, re-renders... Measured, this does not degrade — it crashes, with React's own diagnosis:
Warning: The result of getSnapshot should be cached to avoid an infinite loop
Uncaught Error: Maximum update depth exceeded.
The fix is in the name: cache it. Returning state directly is already cached, which is why the solution above never has to think about it. You hit this the moment you get ambitious — the first thing anyone tries after this question is useGlobalState(selector), and () => selector(state) returning { name, age } is a fresh object per call and an instant crash. That is not a reason to avoid selectors; it is the reason real store libraries take an equality function.
The third argument is not optional here. Measured under renderToString:
Missing getServerSnapshot, which is required for server-rendered content.
Will revert to client rendering.
Passing getSnapshot twice is right for this store, because a module-scope value is genuinely the same on both sides. It stops being right the moment the store reads something the server does not have — window.matchMedia, localStorage, a cookie. Then getSnapshot and getServerSnapshot must be different functions, and the server one must return what the client will find at hydration, or React swaps a correct server render for a client re-render.
This is the one place the improvised version is simpler: react-use's hook server-renders with no third argument and no thought, because a useState seeded from a module variable already works everywhere.
The interesting one is createGlobalState itself, because it is react-use's hook and it is a gift to this question.
react-use 17.6.1 — the current release — does not use useSyncExternalStore. It ships the improvised shape, verbatim: a module-scope store object, a setters array, a useState per subscriber, and a setState that loops the array calling each setter. It is the pre-18 pattern, still shipping, in a library with millions of weekly downloads.
It is also a careful version of it, and the differences from the naive attempt above are exactly those two bugs:
| naive | react-use 17.6.1 | this hook | |
|---|---|---|---|
| initial read | useState(initialValue) | useState(store.state) | getSnapshot() |
| setter identity | new each render | store.setState, stable | setState, stable |
| wire 3 | a useState per subscriber | a useState per subscriber | onStoreChange |
| tears | yes (measured) | yes (measured) | no (measured) |
Run this question's fourteen tests against the real react-use export and all fourteen pass. It is a good hook. Its useIsomorphicLayoutEffect subscribe even closes the render-to-effect gap more tightly than a plain useEffect would.
So the recommendation, stated plainly: use useSyncExternalStore. Not because the alternative is broken — it demonstrably is not — but because you are writing wires 1 and 2 either way, wire 3 is the only part you were improvising, and React now ships it. The improvised version's ceiling is tearing, and the thing about tearing is that you cannot test for it, cannot reproduce it on demand, and will meet it as a screenshot from a user showing two numbers that disagree.
And if you are on React 17, or your app has no concurrent features at all: react-use's version is fine. Say that honestly rather than performing ceremony.
useState(initialValue) reads what the factory was handed, not what the store holds. A component mounting after the count reaches 42 renders 0, and stays at 0 until the next write jumps it to 43 — a value the user never saw. Fix: every read asks the store. That is what getSnapshot is.getSnapshot. () => ({ ...state }) differs from itself by Object.is on every call, so React re-renders forever and throws Maximum update depth exceeded. Fix: return a cached value — the stored object itself — and recompute only when the store changes.subscribe defined inside the hook. A new subscribe identity on each render makes React unsubscribe and resubscribe every render. Fix: define it once in the factory closure, as above, where React's docs tell you to put it.listeners.forEach(...) then state = resolved tells everyone to re-read a value that has not changed yet, so nothing updates and the next write appears to lag one behind. Fix: assign, then notify.[...listeners] — a one-shot snapshot, and O(n) was the cost of the loop anyway.createGlobalState inside a component. It is a factory, not a hook. Call it in a render body and every render builds a brand-new store with a brand-new empty listener set, so nothing shares anything and the value resets constantly. Fix: call it once at module scope. The lint rule cannot catch this one — the name does not start with use.getServerSnapshot. Omit the third argument and server rendering throws Missing getServerSnapshot. Fix: pass one. For a module-scope store it is getSnapshot; for anything that reads a browser API it is a different function that must agree with what hydration will find.useGlobalState((s) => s.user.name) so a component only re-renders when its slice changes is the natural next question, and it is where the getSnapshot trap bites: a selector returning a fresh object crashes. Real stores answer it with useSyncExternalStoreWithSelector and a custom equality function. Zustand's whole API is this hook plus that.useSyncExternalStore is itself implementable in userland — that is what the use-sync-external-store shim package is, and how libraries supported React 17. Writing it is the inverse of this question: you get subscribe and getSnapshot, and you have to improvise wire 3 again, correctly.getState/subscribe core and stop exactly where this one starts. Their subscribe is wire 2 and their getState is wire 1, so binding them to React is now a four-line hook. That is all React-Redux is, plus selectors.storage event only fires in other tabs. A module-scope listener set — this exact store — is the standard fix, and the same trick closes the same gap in useUrlState.localStorage in the factory and write it in setState. Ten lines, and it is worth doing to feel where the seams are: the store is a plain closure, so persistence is a concern that never touches the React binding at all.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.