React State Management
Architect scalable frontend applications by selecting between local state, server caches, and global client stores.
TL;DR
- Colocate component state strictly where it is
rendered. - Delegate remote data fetching to dedicated
servercache libraries. - Manage shared global client state with lightweight
Zustandstores.
State Architecture Tiers
Local Component StateStore UI toggles and form inputs locally.
const [isOpen, setIsOpen] = useState(false);
const toggle = () => setIsOpen((o) => !o);Lifted Sibling StateLift shared state up to closest common parent.
export function SearchShell() {
const [query, setQuery] = useState('');
return (
<CardView>
<InputBase value={query} onChange={setQuery} />
<ResultsList query={query} />
</CardView>
);
}Server State CacheQuery and cache remote endpoint data cleanly.
const { data, isPending } = useQuery({
queryKey: ['user', id],
queryFn: () => fetchUser(id),
});Global Stores with Zustand
Define Zustand StoreCreate lightweight store outside React hierarchy.
import { create } from 'zustand';
interface CartStore {
items: string[];
add: (item: string) => void;
}
export const useCartStore =
create<CartStore>((set) => ({
items: [],
add: (it) =>
set((s) => ({ items: [...s.items, it] })),
}));Atomic SelectorSubscribe only to specific slice to prevent renders.
export function CartBadge() {
const count = useCartStore((s) => s.items.length);
return <Badge text={String(count)} />;
}Action Dispatch Without StateExtract action functions without subscribing to state.
export function AddButton({ id }: { id: string }) {
const add = useCartStore((s) => s.add);
return <Button onClick={() => add(id)}>Add</Button>;
}External Store Subscriptions
useSyncExternalStore HookSubscribe safely to external store with React 18+.
const state = useSyncExternalStore(
store.subscribe,
store.getSnapshot
);Server Snapshot HydrationProvide server snapshot fallback for SSR hydration.
const state = useSyncExternalStore(
store.subscribe,
store.getSnapshot,
store.getServerSnapshot
);URL Query Parameter StateSynchronize view filters with browser query parameters.
const [searchParams, setSearchParams] =
useSearchParams();State Management Pitfalls
Premature GlobalizationPutting local modal flags into global stores.
// BAD: Global state for single modal
useGlobalStore((s) => s.loginModalOpen);Duplicate Server CachesCopying fetch responses into local useState buffers.
// BAD: Sync nightmare
const [items, setItems] = useState(data);Direct State MutationMutating arrays directly rather than returning copy.
// BAD: direct push
state.items.push(newItem);Tips
- Separate server state from client state by using TanStack Query or SWR
useQueryfor remote API endpoints and queries. - Use atomic state selectors when consuming global
zustandstores to ensure components only re-render when their slice changes.
Warnings
- Do not copy asynchronous server cache responses into local
useStatevariables where synchronization bugs will quickly emerge. - Avoid reaching for heavy global state stores when simple parent-child component
compositionsolves prop forwarding naturally.
In Practice
Build an optimized global counter store where display components only render when their data changes.
- Create typed store holding counter state and action handlers.
- Extract reactive count value using an atomic selector hook.
- Extract action dispatchers independently to avoid re-renders.
- Verify that clicking increment only triggers the counter display.
import { create } from 'zustand';
type Store = {
count: number;
inc: () => void;
};
export const useStore = create<Store>((set) => ({
count: 0,
inc: () => set((s) => ({ count: s.count + 1 })),
}));
export function Display() {
const count = useStore((s) => s.count);
return <CardTitle>Count: {count}</CardTitle>;
}
export function Action() {
const inc = useStore((s) => s.inc);
return <Button onClick={inc}>Increment</Button>;
}FAQ
Choose Zustand when state updates frequently or when multiple independent components read small slices of state, because Zustand selectors prevent the unnecessary re-render storms inherent in React Context.
Server state resides remotely, requiring asynchronous fetching, caching, and invalidation. Client state lives purely in browser memory, such as toggled drawers, theme preferences, and active tab indices.
Colocation keeps state as close as possible to the components that render it, simplifying maintenance, preventing premature abstraction, and minimizing the subtree affected by re-renders.