The Philosophy of State Management
The Philosophy of State Management — Client State and Server State
State management in React apps creates confusion because there are many libraries. The biggest fork, however, is not the library choice but whether the state belongs to the client or to the server.
1. Two kinds of state
The distinction TanStack Query's docs spell out is widely cited.
Client state is owned by the client. Whether a button was pressed, whether a sidebar is open, what was typed into a form.
Server state is owned by the server, and the client merely holds a copy. Other users can change it, and over time it goes stale.
Trying to handle both with the same tool gets awkward.
- Putting server state into a Redux store means writing cache invalidation, background refetching, and stale handling by hand.
- Forcing client state into React Query produces meaningless query keys.
2. Client state tools
| Place | Tool | Notes |
|---|---|---|
| Inside a component | useState, useReducer |
React built-ins. |
| Inside a component tree | Context API | If the value changes often, re-render cost is high. |
| App-global (lightweight) | zustand, jotai, valtio | Small, low boilerplate. |
| App-global (structured) | Redux Toolkit, MobX | DevTools, consistent patterns for large teams. |
Library origins (all licensed MIT):
| Tool | First release | Author |
|---|---|---|
| Redux | 2015 | Dan Abramov, Andrew Clark. |
| Redux Toolkit (RTK) | 2019 | Official Redux. Reduces boilerplate. |
| MobX | 2015 | Michel Weststrate. observable-based. |
| Recoil | 2020, Meta | atom/selector. Effectively frozen with the January 2025 archive notice. Successor is Jotai. |
| zustand | 2019 | pmndrs (Paul Henschel). bear store. |
| jotai | 2020 | pmndrs. atom-based, Recoil-style. |
| valtio | 2021 | pmndrs. proxy-based. |
| XState | 2017 | David Khourshid. State machines. |
The model differences between zustand and jotai are often compared.
- zustand — keep multiple values in one store, subscribe via selector. "store-centric".
- jotai — compose small atoms. "atom-centric", explicitly citing Recoil's influence.
3. Server state tools
Server state needs caching, refetching, optimistic updates, and deduplication.
| Tool | First release | Author |
|---|---|---|
| Apollo Client | 2016 | GraphQL-focused. Normalized cache. |
| SWR | 2019, Vercel | Named after "stale-while-revalidate" in RFC 5861. |
| React Query → TanStack Query | 2020 (RQ v1) | Tanner Linsley. Renamed to TanStack from v4 (2022). |
| RTK Query | 2021 | Bundled with Redux Toolkit. |
| Relay | 2015 | Meta. GraphQL-only. |
Most libraries share the following pattern.
const { data, isLoading, error } = useQuery({
queryKey: ["posts", page],
queryFn: () => fetch(`/api/posts?p=${page}`).then(r => r.json()),
staleTime: 60_000,
})
- queryKey — cache identifier. Same key, same data.
- staleTime — "considered fresh for this duration" (no refetch).
- gcTime / cacheTime — how long unused cache stays in memory.
- invalidate — invalidate related keys after a mutation so the next use refetches.
4. Relationship with Server Components
With React Server Components (Next.js App Router and similar) emerging, part of the server state can be read directly from the server at render time. In that case, a client-side cache library may not be necessary.
export default async function Page() {
const posts = await db.post.findMany()
return <List items={posts} />
}
Interactive state (optimistic updates, polling, infinite scroll) still needs a client cache. SWR/TanStack Query and RSC are not competitors but share the workload.
In addition, 19's useOptimistic and useActionState are creating a standard place for handling small, form-level server state.
5. zustand
import { create } from "zustand"
type Store = {
count: number
inc: () => void
}
export const useCounter = create<Store>((set) => ({
count: 0,
inc: () => set((s) => ({ count: s.count + 1 })),
}))
// component
const count = useCounter((s) => s.count)
const inc = useCounter((s) => s.inc)
6. TanStack Query
import { QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query"
const qc = new QueryClient()
export function Root() {
return (
<QueryClientProvider client={qc}>
<App />
</QueryClientProvider>
)
}
function Posts() {
const { data, isPending } = useQuery({
queryKey: ["posts"],
queryFn: () => fetch("/api/posts").then(r => r.json()),
staleTime: 30_000,
})
if (isPending) return <p>...</p>
return <ul>{data.map((p: any) => <li key={p.id}>{p.title}</li>)}</ul>
}
7. SWR
import useSWR from "swr"
const fetcher = (url: string) => fetch(url).then(r => r.json())
function Posts() {
const { data, error, isLoading } = useSWR("/api/posts", fetcher)
if (isLoading) return <p>...</p>
if (error) return <p>error</p>
return <ul>{data.map((p: any) => <li key={p.id}>{p.title}</li>)}</ul>
}
8. Common pitfalls
Putting server state in Redux — possible, but cache invalidation and refetching must be coded manually. A heavy cost.
Fetching inside useEffect — fine in small apps, but concurrency, cancellation, and deduplication must be handled by hand. As pages grow, tools like SWR or TanStack Query become lighter.
Overuse of Context — when one Context changes often, the entire tree re-renders. zustand and jotai's selector model fills this place well.
queryKey design — too coarse a key broadens the invalidation scope; too fine a key explodes the invalidate combinations. Resource + identifier + filter is a recommended starting point.
The SSR problem of localStorage sync — window does not exist on the server. Even zustand's persist middleware can cause hydration mismatch, so a if (typeof window !== "undefined") guard or useEffect sync is needed.
The meaning of stale-while-revalidate — a cache model from RFC 5861 that "shows the stale response briefly and refetches in the background." SWR's name comes from there.
Closing thoughts
State management depends more on the decision of "where does this state belong" than on the choice of tool. Treating client state and server state with different tools from the start reduces operational burden. With RSC, part of server state has come closer, but interactive places still need a client cache as the answer.
Next
- styling-tailwind
- tauri-over-electron
We refer to the TanStack Query docs, SWR official, Redux Toolkit, zustand, Jotai, RFC 5861, Choosing the State Structure, and XState.