State management is hard because apps juggle many sources of truth, async data, UI interactions, and performance tradeoffs. Learn patterns to cut bugs.

In a frontend app, state is simply the data your UI depends on that can change over time.
When state changes, the screen should update to match. If the screen doesn’t update, updates inconsistently, or shows a mix of old and new values, you feel “state problems” immediately—buttons that stay disabled, totals that don’t match, or a view that doesn’t reflect what the user just did.
State shows up in both small and big interactions, such as:
Some of these are “temporary” (like a selected tab), while others feel “important” (like a cart). They’re all state because they influence what the UI renders right now.
A plain variable only matters where it lives. State is different because it has rules:
The real goal of state management isn’t storing data—it’s making updates predictable so the UI stays consistent. When you can answer “what changed, when, and why,” state becomes manageable. When you can’t, even simple features turn into surprises.
At the start of a frontend project, state feels almost boring—in a good way. You have one component, one input, and one obvious update. A user types into a field, you save that value, and the UI re-renders. Everything is visible, immediate, and contained.
Imagine a single text input that previews what you typed:
In that setup, state is basically: a variable that changes over time. You can point to where it’s stored and where it’s updated, and you’re done.
Local state works because the mental model matches the code structure:
Even if you use a framework like React, you don’t need to think deeply about architecture. The defaults are enough.
As soon as the app stops being “a page with a widget” and becomes “a product,” state stops living in one place.
Now the same piece of data might be needed across:
A profile name might be shown in a header, edited in a settings page, cached for faster loading, and also used to personalize a welcome message. Suddenly, the question isn’t “how do I store this value?” but “where should this value live so it stays correct everywhere?”
State complexity doesn’t grow gradually with features—it jumps.
Adding a second place that reads the same data isn’t “twice as hard.” It introduces coordination problems: keeping views consistent, preventing stale values, deciding what updates what, and handling timing. Once you have a few shared pieces of state plus async work, you can end up with behavior that’s difficult to reason about—even though each individual feature still looks simple on its own.
State gets painful when the same “fact” is stored in more than one place. Each copy can drift, and now your UI is arguing with itself.
Most apps end up with several places that can hold “truth”:
All of these are valid owners for some state. The trouble starts when they all try to own the same state.
A common pattern: fetch server data, then copy it into local state “so we can edit it.” For example, you load a user profile and set formState = userFromApi. Later, the server refetches (or another tab updates the record), and now you have two versions: the cache says one thing, your form says another.
Duplication also sneaks in through “helpful” transformations: storing both items and itemsCount, or storing selectedId and selectedItem.
When there are multiple sources of truth, bugs tend to sound like:
For each piece of state, pick one owner—the place where updates are made—and treat everything else as a projection (read-only, derived, or synced in a single direction). If you can’t point to the owner, you’re probably storing the same truth twice.
A lot of frontend state feels simple because it’s synchronous: a user clicks, you set a value, the UI updates. Side effects break that neat, step-by-step story.
Side effects are any actions that reach outside your component’s pure “render based on data” model:
Each one can fire later, fail unexpectedly, or run more than once.
Async updates introduce time as a variable. You no longer reason about “what happened,” but “what might still be happening.” Two requests can overlap. A slow response can arrive after a newer one. A component can unmount while an async callback still tries to update state.
That’s why bugs often look like:
Instead of sprinkling booleans like isLoading across the UI, treat async work as a small state machine:
Track the data and the status together, and keep an identifier (like a request id or query key) so you can ignore late responses. This makes “what should the UI show right now?” a straightforward decision, not a guess.
A lot of state headaches start with a simple mix-up: treating “what the user is doing right now” the same as “what the backend says is true.” They can both change over time, but they follow different rules.
UI state is temporary and interaction-driven. It exists to render the screen the way the user expects in this moment.
Examples include modals being open/closed, active filters, a search input draft, hover/focus, which tab is selected, and pagination UI (current page, page size, scroll position).
This state is usually local to a page or component tree. It’s fine if it resets when you navigate away.
Server state is data from an API: user profiles, product lists, permissions, notifications, saved settings. It’s “remote truth” that can change without your UI doing anything (someone else edits it, the server recalculates it, a background job updates it).
Because it’s remote, it also needs metadata: loading/error states, cache timestamps, retries, and invalidation.
If you store UI drafts inside server data, a refetch can wipe local edits. If you store server responses inside UI state without caching rules, you’ll fight stale data, duplicate fetches, and inconsistent screens.
A common failure mode: the user edits a form while a background refetch finishes, and the incoming response overwrites the draft.
Manage server state with caching patterns (fetch, cache, invalidate, refetch on focus) and treat it as shared and asynchronous.
Manage UI state with UI tools (local component state, context for truly shared UI concerns), and keep drafts separate until you intentionally “save” them back to the server.
Derived state is any value you can compute from other state: a cart total from line items, a filtered list from the original list + a search query, or a “canSubmit” flag from field values and validation rules.
It’s tempting to store these values because it feels convenient (“I’ll just keep total in state too”). But as soon as the inputs change in more than one place, you risk drift: the stored total no longer matches the items, the filtered list doesn’t reflect the current query, or the submit button stays disabled after fixing an error. These bugs are annoying because nothing looks “wrong” in isolation—each state variable is valid on its own, just inconsistent with the rest.
A safer pattern is: store the minimal source of truth, and compute everything else at read time. In React this can be a simple function, or a memoized calculation.
const items = useCartItems();
const total = items.reduce((sum, item) => sum + item.price * item.qty, 0);
const filtered = products.filter(p => p.name.includes(query));
In larger apps, “selectors” (or computed getters) formalize this idea: one place defines how to derive total, filteredProducts, visibleTodos, and every component uses the same logic.
Computing on every render is usually fine. Cache when you’ve measured a real cost: expensive transformations, huge lists, or derived values shared across many components. Use memoization (useMemo, selector memoization) so the cache keys are the true inputs—otherwise you’re back to drift, just wearing a performance hat.
State gets painful when it’s unclear who owns it.
The owner of a piece of state is the place in your app that has the right to update it. Other parts of the UI may read it (via props, context, selectors, etc.), but they shouldn’t be able to change it directly.
Clear ownership answers two questions:
When those boundaries blur, you get conflicting updates, “why did this change?” moments, and components that are difficult to reuse.
Putting state in a global store (or top-level context) can feel clean: anything can access it, and you avoid prop drilling. The tradeoff is unintended coupling—suddenly unrelated screens depend on the same values, and small changes ripple across the app.
Global state is a good fit for things that are truly cross-cutting, like the current user session, app-wide feature flags, or a shared notification queue.
A common pattern is to start local and “lift” state to the nearest common parent only when two sibling parts need to coordinate.
If only one component needs the state, keep it there. If multiple components need it, lift it to the smallest shared owner. If many distant areas need it, then consider global.
Keep state close to where it’s used unless sharing is required.
This keeps components easier to understand, reduces accidental dependencies, and makes future refactors less scary because fewer parts of the app are allowed to mutate the same data.
Frontend apps feel “single-threaded,” but user input, timers, animations, and network requests all run independently. That means multiple updates can be in flight at once—and they don’t necessarily finish in the order you started them.
A common collision: two parts of the UI update the same state.
query on every keystroke.query (or the same results list) when changed.Individually, each update is correct. Together, they can overwrite each other depending on timing. Even worse, you can end up showing results for a previous query while the UI displays the new filters.
Race conditions show up when you fire request A, then quickly fire request B—but request A returns last.
Example: the user types “c”, “ca”, “cat”. If the “c” request is slow and the “cat” request is fast, the UI might briefly show “cat” results and then get overwritten by stale “c” results when that older response finally arrives.
The bug is subtle because everything “worked”—just in the wrong order.
You generally want one of these strategies:
AbortController).A simple request ID approach:
let latestRequestId = 0;
async function fetchResults(query) {
const requestId = ++latestRequestId;
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
const data = await res.json();
if (requestId !== latestRequestId) return; // stale response
setResults(data);
}
Optimistic updates make the UI feel instant: you update the screen before the server confirms. But concurrency can break assumptions:
To keep optimism safe, you typically need a clear reconciliation rule: track the pending action, apply server responses in order, and if you must rollback, rollback to a known checkpoint (not “whatever the UI looks like now”).
State updates aren’t “free.” When state changes, the app has to figure out what parts of the screen might be affected and then do the work to reflect the new reality: re-calculating values, re-rendering UI, re-running formatting logic, and sometimes re-fetching or re-validating data. If that chain reaction is bigger than it needs to be, the user feels it as lag, jank, or buttons that seem to “think” before responding.
A single toggle can accidentally trigger a lot of extra work:
The outcome isn’t just technical—it’s experiential: typing feels delayed, animations hitch, and the interface loses that “snappy” quality people associate with polished products.
One of the most common causes is state that’s too broad: a “big bucket” object holding lots of unrelated information. Updating any field makes the whole bucket look new, so more of the UI wakes up than necessary.
Another trap is storing computed values in state and updating them manually. That often creates extra updates (and extra UI work) just to keep everything in sync.
Split state into smaller slices. Keep unrelated concerns separate so changing a search input doesn’t refresh an entire page of results.
Normalize data. Instead of storing the same item in many places, store it once and reference it. This reduces repeated updates and prevents “change storms” where one edit forces many copies to be rewritten.
Memoize derived values. If a value can be calculated from other state (like filtered results), cache that calculation so it only re-computes when the inputs actually change.
Good performance-minded state management is mostly about containment: updates should affect the smallest possible area, and expensive work should happen only when it truly needs to. When that’s true, users stop noticing the framework and start trusting the interface.
State bugs often feel personal: the UI is “wrong,” but you can’t answer the simplest question—who changed this value and when? If a number flips, a banner disappears, or a button disables itself, you need a timeline, not a hunch.
The fastest path to clarity is a predictable update flow. Whether you use reducers, events, or a store, aim for a pattern where:
setShippingMethod('express'), not updateStuff)Clear action logging turns debugging from “stare at the screen” into “follow the receipt.” Even simple console logs (action name + key fields) beat trying to reconstruct what happened from symptoms.
Don’t try to test every re-render. Instead, test the parts that should behave like pure logic:
This mix catches both “math bugs” and real-world wiring issues.
Async problems hide in gaps. Add minimal metadata that makes timelines visible:
Then when a late response overwrites a newer one, you can prove it immediately—and fix it with confidence.
Choosing a state tool is easier when you treat it as an outcome of design decisions, not the starting point. Before comparing libraries, map your state boundaries: what is purely local to a component, what needs to be shared, and what is actually “server data” that you fetch and synchronize.
A practical way to decide is to look at a few constraints:
If you start with “we use X everywhere,” you’ll store the wrong things in the wrong place. Start with ownership: who updates this value, who reads it, and what should happen when it changes.
Many apps do well with a server-state library for API data plus a small UI-state solution for client-only concerns like modals, filters, or draft form values. The goal is clarity: each type of state lives where it’s easiest to reason about.
If you’re iterating on state boundaries and async flows, Koder.ai can speed up the “try it, observe it, refine it” loop. Because it generates React frontends (and Go + PostgreSQL backends) from chat with an agent-based workflow, you can prototype alternative ownership models (local vs global, server cache vs UI drafts) quickly, then keep the version that stays predictable.
Two practical features help when experimenting with state: Planning Mode (to outline the state model before building) and snapshots + rollback (to safely test refactors like “remove derived state” or “introduce request IDs” without losing a working baseline).
State gets easier when you treat it like a design problem: decide who owns it, what it represents, and how it changes. Use this checklist when a component starts feeling “mysterious.”
Ask: Which part of the app is responsible for this data? Put state as close as possible to where it’s used, and lift it up only when multiple parts truly need it.
If you can compute something from other state, don’t store it.
items, filterText).visibleItems) during render or via memoization.Async work is clearer when you model it directly:
status: 'idle' | 'loading' | 'success' | 'error', plus data and error.isLoading, isFetching, isSaving, hasLoaded, …) instead of a single status.Aim for fewer “how did it get into this state?” bugs, changes that don’t require touching five files, and a mental model where you can point to one place and say: this is where the truth lives.