Written by

Halkwinds Editorial Team

Halkwinds Research & Editorial

Published June 26, 2026
Blog image
Application

State Management in Modern Web Applications: Patterns and Trade-offs

How to choose and implement the right state management approach — from local component state to distributed server state.

State management is where most frontend architectures quietly succeed or catastrophically fail. When your team is small and the app is simple, almost any approach works. But as features accumulate, data sources multiply, and the number of engineers touching the codebase grows, the wrong state strategy becomes a tax on every pull request. If your engineers routinely debate "where does this data live?" or spend afternoons chasing stale UI bugs, you're paying that tax. This article breaks down the major state management patterns for modern web applications, the trade-offs between them, and how to make architectural decisions that hold up as your product and team scale.

  • Background / Why This Matters
  • Core Concepts and Architecture
  • Implementation Strategy
  • Scaling and Operational Considerations
  • Common Mistakes / What to Avoid
  • Frequently Asked Questions
  • Conclusion

Background / Why This Matters

A decade ago, "state management" in a React app was almost synonymous with Redux. Teams reflexively reached for a single global store, wrote actions and reducers for everything, and accepted a fair amount of boilerplate as the cost of predictability. That model made sense in an era when most application data was fetched once, transformed on the client, and mutated locally.

The reality of modern applications has shifted. Most of the "state" in a typical web app isn't really client state at all — it's a cached copy of data that lives on a server. Your user list, dashboard metrics, and product catalog are owned by the backend. Treating them as global client state means you're manually reimplementing caching, invalidation, and synchronization logic that a dedicated tool can handle far better.

For engineering managers, this matters because state management decisions have outsized effects on onboarding time, bug frequency, and feature velocity. Research and industry surveys consistently suggest that a large share of frontend bugs trace back to inconsistent or stale state. When state is scattered across a dozen conventions, every new hire needs weeks to become productive, and every reviewer has to hold too much context in their head.

Actionable takeaway: Audit your current codebase and classify where each piece of state actually lives — server, URL, component, or truly global. This classification alone often reveals that 60–70% of what you're managing globally should be handled as server cache instead.

Core Concepts and Architecture

The single most useful mental model is to stop thinking about "state" as one thing. In a well-architected frontend, state falls into distinct categories, and each has a natural home.

The Four Types of State

  • Server state (server cache): Data owned by your backend that you fetch, cache, and display. This is asynchronous, can become stale, and is shared across users. Examples: API responses, database records.
  • Client/UI state: Ephemeral state that only the frontend cares about — modal open/closed, form input before submission, current tab, drag-and-drop position.
  • URL state: State that belongs in the address bar so it's shareable and bookmarkable — filters, pagination, search queries, selected entity IDs.
  • Global client state: Truly application-wide client concerns — authentication status, theme, feature flags, notification queues.

The architectural mistake most teams make is forcing all four into a single tool. The modern consensus is to match each category to a purpose-built solution.

How the Major Tools Map to State Types

Tool Best for Strengths Weaknesses
React built-in (useState, useReducer, Context) Local UI state, small global state Zero dependencies, ideal for component-scoped concerns Context re-renders can cause performance issues at scale
Zustand Global client state Minimal boilerplate, tiny bundle, no provider wrapping, selective subscriptions Less opinionated, fewer guardrails for large teams
Redux (with Redux Toolkit) Complex, highly-interconnected client state Strict conventions, excellent devtools, time-travel debugging, mature ecosystem More boilerplate, overkill for server data
TanStack Query Server state Automatic caching, background refetch, deduplication, invalidation, loading/error states Not designed for client-only state

Notice the sharp division of labor. TanStack Query owns the server. Zustand or Redux own genuinely global client state. React's own primitives handle everything local. This layered approach is what most high-functioning frontend teams converge on today.

Actionable takeaway: Adopt the rule "server data goes in TanStack Query, everything else gets a decision." Removing server data from your global store often shrinks it by an order of magnitude and eliminates entire classes of synchronization bugs.

Implementation Strategy

Choosing tools is the easy part. The harder work is establishing conventions that hold across a growing team. Here's a practical sequencing that we recommend to clients building or refactoring applications.

Step 1: Start Local, Escalate Deliberately

