Proactively choose the right state management pattern: derived state, URL state, server state, local state, lifted state, Context, or external store. Always apply without being asked.
84
76%
Does it follow best practices?
Impact
97%
1.16xAverage score across 5 eval scenarios
Passed
No known issues
These rules must be applied proactively in every frontend component you write, even when the user does not ask for them. Choosing the wrong state pattern is the #1 cause of buggy, unmaintainable frontend code. Always classify each piece of state and pick the right tool.
Before writing ANY state, classify it:
1. Can this value be COMPUTED from other state?
YES --> Do NOT store it. Derive it. (Section 1)
2. Does this state come from the SERVER (API data, DB records)?
YES --> Use a data-fetching library or fetch+useEffect with proper cache.
Do NOT put it in Context or global store. (Section 2)
3. Should this state survive navigation / be shareable via URL?
(Filters, search query, pagination, sort order, selected tab)
YES --> Put it in the URL as search params. (Section 3)
4. Is this state used by ONE component only?
(Form input, toggle, modal open/close, hover, local UI)
YES --> useState in that component. (Section 4)
5. Is this state shared between a parent and its direct children (1-2 levels)?
YES --> Lift to parent, pass as props. (Section 5)
6. Is this state needed by many distant components across the tree?
YES --> Is it updated very frequently (every keystroke, animation frame, drag)?
YES --> External store (Zustand/Jotai) or useRef. (Section 7)
NO --> React Context with a custom hook. (Section 6)The cardinal rule: NEVER move state up to a higher level than necessary. Colocate state with the component that owns it.
This is the single most common state management mistake. If a value can be calculated from existing state, compute it inline or in a useMemo. NEVER store it in a separate useState.
function Cart({ items }: { items: CartItem[] }) {
const [total, setTotal] = useState(0);
const [itemCount, setItemCount] = useState(0);
const [hasExpensiveItem, setHasExpensiveItem] = useState(false);
// BUG: these can go out of sync with items
useEffect(() => {
setTotal(items.reduce((sum, i) => sum + i.price * i.quantity, 0));
setItemCount(items.reduce((sum, i) => sum + i.quantity, 0));
setHasExpensiveItem(items.some(i => i.price > 100));
}, [items]);
return <div>Total: ${total} ({itemCount} items)</div>;
}function Cart({ items }: { items: CartItem[] }) {
// Derive directly -- always in sync, no extra renders, no bugs
const total = items.reduce((sum, i) => sum + i.price * i.quantity, 0);
const itemCount = items.reduce((sum, i) => sum + i.quantity, 0);
const hasExpensiveItem = items.some(i => i.price > 100);
// Use useMemo ONLY if the computation is genuinely expensive (>1ms)
const sortedItems = useMemo(
() => [...items].sort((a, b) => b.price - a.price),
[items]
);
return <div>Total: ${total} ({itemCount} items)</div>;
}Rule: If you see useEffect that reads state A and calls setState for state B, you almost certainly have derived state that should be computed inline instead.
Data from APIs (product lists, user profiles, order history) is SERVER state. It has different needs than client state: caching, revalidation, loading/error states, deduplication.
// BAD: manually managing server data in context
function ProductProvider({ children }: { children: ReactNode }) {
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch('/api/products')
.then(r => r.json())
.then(setProducts)
.catch(e => setError(e.message))
.finally(() => setLoading(false));
}, []);
// Problems: no cache, no revalidation, no dedup, stale on navigate back,
// every consumer re-renders when ANY product data changes
return (
<ProductContext.Provider value={{ products, loading, error }}>
{children}
</ProductContext.Provider>
);
}// Option A: React Query / TanStack Query (preferred for apps with multiple API calls)
function useProducts() {
return useQuery({
queryKey: ['products'],
queryFn: () => fetch('/api/products').then(r => r.json()),
});
}
function ProductList() {
const { data: products, isLoading, error } = useProducts();
if (isLoading) return <Spinner />;
if (error) return <div role="alert">Failed to load products</div>;
return <ul>{products.map(p => <ProductCard key={p.id} product={p} />)}</ul>;
}
// Option B: fetch + useEffect (fine for simpler apps / prototypes)
function useProducts() {
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const controller = new AbortController();
fetch('/api/products', { signal: controller.signal })
.then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); })
.then(setProducts)
.catch(e => { if (e.name !== 'AbortError') setError(e.message); })
.finally(() => setLoading(false));
return () => controller.abort();
}, []);
return { products, loading, error };
}Rule: Server data belongs in a custom hook or data-fetching library, NOT in React Context or global state. The only exception is when you have a simple prototype with one or two API calls.
State that should survive page refresh, be shareable via URL, or work with browser back/forward MUST live in URL search params.
// BAD: filters lost on refresh, can't share URL, back button doesn't work
function ProductPage() {
const [search, setSearch] = useState('');
const [category, setCategory] = useState('all');
const [sortBy, setSortBy] = useState('name');
const [page, setPage] = useState(1);
// ...
}// With React Router v6+ or Next.js
function ProductPage() {
const [searchParams, setSearchParams] = useSearchParams();
const search = searchParams.get('q') ?? '';
const category = searchParams.get('category') ?? 'all';
const sortBy = searchParams.get('sort') ?? 'name';
const page = Number(searchParams.get('page') ?? '1');
const updateFilters = (updates: Record<string, string>) => {
setSearchParams(prev => {
const next = new URLSearchParams(prev);
Object.entries(updates).forEach(([k, v]) => {
if (v) next.set(k, v); else next.delete(k);
});
return next;
});
};
return (
<>
<SearchBar value={search} onChange={q => updateFilters({ q, page: '1' })} />
<CategoryFilter value={category} onChange={c => updateFilters({ category: c, page: '1' })} />
<SortSelector value={sortBy} onChange={s => updateFilters({ sort: s })} />
<ProductGrid search={search} category={category} sortBy={sortBy} page={page} />
<Pagination page={page} onChange={p => updateFilters({ page: String(p) })} />
</>
);
}Rule: Ask yourself "should this state survive a page refresh?" If yes, it belongs in the URL.
State that belongs in URL: search queries, filter selections, sort order, pagination, selected tab (if meaningful), open modal with specific content (e.g. /products?preview=123).
State that does NOT belong in URL: form input mid-typing, hover state, animation state, whether a dropdown is open.
State used by only one component stays in that component. Do not lift it, do not put it in context.
// BAD: modal visibility has no business being global
const useAppStore = create((set) => ({
isDeleteModalOpen: false,
setDeleteModalOpen: (open: boolean) => set({ isDeleteModalOpen: open }),
deleteTargetId: null as string | null,
// ... 20 other unrelated pieces of state
}));
function ProductRow({ product }: Props) {
const { setDeleteModalOpen } = useAppStore();
return <button onClick={() => setDeleteModalOpen(true)}>Delete</button>;
}function ProductRow({ product, onDelete }: Props) {
const [showConfirm, setShowConfirm] = useState(false);
return (
<>
<button onClick={() => setShowConfirm(true)}>Delete</button>
{showConfirm && (
<ConfirmDialog
message={`Delete "${product.name}"?`}
onConfirm={() => { onDelete(product.id); setShowConfirm(false); }}
onCancel={() => setShowConfirm(false)}
/>
)}
</>
);
}Use useState for: modal open/close, dropdown open/close, form input values, toggle/switch state, accordion expand/collapse, tooltip visibility, local loading spinners, temporary error messages.
When two sibling components need the same state, lift it to their nearest common parent.
function OrderPage() {
const [cart, setCart] = useState<CartItem[]>([]);
const [selectedCategory, setSelectedCategory] = useState('all');
const addToCart = (item: CartItem) => setCart(prev => [...prev, item]);
const removeFromCart = (index: number) =>
setCart(prev => prev.filter((_, i) => i !== index));
// Derived state -- NOT stored separately
const total = cart.reduce((sum, item) => sum + item.price * item.quantity, 0);
return (
<>
<MenuSidebar
selectedCategory={selectedCategory}
onSelectCategory={setSelectedCategory}
/>
<MenuGrid category={selectedCategory} onAddItem={addToCart} />
<CartPanel items={cart} total={total} onRemove={removeFromCart} />
</>
);
}Stop and do NOT reach for Context if: props go only 1-2 levels deep. Passing onAddItem to MenuGrid which passes it to MenuItem is fine -- that is NOT prop drilling, that is normal React.
Prop drilling becomes a problem when: The same prop passes through 3+ intermediate components that do not use it themselves. Only then consider Context.
Context is for state needed by many distant components: authentication/user, shopping cart (in e-commerce), theme, locale. Context is NOT a general-purpose state manager.
// src/context/AuthContext.tsx
interface AuthState {
user: User | null;
isAuthenticated: boolean;
}
interface AuthContextType extends AuthState {
login: (credentials: Credentials) => Promise<void>;
logout: () => void;
}
const AuthContext = createContext<AuthContextType | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
// Stable function references prevent unnecessary re-renders
const login = useCallback(async (credentials: Credentials) => {
const user = await authApi.login(credentials);
setUser(user);
}, []);
const logout = useCallback(() => {
authApi.logout();
setUser(null);
}, []);
// Memoize the context value to prevent re-renders when parent renders
const value = useMemo(
() => ({ user, isAuthenticated: !!user, login, logout }),
[user, login, logout]
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
// ALWAYS wrap context in a custom hook with a null check
export function useAuth(): AuthContextType {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
return ctx;
}// BAD: dumping all state into one giant context
<AppProvider> {/* user, theme, locale */}
<CartProvider> {/* cart items */}
<MenuProvider> {/* menu data -- this is SERVER state! */}
<UIProvider> {/* modal states -- these are LOCAL state! */}
<FilterProvider> {/* search/sort -- this is URL state! */}
{children}
</FilterProvider>
</UIProvider>
</MenuProvider>
</CartProvider>
</AppProvider>Context rules:
useAuth, useCart) with null-check erroruseMemouseState)Use Zustand, Jotai, or similar ONLY when Context causes perf issues (frequent updates re-rendering many consumers) or when state logic is complex enough to benefit from middleware (devtools, persistence, undo).
// Use Zustand when: many components need state that updates often
interface CanvasStore {
cursorPosition: { x: number; y: number };
zoom: number;
selectedElementIds: Set<string>;
setCursor: (pos: { x: number; y: number }) => void;
setZoom: (zoom: number) => void;
toggleSelection: (id: string) => void;
}
const useCanvasStore = create<CanvasStore>((set) => ({
cursorPosition: { x: 0, y: 0 },
zoom: 1,
selectedElementIds: new Set(),
setCursor: (pos) => set({ cursorPosition: pos }),
setZoom: (zoom) => set({ zoom }),
toggleSelection: (id) => set((state) => {
const next = new Set(state.selectedElementIds);
next.has(id) ? next.delete(id) : next.add(id);
return { selectedElementIds: next };
}),
}));
// Components subscribe to ONLY the slice they need (no unnecessary re-renders)
function Minimap() {
const zoom = useCanvasStore(s => s.zoom);
return <div>Zoom: {Math.round(zoom * 100)}%</div>;
}Do NOT reach for Zustand/Redux when: Context with useMemo handles it fine. Most CRUD apps, dashboards, and e-commerce sites do NOT need an external store.
// BAD for forms with many fields -- tedious and error-prone
function RegistrationForm() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [phone, setPhone] = useState('');
const [address, setAddress] = useState('');
// 6 separate useState calls, 6 separate onChange handlers...
}// Option A: Single object state (good for 4+ fields)
interface FormData {
name: string;
email: string;
password: string;
confirmPassword: string;
}
function RegistrationForm() {
const [form, setForm] = useState<FormData>({
name: '', email: '', password: '', confirmPassword: '',
});
const [errors, setErrors] = useState<Partial<Record<keyof FormData, string>>>({});
const updateField = <K extends keyof FormData>(field: K, value: FormData[K]) => {
setForm(prev => ({ ...prev, [field]: value }));
// Clear error on edit
if (errors[field]) setErrors(prev => ({ ...prev, [field]: undefined }));
};
// Derived: validation state computed, not stored
const isValid = form.name.trim() !== '' && form.email.includes('@')
&& form.password.length >= 8 && form.password === form.confirmPassword;
return (
<form onSubmit={handleSubmit}>
<input value={form.name} onChange={e => updateField('name', e.target.value)} />
{/* ... */}
<button type="submit" disabled={!isValid}>Register</button>
</form>
);
}
// Option B: useReducer (good for forms with complex transitions)
type FormAction =
| { type: 'SET_FIELD'; field: keyof FormData; value: string }
| { type: 'SET_ERRORS'; errors: Partial<Record<keyof FormData, string>> }
| { type: 'RESET' };
function formReducer(state: FormData, action: FormAction): FormData {
switch (action.type) {
case 'SET_FIELD': return { ...state, [action.field]: action.value };
case 'RESET': return { name: '', email: '', password: '', confirmPassword: '' };
default: return state;
}
}Rule: Individual useState per field is fine for 1-3 fields. For 4+ fields, use an object state or useReducer. Form state is almost always LOCAL -- do not put it in Context or global store.
When a user action triggers an API call, update the UI immediately and roll back on failure.
function TodoList() {
const [todos, setTodos] = useState<Todo[]>(initialTodos);
const toggleTodo = async (id: string) => {
// 1. Save previous state for rollback
const previous = todos;
// 2. Optimistically update
setTodos(prev => prev.map(t =>
t.id === id ? { ...t, completed: !t.completed } : t
));
// 3. Send to server, rollback on failure
try {
await api.toggleTodo(id);
} catch {
setTodos(previous); // Rollback
toast.error('Failed to update. Please try again.');
}
};
return (
<ul>
{todos.map(todo => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
/>
{todo.name}
</li>
))}
</ul>
);
}Rule: For actions where the server almost always succeeds (toggling, liking, adding to cart), use optimistic updates. For destructive/irreversible actions (delete, payment), wait for server confirmation.
function SearchResults() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<Result[]>([]);
useEffect(() => {
// BUG: no cleanup -- if user types fast, responses arrive out of order
// and results can show data for an old query
fetch(`/api/search?q=${query}`)
.then(r => r.json())
.then(setResults);
}, [query]);
return <input value={query} onChange={e => setQuery(e.target.value)} />;
}function SearchResults() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<Result[]>([]);
useEffect(() => {
if (!query.trim()) { setResults([]); return; }
const controller = new AbortController();
fetch(`/api/search?q=${query}`, { signal: controller.signal })
.then(r => r.json())
.then(setResults)
.catch(e => { if (e.name !== 'AbortError') console.error(e); });
return () => controller.abort(); // Cancel previous request
}, [query]);
return <input value={query} onChange={e => setQuery(e.target.value)} />;
}Rule: Every useEffect that fetches data MUST have a cleanup function (AbortController or cancelled flag). Without it, you get race conditions where stale responses overwrite fresh ones.
For apps without React, use a simple pub/sub store:
type Listener<T> = (state: T) => void;
function createStore<T>(initial: T) {
let state = initial;
const listeners = new Set<Listener<T>>();
return {
getState: () => state,
setState: (update: Partial<T> | ((prev: T) => T)) => {
state = typeof update === 'function' ? update(state) : { ...state, ...update };
listeners.forEach(fn => fn(state));
},
subscribe: (fn: Listener<T>) => {
listeners.add(fn);
return () => listeners.delete(fn);
},
};
}| State type | Pattern | Example |
|---|---|---|
| Computable from other state | Derive inline / useMemo | Total, item count, validation |
| Server data (API responses) | Data-fetching hook / library | Products, user profile, orders |
| URL-worthy (survives refresh) | useSearchParams / URL | Filters, search, pagination, sort |
| Single-component UI | useState | Modal open, dropdown, form input |
| Parent + children (1-2 levels) | Lift to parent, pass props | Selected item + detail view |
| Cross-cutting, low-frequency | React Context + custom hook | Auth, cart, theme, locale |
| Cross-cutting, high-frequency | External store (Zustand) | Cursor position, canvas zoom |
| Complex transitions | useReducer | Multi-step form, complex toggle logic |