Skip to content
By Yash KapureFrontend11 min readMay 21, 2026

State Management Without the Pain

Most state bugs trace back to one root: two sources of truth for the same thing. The fix is not a better library. It is a clear answer to "where does this live?" before you write the first line.

Article focus

5 state categories

One owner per piece of data

Key takeaways

  • Server state, URL state, local state, and global state each have a natural home. Mixing them creates bugs.
  • If you can derive it, derive it. If you must store it, store it once.
  • Zustand replaces Context for most shared global state - less boilerplate, no provider nesting.

Why is state management still hard in 2026?

Because the question is not technical - it is architectural. Which layer owns this data? The answer determines everything that follows.

Every frontend framework has solved the "how" of state management. React has useState, useReducer, Context, and server components. Zustand, Jotai, and Valtio exist for global state. React Query and SWR handle server state beautifully. The problem is never a missing tool. It is not knowing which tool to reach for.

The result is the same pattern in every codebase I audit: a user object in Context, a duplicated subset in local state, a third copy derived from a prop, and a fourth fetched from an API. When the UI shows stale data, nobody knows which copy is the truth.

The fix is a decision tree. Before you write a single line of state code, answer: what kind of state is this? See the Frontend Architecture pillar for the full framework this tree fits into.

State Decision Flow

Click each state type to see its tool, description, and examples.

Server State

Tool: React Query / SWR

Data that lives on the backend. Fetch, cache, invalidate. Never duplicate into local state.

Examples: User profile, product list, notifications
Can I derive it?Is it server data?URL state?Local?Global?

The five kinds of frontend state

Server state, URL state, local UI state, shared global state, and derived state. Each has a clear owner.

The rule is simple: every piece of data falls into exactly one of these buckets. If you catch yourself putting the same value in two buckets, you have a bug waiting to happen.

  • Server state - data that lives on the backend. Fetch with React Query, SWR, or server components. Cache, refetch, invalidate. Never duplicate into local state.
  • URL state - the current route, search params, hash. Owned by the router. Read with useSearchParams, useParams. Push updates via navigation.
  • Local UI state - is this dropdown open? Which tab is selected? Owned by useState or useReducer near the component.
  • Shared global state - theme, auth, preferences. Crosses many components. Owned by Zustand or Context near the root.
  • Derived state - computed from other state. Use useMemo. If you can calculate it from existing state, do not store it separately.

The decision tree in practice

Start at the top: is it server data? If yes, use a data-fetching library. If no, is it URL-level? If yes, use the router. Continue down until you find the right home.

Here is the exact logic I run through for every new piece of state:

typescript

// Decision tree (run this mentally before every state addition)

// 1. Can I derive it from existing state?
//    -> useMemo. Stop.

// 2. Does it come from the server?
//    -> React Query / SWR / server component. Never cache locally.

// 3. Does it belong in the URL? (pagination, filters, selected item)
//    -> Router search params or route params.

// 4. Is it needed by only one component or its immediate children?
//    -> useState or useReducer in the closest common parent.

// 5. Is it needed by many unrelated components across the tree?
//    -> Zustand store (or Context for simple cases).

// 6. Is none of the above? You probably don't need this state.

When useState is the right answer (and when it is not)

useState is right when the state is local, transient, and only one component cares. It is wrong when two distant leaves need the same value.

useState is the default for a reason. It is simple, it is local, and it does not leak. A dropdown open state, a form input value, a toggle - these are perfect useState candidates.

The red flag is prop drilling beyond two or three levels. If ComponentA needs state from ComponentF, and they are separated by four layers of components that just pass props through, you have a structural problem. Either the state belongs in a shared context/store, or the component tree needs refactoring.

I use a simple heuristic: if I am passing a prop through more than two intermediate components that do not use it, I reach for either lifting the state up to a proper shared location or restructuring the tree.

Zustand over Context for shared global state

Zustand gives you a store outside the React tree. No provider nesting, no unnecessary re-renders, less boilerplate than Redux.

Context is not a state management tool. It is a dependency injection mechanism. It works fine for values that rarely change - theme, locale, current user - but it causes unnecessary re-renders for frequently updated state because every consumer re-renders when the context value changes.

Zustand solves this cleanly. It creates a store outside the React tree and only re-renders components that subscribe to the specific slice that changed.

I use Context for truly static values (theme, locale config) and Zustand for shared state that changes during a session (shopping cart, notification preferences, feature flags).

typescript

// Zustand store - no provider needed
import { create } from 'zustand';

type ThemeStore = {
  theme: 'light' | 'dark';
  setTheme: (theme: 'light' | 'dark') => void;
};

export const useThemeStore = create<ThemeStore>((set) => ({
  theme: 'light',
  setTheme: (theme) => set({ theme }),
}));

// In any component - only re-renders if theme changes
const theme = useThemeStore((s) => s.theme);
const setTheme = useThemeStore((s) => s.setTheme);

Server state - the most common mistake

Do not put server data into local state. Fetch it with a dedicated library that handles caching, refetching, and invalidation.

The most common state management mistake I see is fetching data from an API and storing it in useState or Context. Now you are responsible for refetching, cache invalidation, loading states, error states, and stale data detection. That is a lot of code to write and test.

React Query (TanStack Query) and SWR handle all of this. They give you loading, error, and success states out of the box. They deduplicate requests. They refetch on focus. They let you invalidate queries when mutations happen. Combined with shared API contracts, this eliminates the most common class of frontend bugs.

tsx

// Instead of this:
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
  fetchUser(id).then(setUser);
}, [id]);

// Do this:
const { data: user, isLoading, error } = useQuery({
  queryKey: ['user', id],
  queryFn: () => fetchUser(id),
});

// Mutation that auto-invalidates the cache:
const { mutate } = useMutation({
  mutationFn: updateUser,
  onSuccess: () => queryClient.invalidateQueries({ queryKey: ['user'] }),
});

Need help implementing this?

I build these systems for a living - let's work on yours.

Frontend Architecture & System Design

Structure so teams can ship. Clear boundaries, state strategy, and contracts. New features land cleanly; refactors stay low-risk.

TypeScriptComponent designState managementAPI contracts+4
Discuss this service

Frontend Development

Production UIs in React, Next.js, or Vue- third-party and payment integrations included. Built for real traffic and maintained in production.

ReactNext.jsTypeScriptTailwind CSSshadcn/ui+5
Discuss this service
$ git checkout --your-opinion

Your turn

  • >Did this help you ship something?
  • >Which part clicked the most for you?
  • >Applying this at work? Share your experience.

Discussion

Leave a comment

Thoughts, questions, corrections - all welcome.

Sign in with GitHub to comment - free & takes 10 seconds

Recommended blogs

Continue reading

View all blogs
Illustration for JavaScript closures and lexical scope
Frontend
14 min readApril 24, 2026

JavaScript Closures Explained: Why Your Functions Remember Everything

Learn JavaScript closures with interactive demos. Covers lexical scope, the var vs let loop bug, stale React hooks, memory leak patterns, and closure interview questions.

Reference photo by Asad Photo on Pexels

Read article
Developers working on laptops together at a workspace
Engineering
7 min readApril 15, 2026

Onboard to Any Git Repo Fast: 5 Commands Before I Read Code

Five git commands I run before reading any code: find churn hotspots, map ownership risk, spot bug clusters, read delivery cadence, and detect firefighting patterns in minutes.

Photo from Pexels

Read article

Subscribe for new posts, or read how referrals and sponsored placements are handled on this site.