Manage application state with useState, useReducer, Context, Redux, or Zustand.
Server state is data that lives on the server and needs to stay in sync — fetch it with React Query or SWR, not useState.
import { useQuery } from "@tanstack/react-query";
function UserProfile({ id }: { id: string }) {
const { data, isLoading, error } = useQuery({
queryKey: ["user", id],
queryFn: () => fetch(`/api/users/${id}`).then(r => r.json()),
staleTime: 60_000 // cache for 1 minute
});
if (isLoading) return <p>Loading...</p>;
if (error) return <p>Error loading user.</p>;
return <h1>{data.name}</h1>;
}
Client state is UI-only data that doesn't need to persist on the server — modal open/closed, selected tab, input value.
// Client state: lives in the component, no server sync needed
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [activeTab, setActiveTab] = useState("overview");
Use SWR for lightweight data fetching with automatic revalidation on focus and reconnect.
import useSWR from "swr";
const fetcher = (url: string) => fetch(url).then(r => r.json());
function Dashboard() {
const { data } = useSWR("/api/stats", fetcher, { refreshInterval: 30000 });
return <p>Users: {data?.count}</p>;
}
Avoid duplicating server state in useState — let the fetching library own it and read from its cache.
// Wrong: duplicating server data into local state
const [user, setUser] = useState(null);
useEffect(() => { fetch("/api/me").then(r => r.json()).then(setUser); }, []);
// Right: let React Query own the state
const { data: user } = useQuery({ queryKey: ["me"], queryFn: () => fetch("/api/me").then(r => r.json()) });
Invalidate cache entries after mutations so the UI stays in sync without manual state updates.
const queryClient = useQueryClient();
await updateUser(data);
queryClient.invalidateQueries({ queryKey: ["user", id] }); // refetches automatically
Place state as close as possible to where it is used — don't lift state higher than necessary.
// Wrong: lifting filter state to the top-level App when only ProductList needs it
function App() {
const [filter, setFilter] = useState("all");
return <ProductList filter={filter} onFilterChange={setFilter} />;
}
// Right: colocate filter state inside ProductList where it belongs
function ProductList() {
const [filter, setFilter] = useState("all");
// ...no prop drilling needed
}
State used only in one component belongs in that component, not in a parent or global store.
State used by two sibling components should live in their closest common ancestor (lift state up one level, not all the way up).
// Both SearchBox and SearchResults need the query — lift to their parent
function SearchPage() {
const [query, setQuery] = useState("");
return (
<>
<SearchBox value={query} onChange={setQuery} />
<SearchResults query={query} />
</>
);
}
State used across unrelated parts of the app belongs in a global store (Zustand, Context, or Redux).
Avoid premature globalization — start local and lift only when a second consumer appears.
// Progression: local → lifted → global
// 1. useState in component (local)
// 2. useState in parent (lifted) — when a sibling needs it
// 3. Zustand or Context (global) — when distant components need it
Use functional state updates when new state depends on previous state to avoid stale closures.
// Wrong: may read stale count if React batches updates
setCount(count + 1);
// Right: always uses the latest committed state
setCount(prev => prev + 1);
setItems(prev => [...prev, newItem]);
Batch related state updates with Object.assign or a single state object to avoid extra renders.
// Two setState calls = two renders
setLoading(true);
setError(null);
// One setState call = one render
setState(prev => ({ ...prev, loading: true, error: null }));
Use Zustand's selector to subscribe only to the slice of state a component needs.
const useStore = create((set) => ({
count: 0, user: null, theme: "light",
setCount: (n) => set({ count: n }),
}));
// Only re-renders when count changes, not when user or theme changes
const count = useStore(state => state.count);
Derive values during render instead of storing derived state — computed values don't need setState.
// Wrong: storing derived state
const [items, setItems] = useState([]);
const [count, setCount] = useState(0); // always equals items.length
// Right: derive during render
const [items, setItems] = useState([]);
const count = items.length; // computed, always accurate, no sync needed
Use React.memo plus stable callback references to prevent child re-renders from parent state changes.
const handleClick = useCallback(() => dispatch({ type: "SUBMIT" }), [dispatch]);
const ExpensiveChild = React.memo(({ onClick }) => <button onClick={onClick}>Submit</button>);
Use Redux Toolkit's configureStore — createStore is deprecated since Redux 4.x.
import { configureStore } from "@reduxjs/toolkit";
import counterReducer from "./counterSlice";
export const store = configureStore({
reducer: { counter: counterReducer }
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
Define slices with createSlice to eliminate action type constants and manual spread reducers.
import { createSlice } from "@reduxjs/toolkit";
const counterSlice = createSlice({
name: "counter",
initialState: { value: 0 },
reducers: {
increment: state => { state.value += 1; }, // Immer allows direct mutation
decrement: state => { state.value -= 1; },
setValue: (state, action) => { state.value = action.payload; }
}
});
export const { increment, decrement, setValue } = counterSlice.actions;
export default counterSlice.reducer;
Wrap your app in <Provider store={store}> to make state available to all components.
import { Provider } from "react-redux";
root.render(<Provider store={store}><App /></Provider>);
Read state with useSelector and dispatch actions with useDispatch.
function Counter() {
const count = useSelector((state: RootState) => state.counter.value);
const dispatch = useDispatch<AppDispatch>();
return <button onClick={() => dispatch(increment())}>{count}</button>;
}
Use Redux DevTools to inspect state history, replay actions, and debug time-travel in development.
Create a Zustand store with state and actions in a single create call.
import { create } from "zustand";
const useStore = create<{ count: number; increment: () => void }>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
}));
function Counter() {
const { count, increment } = useStore();
return <button onClick={increment}>Count: {count}</button>;
}
Use Jotai atoms for fine-grained, component-level shared state without a central store.
import { atom, useAtom } from "jotai";
const countAtom = atom(0);
function Counter() {
const [count, setCount] = useAtom(countAtom);
return <button onClick={() => setCount((c) => c + 1)}>{count}</button>;
}
Use Recoil atoms and selectors to share state and compute derived values declaratively.
import { atom, selector } from "recoil";
const textState = atom({ key: "textState", default: "" });
const charCountState = selector({
key: "charCountState",
get: ({ get }) => get(textState).length, // derived from textState
});
Access Zustand state anywhere without wrapping the app in a Provider.
// No <Provider> needed — call the hook directly in any component
function Navbar() {
const user = useStore((state) => state.user); // selector for one slice
return <p>Hello {user?.name}</p>;
}
Choose Zustand or Jotai over Redux when you want minimal boilerplate for small to medium apps.
// Redux: actions + reducer + selectors + Provider setup
// Zustand: one create() call, one hook, no Provider required
// Jotai: one atom() call, one useAtom() hook, no Provider required
const theme = useStore((s) => s.theme); // direct selector access
Use useReducer when you have multiple related state values that change together, or when next state depends on the previous state in complex ways. If you find yourself writing several useState calls that always update together, that's a signal to consolidate with useReducer.
Context API replaces Redux for many medium-sized apps, but it's not optimized for high-frequency updates — every consumer re-renders when context value changes. For apps with frequent global state updates or large teams needing strict state patterns, Redux or Zustand will perform better.
Zustand has a much smaller API surface and requires no boilerplate — you define a store in a single function call versus Redux's actions, reducers, and selectors. Choose Zustand for smaller teams or projects where Redux's structure feels like overhead.
Any state change in the context value causes all consumers to re-render, even if they only use a part of the value. Fix this by splitting large contexts into smaller focused ones, or memoizing the context value with useMemo.
Yes, and it's often the right call — local UI state in useState, shared server data in React Query or SWR, and global client state in Context or Zustand can coexist cleanly. Mixing solutions by concern keeps each layer simple rather than forcing everything through one system.