Default to useState inside the component. Only lift state up when two or more components genuinely need it. Only reach for a global store when prop-drilling becomes painful across more than three or four levels. This "escalation ladder" prevents the premature centralization that makes codebases rigid.

Step 2: Move Server State to TanStack Query First

If you're refactoring an existing Redux-heavy app, migrating server data to TanStack Query typically delivers the biggest immediate win. A common pattern looks like this:

  • Wrap the app in a QueryClientProvider.
  • Replace hand-written fetch-plus-useEffect-plus-reducer flows with useQuery hooks keyed by resource.
  • Replace manual cache updates with useMutation and queryClient.invalidateQueries.

Teams frequently report deleting hundreds of lines of loading-flag and error-flag boilerplate in this migration. The mental model shifts from "fetch, store, update, invalidate manually" to "declare what data this component needs; the library handles freshness."

Step 3: Pick One Global Client Store and Standardize

For the remaining global client state — auth, theme, UI preferences — pick one tool and enforce it. For most teams starting fresh, Zustand hits the sweet spot: minimal ceremony, no provider boilerplate, and selective subscriptions that avoid the re-render cascades that plague React Context. For large teams that value strict conventions and rich debugging, Redux Toolkit remains a defensible choice.

The tool matters less than the consistency. A team that uses Zustand consistently will out-ship a team that mixes Redux, Context, and three custom stores — every time.

Step 4: Put Shareable State in the URL

Filters, search terms, active tabs, and pagination should live in the URL via query parameters. This makes application state shareable, bookmarkable, and survivable across refreshes — for free. It also removes a surprising amount of state from your stores entirely.

This is exactly the kind of architectural decision Halkwinds helps teams codify when we build custom applications — establishing the conventions and folder structure up front so that state ownership is obvious to every engineer who joins later.

Actionable takeaway: Write a one-page "state decision tree" in your repo's README. When an engineer has a new piece of state, they should be able to answer in under 30 seconds where it belongs.

Scaling and Operational Considerations

State strategies that work for a 5,000-line app can buckle at 200,000 lines. As your application and team grow, several operational concerns come into focus.

Performance and Re-renders

The most common scaling problem is unnecessary re-renders. React Context re-renders every consumer whenever any part of the context value changes, which becomes a real performance drag in large trees. Tools like Zustand and Redux (with selectors) allow components to subscribe to only the slices they care about. Measure this with React DevTools' profiler before optimizing — intuition about re-renders is frequently wrong.

Cache Configuration for Server State

TanStack Query's power comes from tuning staleTime and gcTime (garbage collection time). A dashboard metric that changes hourly can have a long staleTime; a real-time notification count needs a short one or a background refetch interval. Getting these values right is the difference between a snappy app and one that either hammers your API or shows stale data.

Team Conventions and Onboarding

At scale, the biggest operational cost isn't runtime performance — it's cognitive load. Establish and document:

  • Consistent naming for query keys (e.g., ['users', userId]).
  • A single directory or module pattern for stores.
  • Lint rules that discourage anti-patterns, like fetching directly inside components outside of query hooks.

Testing

Well-separated state is dramatically easier to test. Server state mocked at the query layer, client stores tested in isolation, and pure UI components tested without any store at all. When state is entangled, tests become brittle integration tests that break on every change.

Actionable takeaway: Set explicit staleTime values per query type rather than relying on defaults, and add a profiler check to your definition of done for any component rendering large lists.

Common Mistakes / What to Avoid

Across the codebases we review, the same handful of anti-patterns appear again and again.

  • Putting server data in Redux/Zustand. This forces you to manually handle loading states, caching, and invalidation — reinventing what TanStack Query does automatically. This is the single most common and costly mistake.
  • One giant global store for everything. When every piece of state is global, nothing has clear ownership, and every change risks unintended side effects across the app.
  • Overusing Context for high-frequency updates. Context is great for stable, rarely-changing values (theme, auth). Using it for rapidly-changing state triggers re-render storms.
  • Duplicating state. Storing the same data in two places (e.g., a store and a component) guarantees they'll eventually drift out of sync. Keep a single source of truth.
  • Premature abstraction. Introducing a heavy state library on day one for an app that has three components. Start simple; escalate when pain justifies it