Back to Blog
Frontend11 min readJun 2026

State Management in Frontend Apps

State management is the most over-engineered part of frontend. Learn the five kinds of state, how to pick the simplest tool for each, and why most apps reach for a global store far too early.

FrontendState ManagementReactArchitecture
SB

Sri Balaji

Founder · TheSimplifiedTech

On this page

You wired up a global store for a toggle

A teammate opens the codebase and finds a Redux store, three slices, a middleware, and a set of action creators. They follow the wiring to see what important, app-wide thing it manages. The answer: whether a dropdown menu is open. One boolean. It lives in a global store, reachable from every component in the app, persisted across routes, traced through DevTools, to remember if a little arrow points up or down.

This is the most common failure in frontend, and it is not a beginner mistake, senior teams do it constantly. State management has a reputation for being hard, so people reach for the heaviest tool they know first, before they have a problem that needs it. The result is apps where a one-line change touches four files, and where nobody can tell which state actually matters.

The skill is not learning Redux, Zustand, or Jotai. The skill is knowing what kind of state you have and putting it in the smallest place that works. Most state does not belong in a global store. A lot of it is not even client state at all.

Who this is for

Frontend developers, comfortable with [JavaScript](/blog/javascript-for-the-browser) and React components, who keep hearing "just use a state library" and want a framework for deciding **where** each piece of state should live, not which library to install. No prior Redux or Zustand experience assumed.

Pick the narrowest scope that works

State should live in the narrowest scope that still works, and move outward only when something outside that scope genuinely needs it.
The one rule this whole article is about

Almost every state decision is a question of scope: who needs to read this value, and who needs to change it? If only one component cares, it is local. If two siblings care, lift it to their parent. If the whole app cares, it might be global. The mistake is starting at the widest scope "just in case", you pay the complexity cost forever for a need that usually never arrives.

A reminder only you need, on a sticky note on your own deskLocal UI state, useState inside one component
A note your deskmate also needs, stuck where you both sitLifted state, held in the shared parent of two components
A policy the whole company must see, on the lobby noticeboardGlobal state, a store every component can read
Putting your lunch order on the company noticeboardA toggle in a global Redux store, wrong desk entirely
Where you write a note depends on who needs to read it.

Keep the sticky note on the right desk. Most of what gets pinned to the global noticeboard is a private reminder that only one component ever reads, it just got promoted out of habit.

The five kinds of state, by scope

Before you can choose a tool, you have to name what you are holding. Almost everything falls into one of five buckets, ordered from narrowest scope to widest. The further right you go, the more tools and ceremony you take on, so the goal is always to stay as far left as the requirement allows.

two need iteveryone needs itit lives on a serverit belongs in the link
Local UI

one component

Lifted / Shared

a few components

Global App

whole app

Server-Cache

remote data

URL

the address bar

Component

useState

Parent / Context

props or context

Store

Zustand / Redux

Query cache

TanStack Query

Router

search params

State types by scope, start at the left, move right only when forced.

  1. 1

    Does only one component use it?

    Keep it local with useState. A form input, a hover flag, an open/closed toggle, these almost never leave the component that owns them. This is where most state should stay.

  2. 2

    Do a couple of nearby components need it?

    Lift it to their closest common parent and pass it down as props. Two siblings sharing a selected tab? The tab index lives in the parent. No library required.

  3. 3

    Does genuinely everything need it, far apart in the tree?

    Now consider Context or a small global store, for things like the signed-in user, theme, or feature flags. The bar is high: it must be needed in many distant places and rarely change.

  4. 4

    Does the data actually live on a server?

    Stop. This is not client state. Use a server-state library (TanStack Query, SWR, RTK Query) that handles fetching, caching, and refetching. Do not copy it into a store.

  5. 5

    Should the value survive a refresh or be shareable via link?

    Put it in the URL. A search query, active filter, page number, or selected tab often belongs in search params so a copied link reproduces the exact view.

A cheat sheet: state type to tool

Map each kind of state to its example and the simplest tool that fits. If you can answer "what kind of state is this?" you have already answered "what should I reach for?". Notice how few rows actually call for a global store.

State typeExampleThe right tool
Local UIInput value, dropdown open, hover, an accordion's expanded panel`useState` / `useReducer`
Lifted / sharedSelected row two sibling panels both readState in the common parent + props
Global appSigned-in user, theme, feature flagsContext, or a small store (Zustand)
Server-cacheProduct list, user profile, anything from an APITanStack Query / SWR / RTK Query
URLSearch term, filters, page number, active tabRouter search params (`useSearchParams`)
Pick the row first, the tool second.

Pro tip

When in doubt, default to **local**. It is trivial to lift state upward later when a second component needs it. Pulling a tangle of global state back down once everything depends on it is a refactor nobody volunteers for.

