top of page

State Management in Web Apps Without the Mess

  • Contributor
  • May 21, 2025
  • 5 min read

The previous posts in this path covered API design and authentication patterns. This post covers the frontend concern that determines whether your application feels snappy or janky: state management — how your application stores, updates, and synchronizes the data it needs to display.

State management has a reputation for being complicated. Redux, MobX, Zustand, Jotai, Recoil, XState — the ecosystem of state management libraries suggests that this is a deeply complex problem requiring sophisticated tools. The reality is simpler: most state management problems come from not distinguishing between different kinds of state, and from treating server data as if it were local application data.

The Two Kinds of State

Every web application has two fundamentally different kinds of state, and mixing them together is the source of most state management pain.

Client state is data that exists only in the browser. Form inputs, UI toggles (is the sidebar open?), the current tab in a tabbed interface, a modal's visibility, the user's theme preference. This state is created by the user's interactions, lives for the duration of the page session, and does not need to be synchronized with a server.

Server state is data that comes from your API. User profiles, product listings, order history, notification counts. This state is owned by the server, cached temporarily in the browser, and must be synchronized — what the user sees should match what the server knows, or at least be close.

The mistake: treating server state as client state. Fetching data from the API, storing it in a global client state store (Redux, for example), and manually managing loading states, error states, caching, invalidation, and re-fetching. This produces enormous amounts of boilerplate code that reimplements what HTTP caching and server state libraries handle automatically.

The solution: use a server state library (React Query/TanStack Query, SWR, Apollo Client for GraphQL) for server data, and keep client state in the simplest tool that works — usually the framework's built-in state (React's useState, Vue's reactive refs).

Server State Libraries: The Game Changer

Server state libraries handle the fetch-cache-synchronize cycle that developers used to implement manually. You tell the library "fetch user data from this endpoint" and it handles caching (do not re-fetch if we already have recent data), deduplication (if three components request the same data simultaneously, make one API call), background re-fetching (update stale data without showing a loading spinner), and error/retry handling.

The result: components declare what data they need, and the library handles when and how to fetch it. No more tracking loading states in a global store. No more manually invalidating cached data. No more race conditions when the same data is requested from multiple places.

The mental model shift: instead of "fetch data once, store it in global state, manually update it" the pattern becomes "declare the data this component needs, let the library manage the lifecycle." This produces less code, fewer bugs, and better user experience (background re-fetching means users see fresh data without loading spinners).

Client State: Keep It Simple

For state that does not come from a server, the simplest tool is usually the right one.

Component state (useState in React, ref/reactive in Vue) handles state that belongs to a single component — form inputs, toggle states, local UI flags. This is the default. Start here and only move state elsewhere when you have a reason.

Lifted state handles state that multiple sibling components need. Move the state to the nearest common parent and pass it down as props. This is basic component composition, not a state management pattern — but it solves a surprising number of "I need shared state" problems.

Context (React Context, Vue provide/inject) handles state that many components across the tree need without passing it through every intermediate component. Theme preferences, locale settings, authentication state, and feature flags are good context candidates. Context is not a general-purpose state store — it is a dependency injection mechanism. Use it for values that change infrequently and are needed broadly.

External state libraries (Zustand, Jotai, MobX) handle complex client state that needs to be shared broadly and updated frequently. A collaborative editing interface, a complex multi-step wizard with interdependent fields, or a real-time dashboard with local filters and view preferences might warrant a dedicated state library. But reach for these after simpler approaches prove insufficient, not as a default.

Derived State: Compute, Do Not Store

A common state management mistake: storing computed values alongside the data they are derived from, then keeping them in sync. If you store a list of items and a filteredItems array, you must update filteredItems every time items changes or the filter criteria change. Miss an update and the UI shows stale data.

The fix: derive computed values at render time. Store the items and the filter criteria. Compute the filtered list from those two values when rendering. Memoize the computation if it is expensive. The derived value is always consistent with its inputs because it is computed from them, not stored separately.

This principle extends broadly. Do not store a "total price" alongside a list of cart items — compute it. Do not store a "has errors" flag alongside a form — derive it from the validation state. Do not store a "sorted list" alongside an unsorted list — sort at render time.

The reduction in state reduces the number of things that can go out of sync, which reduces bugs.

URL as State

The URL is state that users can bookmark, share, and navigate. For many applications, significant state belongs in the URL rather than in component state.

Search filters, pagination, selected tabs, sort order, and view modes are URL state candidates. When a user filters a product list and shares the URL, the recipient should see the same filtered view. When the user clicks back, they should return to their previous filter state.

The implementation: sync relevant state to URL query parameters. Use your framework's router to read and write URL state. The URL becomes the source of truth for navigation-related state, and components derive their view from the URL.

The benefit extends beyond sharing. URL-based state survives page refreshes, works with the browser's back/forward buttons, and is bookmarkable. Component state that is equivalent to URL state but stored in memory provides none of these benefits.

Common Patterns and Anti-Patterns

Anti-pattern: global state for everything. Putting all state in a single global store creates unnecessary coupling. A component that manages a form does not need its state in a global store. When everything is global, every state change potentially affects every component, making the application harder to reason about and debug.

Pattern: colocation. Keep state as close to where it is used as possible. Start with component state. Lift only when needed. Use context for widely needed, infrequently changing values. Use a state library only for genuinely complex shared state.

Anti-pattern: synchronizing state manually. Keeping two state values in sync — updating B whenever A changes — is fragile. Derive B from A instead. If you find yourself writing "when X changes, also update Y," reconsider whether Y should be derived from X rather than stored independently.

Pattern: optimistic updates. When the user takes an action (adds an item to the cart), update the UI immediately without waiting for the server response. If the server confirms, you are done. If the server rejects, roll back the UI change. This makes the interface feel instant while maintaining server authority over the data.

The Takeaway

State management becomes simpler when you separate server state (use a server state library) from client state (use the simplest tool that works). Derive computed values instead of storing them. Put navigation-related state in the URL. Keep state colocated with the components that use it.

The right amount of state management tooling is the minimum that solves your actual problems. Start simple, add complexity only when the simpler approach has proven insufficient, and always ask: does this state need to be stored, or can it be derived?

Next in the "Full-Stack Fundamentals" learning path: We'll cover error handling across the full stack — designing error flows that give users clear information and give developers the diagnostic data they need.

bottom of page