React state management settled in 2026. The question isn’t “what library” — it’s “what kind of state, where.” This post is the working playbook.
The taxonomy
- Server state → TanStack Query (or SWR, Apollo). See TanStack Query 2026 .
- URL state → query params + your router.
- Form state → React Hook Form / Tanstack Form / useState.
- UI state local to a component → useState / useReducer.
- UI state shared across components → Zustand / Jotai / Context.
Each has its place. Mixing them in one library creates pain.
Zustand
import { create } from "zustand";
const useStore = create((set) => ({
isOpen: false,
open: () => set({ isOpen: true }),
close: () => set({ isOpen: false }),
toggle: () => set((s) => ({ isOpen: !s.isOpen })),
}));
function MyModal() {
const { isOpen, close } = useStore();
return isOpen ? <Modal onClose={close} /> : null;
}
Tiny API. No provider needed. Selectors for performance:
const isOpen = useStore((s) => s.isOpen);
// only re-renders when isOpen changes
For most React apps: Zustand is the simple right answer.
Slices
const useStore = create((set) => ({
...createUISlice(set),
...createCartSlice(set),
...createAuthSlice(set),
}));
function createUISlice(set) {
return {
sidebarOpen: false,
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
};
}
Modular slices in one store. Avoids massive single-store files.
Jotai
import { atom, useAtom } from "jotai";
const countAtom = atom(0);
function Counter() {
const [count, setCount] = useAtom(countAtom);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
Each atom is independent. Components subscribe to atoms they need.
const doubleAtom = atom((get) => get(countAtom) * 2);
Derived atoms. Async atoms. Composable.
Redux Toolkit
import { createSlice, configureStore } from "@reduxjs/toolkit";
const cartSlice = createSlice({
name: "cart",
initialState: { items: [] },
reducers: {
add: (state, action) => { state.items.push(action.payload); },
remove: (state, action) => { state.items = state.items.filter(i => i.id !== action.payload); },
},
});
const store = configureStore({ reducer: { cart: cartSlice.reducer } });
Verbose vs Zustand but battle-tested. RTK Query also handles server state if you don’t use TanStack.
For big established apps: keep RTK. For new: Zustand / Jotai.
Context (built-in)
const ThemeContext = createContext({ theme: "light", toggle: () => {} });
function App() {
const [theme, setTheme] = useState("light");
return <ThemeContext.Provider value={{ theme, toggle: () => setTheme(t => t === "light" ? "dark" : "light") }}>
<Page />
</ThemeContext.Provider>;
}
Built-in. Sufficient for theme, locale, auth user. Performance issue: any value change re-renders all consumers. For high-frequency state: avoid.
URL state
import { useSearchParams } from "next/navigation";
const [params, setParams] = useSearchParams();
const filter = params.get("filter") ?? "all";
const updateFilter = (v) => setParams(p => { p.set("filter", v); return p; });
Filters, pagination, modal open state — often best in URL. Shareable, refreshable, browser-back works.
For non-trivial: nuqs library handles URL state with type safety.
Form state
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
const Schema = z.object({ email: z.string().email() });
type FormData = z.infer<typeof Schema>;
const { register, handleSubmit } = useForm<FormData>({ resolver: zodResolver(Schema) });
React Hook Form for performance + Zod for validation. Don’t put form state in global stores.
Persistence
For Zustand:
import { persist } from "zustand/middleware";
const useStore = create(
persist(
(set) => ({ ... }),
{ name: "my-store", storage: createJSONStorage(() => localStorage) }
)
);
Auto-saves to localStorage. Useful for theme, draft state, etc.
For Jotai: atomWithStorage.
DevTools
Zustand: devtools middleware integrates with Redux DevTools.
Jotai: Jotai DevTools.
Redux: full DevTools native.
Use them while developing.
RSC integration
In Next.js App Router, server state lives on the server. Client state libraries (Zustand, Jotai) are Client Component-only.
// Provider wraps client tree
"use client";
import { Provider } from "jotai";
export function ClientProviders({ children }) {
return <Provider>{children}</Provider>;
}
In RSC trees: less need for client state. Push it to Client Components only.
Common mistakes
1. Server state in client store
User list cached in Zustand. Now you re-implement TanStack Query badly. Use the right tool.
2. UI state in URL
Modal open state in URL = back button closes modal but refresh leaves it open. Sometimes desired; often not.
3. Massive single Context
Re-renders the world on any change. Split or use Zustand selectors.
4. Form state in global
Mid-typing, refresh closes form. Local form state.
5. State machines for everything
XState is great for complex flows; overkill for isOpen: boolean.
Library comparison
| Strengths | When | |
|---|---|---|
| TanStack Query | Server state | All async data |
| Zustand | Simple stores | Most UI state |
| Jotai | Atomic | Many independent values |
| Redux Toolkit | Mature; structured | Big apps; teams familiar |
| Context | Built-in | Theme, auth, locale |
| useState | Trivial | Component-local |
| XState | State machines | Complex flows (wizards) |
Mix freely. They compose.
What I’d ship today
For new React apps:
- TanStack Query for server state.
- Zustand for UI state.
- useState for local UI.
- React Hook Form + Zod for forms.
- URL params for filters / pagination.
- Context sparingly (theme, auth).
Read this next
If you want my Zustand + RHF + TanStack Query starter, it’s at rajpoot.dev .
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .