Zustand vs. Jotai for Client UI State
Verified against
@tanstack/react-startv1.168.x — July 2026.
This chapter assumes you’ve already read 4.3 and concluded you need a client store for genuinely local UI state — not server data, which belongs in Query.
graph TD
subgraph Zustand["Zustand — per-request pattern"]
Z1["createStore() from zustand/vanilla\n(a factory, not a singleton)"] --> Z2["Instantiated inside\nuseState(() => createStore()) in a Provider"]
Z2 --> Z3["Context supplies the instance\nto useStore(selector)"]
end
subgraph Jotai["Jotai — per-request pattern"]
J1["atom(initialValue)\n(a definition, not the state itself)"] --> J2["<Provider> wraps the request's\nrender tree — owns the actual store"]
J2 --> J3["useHydrateAtoms seeds server-computed\nvalues into that request's store"]
end
Zustand: vanilla store + Context, never the global create()
Section titled “Zustand: vanilla store + Context, never the global create()”The idiom most Zustand tutorials show — export const useStore = create(() => ({...})) at module scope — is an SSR footgun (see Part 4.5 for why). The safe pattern is createStore from zustand/vanilla, instantiated once per request inside a Context Provider:
import { createStore } from 'zustand/vanilla'
type SidebarState = { isOpen: boolean toggle: () => void}
const defaultInitState: Pick<SidebarState, 'isOpen'> = { isOpen: false }
export const createSidebarStore = (initState = defaultInitState) => createStore<SidebarState>()((set) => ({ ...initState, toggle: () => set((state) => ({ isOpen: !state.isOpen })), }))
export type SidebarStoreApi = ReturnType<typeof createSidebarStore>import { createContext, useContext, useState, type ReactNode } from 'react'import { useStore } from 'zustand'import { createSidebarStore, type SidebarStoreApi } from './store'
const SidebarStoreContext = createContext<SidebarStoreApi | undefined>(undefined)
export const SidebarStoreProvider = ({ children }: { children: ReactNode }) => { // useState's initializer runs once per component instance — // once per request on the server, once for the app's lifetime on the client const [store] = useState(() => createSidebarStore()) return <SidebarStoreContext.Provider value={store}>{children}</SidebarStoreContext.Provider>}
export const useSidebarStore = <T,>(selector: (state: SidebarState) => T): T => { const store = useContext(SidebarStoreContext) if (!store) throw new Error('useSidebarStore must be used within SidebarStoreProvider') return useStore(store, selector)}Mount SidebarStoreProvider in your root route component. The useState(() => createSidebarStore()) call is what gives you per-request isolation on the server — each request renders the tree fresh, so each gets its own store instance, instantiated exactly once for that render.
Zustand’s own docs state the constraint plainly: the store “should not be shared across requests. Instead, the store should be created per request.” A module-level const store = createStore(...) violates that on any server that reuses a JS isolate across requests — which is most of them.
Jotai: <Provider> per request, useHydrateAtoms for seeding
Section titled “Jotai: <Provider> per request, useHydrateAtoms for seeding”Jotai’s default mode is provider-less — atoms read/write against an implicit global store. Jotai’s own docs call this out directly as an SSR risk: without an explicit <Provider>, “this global store is kept alive and is shared between multiple requests, which can lead to bugs and security risks.” So the first requirement is the same shape as Zustand’s: scope the store to the request.
// root layoutimport { Provider } from 'jotai'
function RootLayout({ children }: { children: React.ReactNode }) { return <Provider>{children}</Provider>}Each render of RootLayout — once per request on the server — creates a fresh Jotai store scoped to that <Provider>. Atoms defined at module scope (export const sidebarOpenAtom = atom(false)) are safe here specifically because they’re just keys/definitions; the actual state lives in whichever <Provider> store is in context, not on the atom object itself.
To seed a client atom from data you already fetched on the server (a loader result, say), use useHydrateAtoms:
import { useHydrateAtoms } from 'jotai/utils'import { currentThemeAtom } from './atoms'
function ThemeHydrator({ initialTheme, children }: { initialTheme: string; children: React.ReactNode }) { useHydrateAtoms([[currentThemeAtom, initialTheme]]) return children}The Query bridge: Jotai has one, Zustand doesn’t
Section titled “The Query bridge: Jotai has one, Zustand doesn’t”This is the one point that isn’t symmetric. jotai-tanstack-query is a maintained, official-adjacent package (published under the jotaijs org) that gives you atomWithQuery/atomWithMutation — Query-backed atoms that share the same QueryClient your router’s SSR integration uses:
import { atomWithQuery } from 'jotai-tanstack-query'
const todosAtom = atomWithQuery(() => ({ queryKey: ['todos'], queryFn: fetchTodos,}))import { queryClientAtom } from 'jotai-tanstack-query'import { useHydrateAtoms } from 'jotai/react/utils'
function HydrateQueryClient({ queryClient, children }: { queryClient: QueryClient; children: React.ReactNode }) { useHydrateAtoms([[queryClientAtom, queryClient]]) return children}This is mostly relevant if you’re already committed to Jotai for UI state and want atom-shaped access to Query data too, rather than calling useSuspenseQuery directly. It doesn’t replace Part 4.1’s Query integration — queryClientAtom still needs to point at the same QueryClient instance the router’s SSR integration set up. Zustand has no equivalent first-party bridge; if you’re on Zustand and want Query-backed data, you call useQuery directly in the component, same as you would without Zustand at all.
Side by side
Section titled “Side by side”| Zustand | Jotai | |
|---|---|---|
| Per-request pattern | createStore (vanilla) + Context + useState |
<Provider> wrapping the request’s render tree |
| Mental model | One store object, selector functions | Fine-grained atoms, composed by reference |
| Official Start docs | None (Next.js guide only) | None (Next.js guide only) |
| Server → client seeding | Pass initState into createXStore(initState) on both sides |
useHydrateAtoms([[atom, value]]) |
| Query bridge | None — call useQuery directly |
jotai-tanstack-query (atomWithQuery, shares queryClientAtom) |
| Biggest footgun | Global create() instead of vanilla store + Context |
Skipping <Provider>, relying on the implicit global store |
Recommendation
Section titled “Recommendation”For a new Start app, Jotai edges ahead — it has an official Query bridge, a purpose-built hydration primitive (useHydrateAtoms), and its per-request pattern (<Provider>) is a single wrapper rather than a store-factory-plus-context you write by hand. There are also fewer ways to accidentally recreate the global-singleton footgun: Jotai’s docs call the provider-less default out explicitly, whereas Zustand’s naive create() idiom looks correct and only fails under concurrent requests.
That said, Zustand is fully viable with the discipline shown above, and it wins if your team wants one store with a couple of actions rather than a scattered set of atoms — the vanilla-store-per-request pattern isn’t hard, it’s just something you have to remember to do, every time, rather than something the library defaults you into.
Whichever you pick, the underlying risk is the same bug class — covered next.
Next: 4.5 — The singleton-leak bug class explains exactly why module-level stores are dangerous on a server, with or without a framework in the middle.