Local state vs a server-cache hook in code

Here is the contrast that matters most in practice. The first block is local state, a value owned and changed entirely on the client. The second is server state, data that lives in a database and is only borrowed by the browser. They look superficially similar but have completely different needs, so they get completely different tools.

Counter.tsx
tsx
// Local UI state: the client owns it outright.
// No network, no cache, no "is it stale?", just a value and a setter.
import { useState } from "react";

export function Counter() {
  const [count, setCount] = useState(0);

  return (
    <button onClick={() => setCount((c) => c + 1)}>
      Clicked {count} times
    </button>
  );
}
useUser.tsx
tsx
// Server-cache state: the SERVER owns the truth.
// The library handles caching, loading, errors, and refetching.
import { useQuery } from \"@tanstack/react-query\";

async function fetchUser(id: string) {
  const res = await fetch(`/api/users/${id}`);
  if (!res.ok) throw new Error(\"Failed to load user\");
  return res.json() as Promise<{ id: string; name: string }>;
}

export function useUser(id: string) {
  // We don't store the user in useState or a global store.
  // We describe HOW to fetch it; the cache is managed for us.
  return useQuery({
    queryKey: [\"user\", id],
    queryFn: () => fetchUser(id),
    staleTime: 60_000, // treat as fresh for 60s before refetching
  });
}

// In a component:
//   const { data, isLoading, error } = useUser(userId);
// You get loading + error + cached data for free, no useEffect soup,
// no manual setState, no duplicating the response into a store.

The counter needs nothing but useState. The user needs caching, a loading flag, an error path, and a strategy for staleness, all of which the query library provides. Trying to manage the user with useState + useEffect recreates a worse version of that library by hand; putting the counter in a global store adds machinery a button never asked for.

Server state is not client state

This is the single most clarifying idea in modern frontend, so it gets its own section. Client state is state your app invents and owns: a toggle, a form draft, the current theme. You are the source of truth. Server state is a copy of data that lives somewhere else, a database behind an API. You do not own it; you are borrowing a snapshot.

That difference changes everything. Server state can go stale the moment you fetch it, because someone else might change the record. It needs caching so you do not refetch on every render, refetching so it stays current, and shared loading and error states. None of those concerns exist for client state. This is why purpose-built server-state libraries exist, and why shoving API responses into a general-purpose store is a mismatch: a store has no concept of staleness, background refetch, or request deduplication.

The tell-tale sign

If you find yourself writing `useEffect` to fetch data and then `setState` to store it, and then more code to track loading and errors, you are hand-rolling a server-state library. Use a real one. It will be less code and far more correct.

Once you split these two, your "global store" usually shrinks to almost nothing. Most of what people pile into Redux is server state that belongs in a query cache. Take that out, and what is left, the genuinely global, client-owned stuff, is often small enough for plain Context or a tiny store.

Common mistakes that cost hours

  1. A global store for everything. Reaching for Redux/Zustand on day one, then routing a dropdown toggle and a form input through it. Every trivial change becomes a four-file edit. Default to local; promote outward only when a real second consumer appears.
  2. Duplicating server data in a store. Fetching a list, copying it into a global store, and then fighting to keep that copy in sync with the server. Now you have two sources of truth and stale-data bugs. Let a server-state library own the cache; read it where you need it.
  3. Prop-drilling instead of composition. Threading a prop through five intermediate components that do not use it, then "fixing" it with global state. Often the real fix is component composition, pass JSX as children so the data-owning parent renders the consumer directly. See Component Architecture & Design Systems.
  4. Context for high-frequency updates. Putting fast-changing values (mouse position, every keystroke) in a single Context re-renders every consumer on every change. Context is for low-frequency, widely-read values like theme or user, not a performance-sensitive firehose.

Takeaways

The whole article in seven lines

  • State decisions are scope decisions: who reads it, who changes it.
  • Pick the narrowest scope that works; move outward only when forced.
  • Five kinds: local UI, lifted/shared, global app, server-cache, URL.
  • Default to local `useState`; lifting later is cheap, un-globalizing is not.
  • Server state is not client state, give it a query library, not a store.
  • Never duplicate server data into a global store; you create a second truth.
  • Reach for composition before prop-drilling, and before going global.

Where to go next

State lives inside components, so the cleaner your component boundaries, the more obvious every state decision becomes. Strengthen the layers above and below this one:

The next time you feel the urge to install a state library, pause and ask one question: what kind of state is this, and what is the narrowest scope that works? Most of the time, the answer is useState, and that is a feature, not a shortcoming.

Want to go deeper?

This article covers concepts taught hands-on in the Cloud Engineer and DevOps career paths, with real terminal labs, production scenarios, and structured lessons.