Choosing State Tools: Decision Framework
By now, you understand Zustand, Jotai, Context, and React Query. But how do you choose when starting a new feature? This article distills the series into a practical decision framework that handles 95% of real-world scenarios. I have used this framework to architect state in five production React applications with zero regrets.
The Decision Tree
Start with this flowchart whenever you need to add new state:
1. Is this data stored on a server (users, posts, products)?
→ YES: Use React Query (or SWR)
→ NO: Go to step 2
2. Will this state be used by more than one component?
→ NO: Use local state (useState)
→ YES: Go to step 3
3. Is this state theme, locale, or user auth?
→ YES: Use React Context (simple, built-in)
→ NO: Go to step 4
4. Do you have many small, independent pieces of state?
→ YES: Consider Jotai (atomic, composable)
→ NO: Use Zustand (store, simpler mental model)
This tree covers 90% of cases. Let me detail each branch.
Branch 1: Server Data → React Query
If the data originates on a server and can change independently of the user's actions, use React Query:
import { useQuery } from '@tanstack/react-query';
export function UserProfile({ userId }) {
const { data: user } = useQuery({
queryKey: ['users', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
});
return <div>{user?.name}</div>;
}
React Query handles caching, background refetches, and synchronization. This is the right tool for API data, and trying to use Zustand or Context instead will cause bugs.
Branch 2: Single-Component State → useState
If only one component uses the state, stick with useState:
export function SearchInput() {
const [query, setQuery] = useState('');
return (
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
);
}
Adding Zustand or Jotai here is overengineering. Keep it simple.
Branch 3: Static Cross-App Data → React Context
If the state is theme, locale, or user auth (static-ish, set once at app start), use Context:
const AuthContext = createContext();
export function App() {
const [user, setUser] = useState(null);
return (
<AuthContext.Provider value={{ user, setUser }}>
<Dashboard />
</AuthContext.Provider>
);
}
export function Profile() {
const { user } = useContext(AuthContext);
return <div>Logged in as {user?.email}</div>;
}
Context is zero-cost (built into React) and sufficient for infrequently-changing data. If theme changes frequently, upgrade to Zustand.
Branch 4A: Granular State → Jotai
If state is granular and you need fine-grained reactivity (a filter panel with 20 toggles, a metrics dashboard where each metric depends on others), use Jotai:
import { atom, useAtom } from 'jotai';
const filterAAtom = atom(true);
const filterBAtom = atom(false);
const filterCAtom = atom(true);
export function FilterPanel() {
const [a, setA] = useAtom(filterAAtom);
const [b, setB] = useAtom(filterBAtom);
const [c, setC] = useAtom(filterCAtom);
return (
<div>
<label><input checked={a} onChange={(e) => setA(e.target.checked)} /> A</label>
<label><input checked={b} onChange={(e) => setB(e.target.checked)} /> B</label>
<label><input checked={c} onChange={(e) => setC(e.target.checked)} /> C</label>
</div>
);
}
Each filter is an atom. When one changes, only components using that atom re-render. This is optimal for highly-granular state.
Branch 4B: Grouped State → Zustand
If state is grouped by feature (a user preferences store, a modal state store, a form draft), use Zustand:
import { create } from 'zustand';
export const usePreferencesStore = create((set) => ({
darkMode: false,
language: 'en',
notifications: true,
setDarkMode: (value) => set({ darkMode: value }),
setLanguage: (lang) => set({ language: lang }),
setNotifications: (value) => set({ notifications: value }),
}));
export function Settings() {
const darkMode = usePreferencesStore((state) => state.darkMode);
const setDarkMode = usePreferencesStore((state) => state.setDarkMode);
return (
<label>
<input
type="checkbox"
checked={darkMode}
onChange={(e) => setDarkMode(e.target.checked)}
/>
Dark Mode
</label>
);
}
All related state lives in one store. This is easier to reason about and scales well as the store grows.
Real-World Example: A Task Management App
Let me apply this framework to a real app:
App state breakdown:
| State | Type | Tool |
|---|---|---|
| Current user (fetched from /api/me) | Server | React Query |
| List of tasks (from /api/tasks) | Server | React Query |
| Filter (all/active/completed) | Global, UI | Zustand |
| Sort order (name/date) | Global, UI | Zustand |
| Dark mode theme | Global, cross-app | Context |
| Currently editing task ID | Local | useState |
| Draft text in edit input | Local | useState |
Architecture:
// 1. React Query for server data
const { data: tasks } = useQuery({
queryKey: ['tasks'],
queryFn: () => fetch('/api/tasks').then(r => r.json()),
});
// 2. Zustand for UI state
const useTaskUIStore = create((set) => ({
filter: 'all',
sortBy: 'date',
setFilter: (f) => set({ filter: f }),
setSortBy: (s) => set({ sortBy: s }),
}));
// 3. Context for theme
const ThemeContext = createContext();
<ThemeContext.Provider value={theme}>...</ThemeContext.Provider>
// 4. useState for local form state
const [editingId, setEditingId] = useState(null);
const [draftText, setDraftText] = useState('');
Each piece of state lives in the tool best suited to it.
Decision Checklist
Before choosing a tool, ask:
- Data ownership: Does the server own this data? → React Query
- Scope: Used by more than one component? → Global tool
- Frequency: Does it change frequently? → Zustand/Jotai (not Context)
- Granularity: Many independent pieces? → Jotai
- Grouping: Related by feature? → Zustand
- Static: Theme, locale, auth? → Context
- Complexity: Complex mutations? → Zustand with immer
Key Takeaways
- Server data: React Query (non-negotiable; not Zustand).
- Local state: useState (single component).
- Global static state: Context (theme, locale, auth).
- Global granular state: Jotai (many independent atoms).
- Global grouped state: Zustand (feature-based stores).
- Team consistency: Pick one tool per category and standardize.
Frequently Asked Questions
Can I migrate from Zustand to Jotai later?
Yes, but costly. Choose upfront based on your state shape. If state is granular, start with Jotai. If grouped, start with Zustand.
What if I start with Context and it is too slow?
You can migrate to Zustand incrementally, wrapping the Context value with a Zustand store. Split the Context into two: one for static data (Context), one for frequent updates (Zustand).
Should I plan for scaling?
Yes. If you are building a large app, decide on a state architecture upfront. A hybrid (React Query + Zustand + Context) is standard. Avoid mixing tools for the same purpose.
What about Redux, Recoil, or other libraries?
Redux is overkill for most modern React apps. Recoil is mature but less popular than Jotai. Stick with Zustand, Jotai, and React Query for 2026 — they are the industry standard.
Can I use multiple Zustand stores?
Yes, create one store per feature (userStore, modalStore, formStore). This mirrors a Redux architecture but with less boilerplate. Avoid a single monolithic store.