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

StrengthsWhen
TanStack QueryServer stateAll async data
ZustandSimple storesMost UI state
JotaiAtomicMany independent values
Redux ToolkitMature; structuredBig apps; teams familiar
ContextBuilt-inTheme, auth, locale
useStateTrivialComponent-local
XStateState machinesComplex 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 .