Skip to main content

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:

StateTypeTool
Current user (fetched from /api/me)ServerReact Query
List of tasks (from /api/tasks)ServerReact Query
Filter (all/active/completed)Global, UIZustand
Sort order (name/date)Global, UIZustand
Dark mode themeGlobal, cross-appContext
Currently editing task IDLocaluseState
Draft text in edit inputLocaluseState

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.

Further Reading