State Management Advisor
Choose and implement the right state management solution for React applications.
Quick Start
Use local state by default, Context for simple global state, Zustand for complex client state, and TanStack Query for server state.
Instructions
Decision Tree
Start here:
-
Is it server data (from API)? → Use TanStack Query
-
Is it used in one component only? → Use useState
-
Is it simple global state (theme, auth)? → Use Context
-
Is it complex client state? → Use Zustand or Redux Toolkit
Local State (useState)
For component-specific state.
Basic usage:
function Counter() { const [count, setCount] = useState(0);
return ( <button onClick={() => setCount(count + 1)}> Count: {count} </button> ); }
With objects:
function Form() { const [formData, setFormData] = useState({ name: '', email: '' });
const handleChange = (field: string, value: string) => { setFormData(prev => ({ ...prev, [field]: value })); };
return ( <form> <input value={formData.name} onChange={e => handleChange('name', e.target.value)} /> </form> ); }
React Context
For simple global state shared across components.
Setup:
interface AuthContextValue { user: User | null; login: (credentials: Credentials) => Promise<void>; logout: () => void; }
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: React.ReactNode }) { const [user, setUser] = useState<User | null>(null);
const login = async (credentials: Credentials) => { const user = await api.login(credentials); setUser(user); };
const logout = () => { setUser(null); };
return ( <AuthContext.Provider value={{ user, login, logout }}> {children} </AuthContext.Provider> ); }
export function useAuth() { const context = useContext(AuthContext); if (!context) throw new Error('useAuth must be used within AuthProvider'); return context; }
Usage:
function App() { return ( <AuthProvider> <Router /> </AuthProvider> ); }
function Profile() { const { user, logout } = useAuth(); return <div>{user?.name} <button onClick={logout}>Logout</button></div>; }
Optimization - Split contexts:
// Separate frequently changing data const ThemeContext = createContext<Theme>(null); const ThemeUpdateContext = createContext<(theme: Theme) => void>(null);
function ThemeProvider({ children }) { const [theme, setTheme] = useState('light');
return ( <ThemeContext.Provider value={theme}> <ThemeUpdateContext.Provider value={setTheme}> {children} </ThemeUpdateContext.Provider> </ThemeContext.Provider> ); }
Zustand
Lightweight global state with minimal boilerplate.
Installation:
npm install zustand
Basic store:
import { create } from 'zustand';
interface CounterStore { count: number; increment: () => void; decrement: () => void; }
const useCounterStore = create<CounterStore>((set) => ({ count: 0, increment: () => set((state) => ({ count: state.count + 1 })), decrement: () => set((state) => ({ count: state.count - 1 })), }));
// Usage function Counter() { const count = useCounterStore((state) => state.count); const increment = useCounterStore((state) => state.increment);
return <button onClick={increment}>Count: {count}</button>; }
With async actions:
interface UserStore { users: User[]; loading: boolean; fetchUsers: () => Promise<void>; }
const useUserStore = create<UserStore>((set) => ({ users: [], loading: false, fetchUsers: async () => { set({ loading: true }); const users = await api.fetchUsers(); set({ users, loading: false }); }, }));
With middleware:
import { persist } from 'zustand/middleware';
const useStore = create( persist( (set) => ({ token: null, setToken: (token) => set({ token }), }), { name: 'auth-storage', } ) );
Redux Toolkit
For complex state with many interactions.
Installation:
npm install @reduxjs/toolkit react-redux
Setup store:
import { configureStore, createSlice } from '@reduxjs/toolkit';
const counterSlice = createSlice({ name: 'counter', initialState: { value: 0 }, reducers: { increment: (state) => { state.value += 1; }, decrement: (state) => { state.value -= 1; }, }, });
export const { increment, decrement } = counterSlice.actions;
export const store = configureStore({ reducer: { counter: counterSlice.reducer, }, });
export type RootState = ReturnType<typeof store.getState>; export type AppDispatch = typeof store.dispatch;
Provider:
import { Provider } from 'react-redux';
function App() { return ( <Provider store={store}> <Router /> </Provider> ); }
Usage:
import { useSelector, useDispatch } from 'react-redux';
function Counter() { const count = useSelector((state: RootState) => state.counter.value); const dispatch = useDispatch();
return ( <button onClick={() => dispatch(increment())}> Count: {count} </button> ); }
Async with createAsyncThunk:
import { createAsyncThunk } from '@reduxjs/toolkit';
export const fetchUsers = createAsyncThunk( 'users/fetch', async () => { const response = await api.fetchUsers(); return response.data; } );
const usersSlice = createSlice({ name: 'users', initialState: { data: [], loading: false }, reducers: {}, extraReducers: (builder) => { builder .addCase(fetchUsers.pending, (state) => { state.loading = true; }) .addCase(fetchUsers.fulfilled, (state, action) => { state.data = action.payload; state.loading = false; }); }, });
TanStack Query (React Query)
For server state management.
Installation:
npm install @tanstack/react-query
Setup:
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient();
function App() { return ( <QueryClientProvider client={queryClient}> <Router /> </QueryClientProvider> ); }
Fetching data:
import { useQuery } from '@tanstack/react-query';
function Users() { const { data, isLoading, error } = useQuery({ queryKey: ['users'], queryFn: fetchUsers, });
if (isLoading) return <Spinner />; if (error) return <Error message={error.message} />;
return <UserList users={data} />; }
Mutations:
import { useMutation, useQueryClient } from '@tanstack/react-query';
function CreateUser() { const queryClient = useQueryClient();
const mutation = useMutation({ mutationFn: createUser, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['users'] }); }, });
return ( <button onClick={() => mutation.mutate({ name: 'John' })}> Create User </button> ); }
With parameters:
function User({ id }: { id: string }) { const { data } = useQuery({ queryKey: ['user', id], queryFn: () => fetchUser(id), });
return <div>{data?.name}</div>; }
Optimistic updates:
const mutation = useMutation({ mutationFn: updateUser, onMutate: async (newUser) => { await queryClient.cancelQueries({ queryKey: ['users'] }); const previousUsers = queryClient.getQueryData(['users']);
queryClient.setQueryData(['users'], (old) => [...old, newUser]);
return { previousUsers };
}, onError: (err, newUser, context) => { queryClient.setQueryData(['users'], context.previousUsers); }, onSettled: () => { queryClient.invalidateQueries({ queryKey: ['users'] }); }, });
Jotai
Atomic state management.
Installation:
npm install jotai
Basic atoms:
import { atom, useAtom } from 'jotai';
const countAtom = atom(0);
function Counter() { const [count, setCount] = useAtom(countAtom);
return ( <button onClick={() => setCount(count + 1)}> Count: {count} </button> ); }
Derived atoms:
const countAtom = atom(0); const doubleCountAtom = atom((get) => get(countAtom) * 2);
function Display() { const [doubleCount] = useAtom(doubleCountAtom); return <div>Double: {doubleCount}</div>; }
Async atoms:
const usersAtom = atom(async () => { const response = await fetch('/api/users'); return response.json(); });
function Users() { const [users] = useAtom(usersAtom); return <UserList users={users} />; }
Comparison Matrix
Solution Best For Pros Cons
useState Local state Simple, built-in Limited to component
Context Simple global state Built-in, no deps Can cause re-renders
Zustand Complex client state Minimal, fast Smaller ecosystem
Redux Toolkit Large apps, teams Powerful, DevTools Verbose, learning curve
TanStack Query Server state Caching, sync Not for client state
Jotai Atomic state Fine-grained, modern Smaller ecosystem
Common Patterns
Combining Solutions
TanStack Query + Zustand:
// Server state with TanStack Query const { data: users } = useQuery(['users'], fetchUsers);
// Client state with Zustand const selectedUserId = useStore((state) => state.selectedUserId);
Context + TanStack Query:
function AuthProvider({ children }) { const { data: user } = useQuery(['me'], fetchCurrentUser);
return ( <AuthContext.Provider value={{ user }}> {children} </AuthContext.Provider> ); }
Form State
Controlled with useState:
function Form() { const [values, setValues] = useState({ name: '', email: '' });
const handleSubmit = (e) => { e.preventDefault(); api.submit(values); };
return ( <form onSubmit={handleSubmit}> <input value={values.name} onChange={e => setValues(prev => ({ ...prev, name: e.target.value }))} /> </form> ); }
With form library:
import { useForm } from 'react-hook-form';
function Form() { const { register, handleSubmit } = useForm();
const onSubmit = (data) => api.submit(data);
return ( <form onSubmit={handleSubmit(onSubmit)}> <input {...register('name')} /> </form> ); }
Troubleshooting
Context causing re-renders:
-
Split into multiple contexts
-
Use useMemo for context value
-
Consider Zustand instead
Stale data in TanStack Query:
-
Adjust staleTime and cacheTime
-
Use refetchInterval for polling
-
Invalidate queries after mutations
Redux boilerplate:
-
Use Redux Toolkit (not legacy Redux)
-
Use createSlice for reducers
-
Use createAsyncThunk for async
State updates not reflecting:
-
Check if mutating state directly
-
Use functional updates
-
Verify dependencies in useEffect