Every trading UI starts the same way. You wire up a price feed, drop the data into a global store because "everything needs it", and three weeks later the order book is re-rendering the account balance on every tick. The reflex to reach for Redux comes from a misdiagnosis: people treat "shared state" as one problem with one solution, when a trading frontend actually juggles at least three kinds of state that have nothing in common except that they all happen to live in the browser.
The mistake is conflating them. Market data from a REST endpoint, a 20-messages-per-second websocket feed, and the value of a text input in an order form have wildly different lifecycles, ownership, and update patterns. A single store flattens those differences and you pay for it in re-renders, boilerplate, and bugs. This post walks through how the Hedge UI demo splits state along those seams using TanStack Query for server data, a dedicated subscription layer for the websocket, and plain React Context for UI state, with no Redux and no Zustand anywhere in the tree.
Three kinds of state in a trading UI
Before picking a library, it helps to name what you are actually storing:
- Server state. Data that lives on someone else's machine and you fetch on demand: exchange metadata, symbol filters, historical candles. You do not own it, it can go stale, and the hard problems are caching, deduplication, and revalidation.
- Real-time stream state. Pushed at you over a websocket: order book deltas, trades, ticker updates. The server decides when it changes. You cannot "fetch" it, you subscribe and react.
- Client (UI) state. The stuff that only exists in this tab: which market is selected, the contents of the order form, the watchlist, the active theme, the panel layout. You own all of it.
These three want different tools. Server state wants a cache with revalidation. Stream state wants a subscription pipe feeding a small cache. Client state wants the lightest thing that works. The reason a single Redux store feels heavy in this domain is that it forces one model onto all three, and the model that suits a synchronous text input is a terrible fit for a 50ms order-book diff.
Server state belongs to React Query
The demo fetches all of its Binance REST data through TanStack React Query 5.90.12. Exchange info, aggregate trades, and klines each get a thin hook in src/binance/binance-rest.ts, and the entire networking layer is a one-line fetch wrapper:
const get = <T>(url: string) => { return fetch(url).then((res) => res.json()) as Promise<T>; }; export const useBinanceExchangeInfo = () => { const baseUrl = useBaseUrl(); const url = `${baseUrl}/api/v3/exchangeInfo`; return useQuery({ queryKey: [url], queryFn: () => get<ExchangeInfo>(url), }); };
What you do not see here is the machinery you would have written by hand in a Redux world: a loading flag, an error slice, a "have I already fetched this" guard, a thunk, and an action for each transition. Query gives you data, isPending, isError, and request deduplication out of the box, keyed by queryKey. Two panels asking for the same exchange info share one in-flight request and one cache entry.
The interesting tuning lever is staleTime. Exchange info barely changes within a session, so it is a natural candidate for a long staleTime (minutes, not the default zero), which stops the data being re-fetched every time a component remounts. Short-lived data goes the other way. The aggregate-trades hook sets gcTime: 0 so the cache is dropped the moment nothing is subscribed, because a stale list of trades for a market you have navigated away from is worthless:
export const useBinanceAggTrades = ({ symbol, limit = 20 }) => { const baseUrl = useBaseUrl(); const url = symbol ? `${baseUrl}/api/v3/aggTrades?symbol=${symbol}&limit=${limit}` : undefined; return useQuery({ queryKey: [symbol, limit], queryFn: () => (url ? get<AggTrade[]>(url) : Promise.resolve([])), gcTime: 0, }); };
The honest version of "use the right tool" is that we tune these per query rather than reaching for a one-size default. The point stands regardless: hand-rolling a cache with revalidation, deduplication, and garbage collection is a multi-week project that Query has already solved and battle-tested. If your "global store" exists mostly to hold fetched data, you do not have a state-management problem, you have a data-fetching library that you have not adopted yet.
One subtlety worth calling out is queryKey design, because the key is the cache identity. The demo keys the exchange-info query on the full URL and the trades query on [symbol, limit]. The reason that matters is the base URL itself is dynamic: a useBaseUrl hook reads region from ClientLocationProvider and returns either api.binance.com or api.binance.us. Because the region is part of the key (directly, via the URL), a US user and an international user never collide in the cache, and switching region naturally re-fetches against the correct host. This is also a small example of the lanes feeding each other cleanly: a piece of client state (region) parameterises a piece of server state (the REST query) without either one having to know about the other's storage.
Real-time state is push, not pull
It is tempting to also push websocket messages into React Query, since it is already "the data layer". Resist it. Query's mental model is request and response: a queryFn runs, returns a value, and the cache revalidates on its own schedule. A websocket is the inverse. There is no request, the server pushes whenever it likes, and the rate can be tens of messages a second per stream. Forcing that through a request-shaped cache means manual setQueryData calls on every frame, fighting the revalidation logic the whole way.
So real-time state lives in its own layer. The demo uses react-use-websocket 4.13.0 and a small subscription module in src/binance/binance-stream.ts. The core idea is a cache that keeps only the latest message per stream:
export const useStreamCache = <T>( streamNames: string[], lastMessage: Message<T> | undefined, ) => { const [lastMessages, setLastMessages] = useState<Record<string, T>>({}); useEffect(() => { if (!lastMessage) { return; } setLastMessages((prev) => ({ ...prev, [lastMessage.stream]: lastMessage.data, })); }, [lastMessage]); // ...prune entries for streams we no longer subscribe to return lastMessages; };
The subscription side is just as deliberate. Rather than open a socket per stream or tear the connection down on every change, a useMessenger hook holds one connection and diffs the requested stream names against what is already subscribed, sending only the delta as SUBSCRIBE / UNSUBSCRIBE frames. Switch markets and it unsubscribes the old ticker and subscribes the new one, on the same socket, in one message. That diffing logic, the reconnection handling, and the "ignore stale messages from the market you just left" guard are subtle enough that I gave the whole topic its own writeup in managing websocket state and reconnection in a crypto trading UI.
The stale-message guard deserves a closer look, because it is the kind of bug that only shows up when you have separated the lanes. When the user switches markets, the socket keeps delivering frames for the old stream for a brief window before the unsubscribe lands. A useLastValidMessage helper checks each incoming message's stream against the currently subscribed names and discards anything that does not match:
if (typeof lastJsonMessage === "object" && "stream" in lastJsonMessage) { const message = lastJsonMessage as Message<T>; // If we have changed products, the last message might be stale. const isCurrentProduct = streamNames.includes(message.stream); if (!isCurrentProduct) { return; } return message; }
That guard is only possible because the subscription layer knows what it is subscribed to. If this state were smeared across a global store, "is this message still relevant" would have no obvious home.
The takeaway for state architecture: stream data is owned by the subscription layer, exposed through a cache, and consumed by hooks. It never enters the client state store and it never enters Query. Three lanes, no crossover.
Client state is plain Context, split by concern
Everything the user owns lives in React Context. There is no Redux, no Zustand, no Jotai. There is one provider per concern, and that "per concern" part is what makes the approach scale:
ProductProviderholds the active market and nothing else.OrderFormProviderholds the order-form fields and recomputes the total withdecimal.js.FavoritesProviderholds the watchlist (addFavorite,removeFavorite,isFavorite).ThemeProviderholds the dark/light theme and toggles a class ondocument.documentElement.ClientLocationProviderholds region detection, which decides whether REST and websocket calls hit the.comor.usBinance hosts.
ProductProvider is almost aggressively boring, and that is the point:
export const ProductProvider = ({ children }: ProductProviderProps) => { const [product, setProduct] = useState<Product | undefined>(undefined); return ( <ProductContext.Provider value={{ product, setProduct }}> {children} </ProductContext.Provider> ); };
OrderFormProvider carries more logic because the order-form total has to be exact. It runs new Decimal(limitPrice).mul(amount) rather than native floating-point multiplication, clamps the result to the quote asset's precision, and resets its fields whenever the active product changes. The reasoning behind reaching for decimal.js instead of Number is covered in getting order-form maths right with decimal precision; the relevant architectural point here is that this state is genuinely local to the form, so it has no business sitting in a global atom that the rest of the app can read.
One detail worth flagging for honesty: next-themes is installed in the demo but unused. ThemeProvider is a hand-rolled 30-line context that flips a class on the document element. It predates a cleanup and is exactly the kind of thing you would consolidate in a real project. State architecture in the wild is rarely pristine.
Provider order matters
Splitting into focused providers introduces a dependency question: who wraps whom? OrderFormProvider calls useProduct() to read the active market and to reset its fields when the market changes, so ProductProvider has to sit above it in the tree. ClientLocationProvider wraps almost everything, because both the REST hooks and the websocket hooks read region from it. The composition ends up reading like a declaration of dependencies:
<ClientLocationProvider> <QueryClientProvider client={queryClient}> <ThemeProvider> <FavoritesProvider> <ProductProvider> <OrderFormProvider>{children}</OrderFormProvider> </ProductProvider> </FavoritesProvider> </ThemeProvider> </QueryClientProvider> </ClientLocationProvider>
A single Redux store hides these relationships behind selectors; the provider tree makes them explicit, which I have come to prefer. When the order form depends on the active product, that is a fact about the app, and seeing it in the JSX is a feature, not nesting noise.
Composing the three lanes in one component
The proof that the separation pays off is how a feature consumes all three lanes without friction. An order-book panel reads the active market from Context, fetches the symbol's filters with Query, and subscribes to the depth stream, each through its own hook:
const OrderBookPanel = () => { const { product } = useProduct(); // client state const { data: info } = useBinanceExchangeInfo(); // server state const stream = `${product?.symbol.toLowerCase()}@depth20`; const { lastMessage } = useBinanceStream<OrderBook>([stream]); const book = useStreamCache<OrderBook>([stream], lastMessage); // ...render };
Three hooks, three lanes, zero coordinating store. The component does not know or care that the product came from Context, the filters from a cache, and the book from a socket. Each hook owns its own concern, and the panel just composes them. Try writing that against a single Redux store and you end up with three slices, three sets of selectors, and a middleware to drive the socket, all to express what these five lines say plainly.
Redux vs Zustand vs Context + Query
A comparison, because the "just use Redux" reflex deserves a fair hearing rather than a dismissal:
| Concern | Redux Toolkit | Zustand | Context + React Query |
|---|---|---|---|
| Server data caching | Manual (RTK Query bolts it on) | Manual | Built in (the whole point) |
| Boilerplate per feature | Slice + actions + selectors | Low (a create call) | A provider + a hook |
| Re-render control | Fine-grained via selectors | Fine-grained via selectors | Coarse; needs splitting + memo |
| Bundle cost | Largest | Tiny (~1KB) | Zero extra (built into React) |
| DevTools / time travel | Excellent | Good | None beyond React DevTools |
| Best when | Large team, complex shared workflows, audit trail | Frequently-updated cross-cutting state | Localised UI state + server data |
Read across the rows and the pattern is clear. Redux earns its weight when you have genuinely complex, shared, cross-cutting workflows that benefit from a strict action log and time-travel debugging. For a trading UI where most "shared" state is either server data (Query's job) or scoped to one feature (Context's job), Redux mostly adds ceremony. Zustand sits in between: almost no boilerplate and excellent selector-based re-render control, which is why it is the right call for one specific situation I will come back to.
The Context re-render trap
The standard objection to Context is correct: every consumer of a context re-renders when that context's value changes, and Context has no built-in selector to subscribe to a slice. Put the whole app's state in one mega-context and a single keystroke in the order form re-renders the order book. People hit this, blame Context, and go install Redux.
The actual fix is the splitting you have already seen. Because the active market, the order form, the watchlist, and the theme are separate providers, a change to one cannot wake consumers of another. A component reading useProduct() is untouched when useFavorites() updates. Splitting by concern is doing the same work a Redux selector would do, just at the provider boundary instead of the subscription boundary.
The second half is memoising context values and the components under them so a parent re-render does not cascade. The provider value should be stable between renders, and expensive subtrees should be wrapped in React.memo. I dig into the concrete techniques, including where this strategy breaks down under high update rates, in building performant React trading applications. It is also worth noting that the React Compiler changes the calculus on manual memoisation: once it is auto-memoising components for you, a lot of the hand-written useMemo and React.memo in this pattern becomes redundant, though the split-by-concern provider structure stays exactly as valuable.
When you would actually reach for Zustand or Jotai
Splitting providers handles state that is owned by a clear region of the tree. It handles less well state that is:
- Cross-cutting - read and written from many unrelated places, so there is no natural provider boundary to draw.
- Frequently updated - changing often enough that even a focused context's re-render fan-out becomes a measurable cost.
- Read by a few fields of a large object - where you want components to subscribe to one property and ignore the rest.
That trio is exactly what selector-based stores are built for. If the demo grew a global "connection health" indicator polled by a dozen scattered components, or a position-tracking store that updates on every fill and is sliced a hundred different ways, I would lift that into Zustand rather than torture a context into the shape. Jotai's atom model fits the same need from the other direction, decomposing state into independently-subscribable units. The decision rule is simple: Context until the re-render math stops working or the ownership stops being obvious, then a selector store for that specific slice. You do not have to pick one library for the whole app.
Persisting client state to localStorage
Some client state has to survive a refresh: the panel layout, the selected layout, the theme, and per-panel settings. The demo handles this with a useLocalStorage hook in src/state/state-storage.ts, with every key carrying a v2- version prefix so a schema change can invalidate old data cleanly:
const version = "v2"; export const usePanelState = <T>(panelId: string, initialState: T) => { const key = `${version}-layout-panel-state-${panelId}`; return useLocalStorage<T>(key, initialState); };
The hook reads lazily on mount (guarding for typeof window === "undefined" so it survives any server render), writes through to localStorage on every update, and listens for the storage event so a layout change in one tab propagates to others. Persistence is a property you bolt onto client state, not a fourth category of state. Server data is already cached by Query and stream data is ephemeral by nature, so neither needs this. The full design, including why I version the keys and how layouts serialise, is in persisting UI state to localStorage in a trading app.
Closing guidance
If you take one thing from this, let it be the seam, not the library list. Sort your state into server, real-time, and client before you write a line of store code:
- Server data goes in React Query. Tune
staleTimeandgcTimeper query. Do not hand-roll a cache. - Real-time data goes in a dedicated subscription layer feeding a small latest-message cache. Keep it out of both Query and the client store.
- Client state goes in React Context, split into one provider per concern so re-renders stay contained. Memoise the values, and let the React Compiler retire most of the manual work over time.
- Reach for Zustand or Jotai only for the specific slice that is cross-cutting and frequently updated, not as the default.
- Persistence is a wrapper over client state, versioned so you can evolve the schema.
A surprising amount of "we need a proper state management solution" turns out to be "we never separated our server data from our UI state". Doing that separation first is also the cheapest way to avoid most of the mistakes trading-UI projects repeat. The whole Hedge UI demo runs on React 19.2.1 with Query, a websocket layer, and a handful of small contexts. There is no Redux in it, and after building it, I have not once wished there were.
