SevyDevy

Guide · Frontend

Frontend State Management at Scale: Server State vs UI State

25 min readUpdated By Saket Bhatnagar

Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.

In short

  • Frontend state management is deciding where each piece of changing data in your app lives, who may change it, and how every screen learns that it changed.
  • The biggest split is server state versus UI state. Server state is a copy of data the server owns, like stock counts. UI state belongs only to this screen, like an open menu.
  • Server state needs a server-state cache: a library that keeps each server answer, knows when it is stale (possibly out of date) and fetches it again. UI state needs only the smallest home that every reader can reach.
  • A rule that holds up at scale: server data in that cache, filters in the URL and small UI flags in components. A tiny global store (state every screen can read) holds the rest.
  • Beginners can stop after the section on where each piece of state should live. Engineers then get what we ran on TanStack Query 5.104.5, a popular server-state cache. That covers why a second tab still shows old stock, and an optimistic save that shows a change before the server confirms it, which a late read cannot overwrite.

What goes wrong when frontend state lives in the wrong place?

When frontend state management goes wrong, screens show wrong numbers, filters vanish on refresh and typing turns slow. Each bug comes from data kept in a home that does not match who owns it or how long it should last.

Picture a common setup. An online furniture marketplace gives each of its 1,200 workshops a seller dashboard for listings, orders and stock. The setup is made up, but the problems are real.

The dashboard started as a small React app (React is a popular library for building screens). Everything lived in one global store that every screen could read. Then the support tickets started, in three kinds.

  • A workshop sets a teak dining table's stock to 0 in one browser tab. The listings page, open in another tab, still shows 3. The seller trusts it and promises the table to a buyer on the phone.
  • A seller filters orders to show only unpaid ones, then refreshes the page. The filter is gone, and the list shows all 400 orders again.
  • Typing in the search box feels slow. The search text sits in one big shared store. About 60 components read that store, only one shows the search text, and all 60 redraw on every key press.

The store file has 38 slices, which are separate sections of the store. Each list has its own isLoading flag (a true or false value meaning still loading), error field and fetch code written by hand. Nobody remembers which ones refresh after a save.

The store is not too big by accident. It mixes two very different kinds of data and treats them the same way. The rest of this guide pulls them apart.

What is frontend state management?

Frontend state management is how you decide where changing data lives in a web app, who may change it, and how every screen that shows it stays correct. It is a design choice before it is a library choice.

State means any data that can change while the app runs and that affects what the user sees. The stock count of a table is state. So is whether the filter menu is open, or what the seller has typed in a box.

A component is one reusable piece of the screen, like the orders table. A store is a place outside any single component that holds state, so many components can read it. Redux and Zustand are popular store libraries.

Re-render means React runs a component again to work out what the screen should show. It happens when the component's own state changes, a context it reads changes, or its parent re-renders. Too many re-renders make typing and scrolling feel slow.

At small scale, any approach works. At scale, meaning many screens, many engineers and many users, three questions decide whether the app stays sane. Who owns this data? Who else needs it? How old can it safely be?

What is the difference between server state and UI state?

Server state is a copy of data that lives on a server and that other people can change, like stock counts and orders. UI state is data that exists only in this browser tab, like an open dropdown or a draft in a text box.

Picture a patient's attendant in a hospital ward. The things on her bedside stool are hers: the water bottle, the phone charger, the folded shawl. They stay exactly where she left them, and nobody at the nursing station cares.

The discharge time is different. The doctors and the ward own it. On the morning round the doctor said 4 p.m., but a test result can change it. Before she tells the relatives to come, a sensible attendant checks at the nursing station.

UI state is the bedside stool: yours, local, with no server copy to disagree with. Server state is the discharge time heard on the round: a copy of someone else's information that starts going stale the moment you hear it.

Where the picture breaks: the attendant can walk over and ask. A browser tab is never told when server data changes, unless you add a live connection. It has to decide for itself when to ask again.

The TanStack Query documentation makes the same split. It calls Redux, MobX and Zustand client-state libraries that can store server data, but inefficiently compared with a tool built for it. In its example, only theme and sidebar settings stay in the global store.

Server state and UI state compared on the seller dashboard
Server stateUI state
Who owns itThe server and its databaseThis browser tab
Dashboard examplesListings, stock counts, orders, payoutsOpen modal, selected row, search text, theme
Can others change it?Yes, buyers, other tabs and support staffUsually only this user, on this screen
Can it be out of date?Yes, from the moment it arrivesRarely, since no server copy changes under it
Needs loading and error statesYes, every read can failNo
Best homeA server-state cacheThe component, the URL or a small store
What goes wrong if mishandledStale numbers and wrong promisesLost filters and slow typing

There is a third kind worth naming. URL state is UI state that you put in the address bar, like ?status=unpaid&page=2. It survives a refresh and can be shared as a link. The lost orders filter was URL state kept in the wrong place.

Where should each piece of state live in a large frontend?

Each piece of state should live in the smallest place that every component reading it can reach. Server data goes in a server-state cache, shareable view settings go in the URL, and short-lived flags stay in the component.

The team draws the new layout on a whiteboard. Look at where the stock count lives in the diagram. It sits in one cache that every page reads, instead of separate copies in separate slices.

Where state lives in the seller dashboard

Each kind of state has one home. Notice that both pages read stock from the same cache entry, so neither page keeps a private copy of server data.

Diagram connections:

  • Marketplace API to Server-state cache (Fetch and refetch)
  • Server-state cache to Listings page (Listings and stock)
  • Server-state cache to Orders page (Orders)
  • URL query string to Orders page (status=unpaid)
  • URL query string to Listings page (Sort and page)
  • Small global store to Listings page (Seller and theme)
  • Marketplace API: Owns listings, stock and orders
  • Server-state cache: One copy per query, knows its age
  • URL query string: Filters, sort order and page number
  • Small global store: Theme, sidebar, logged-in seller
  • Listings page: Shows stock for each product
  • Orders page: Local state for the open order

Here are the five homes, from smallest to largest. Start at the top and move down only when the one above cannot do the job.

  • Derived value. If you can calculate it from other state, do not store it. The number of out-of-stock listings is a count over the listings, done during render.
  • Component state. Only one component reads it, like whether one order's details are expanded. It disappears when the component closes, which is usually what you want.
  • Lifted state. Two sibling components need it, so it moves up to their closest shared parent. The selected listing on the stock page is one example.
  • URL state. The user would expect it after a refresh or in a shared link. Filters, sort order, the current tab and the page number belong here.
  • Global store. Many distant parts of the app read it and it does not come from the server. The logged-in seller's name and the theme fit here.

The Redux style guide gives two rules that help in any home. Keep stored state minimal and derive the rest, like filtered lists or totals. Store related data in a normalized form, which makes items easy to look up by their ID.

Normalized means each item is stored once, and other places keep only its ID. That rule fixes a quiet dashboard bug. The old stock page stored a full copy of the selected listing, so its side panel kept the old number after a save.

Storing only selectedListingId and looking the listing up when drawing fixed it. There is now one copy of the listing, so there is nothing to fall out of step.

Reading URL state safely in TypeScript

The team moves the order filters into the URL. Anyone can type anything into an address bar, so the code must check values before trusting them. URLSearchParams is the browser's built-in helper for reading a query string.

typescript
type OrderStatus = "all" | "unpaid" | "shipped";
type OrderFilters = { status: OrderStatus; page: number };

const STATUSES: readonly OrderStatus[] = ["all", "unpaid", "shipped"];

function isStatus(value: string): value is OrderStatus {
  return (STATUSES as readonly string[]).includes(value);
}

function readFilters(search: string): OrderFilters {
  const params = new URLSearchParams(search);
  const raw = params.get("status") ?? "all";
  const n = Number(params.get("page") ?? "1");
  return {
    status: isStatus(raw) ? raw : "all",
    page: Number.isSafeInteger(n) && n >= 1 ? n : 1,
  };
}

console.log(readFilters("?status=unpaid&page=2"));  // { status: 'unpaid', page: 2 }
console.log(readFilters("?status=hacked&page=-5")); // { status: 'all', page: 1 }
console.log(readFilters("?page=Infinity"));         // { status: 'all', page: 1 }

What just happened? isStatus is a type guard: when it returns true, TypeScript treats the string as one of the three known values. Anything else falls back to all. The page rule accepts only whole numbers from 1 up, so -5, abc, 2.7 and Infinity all become page 1.

Check it yourself: save the code as filters.ts and run node filters.ts on Node 22.18 or a later 22 release, or Node 23.6 or newer. These run TypeScript files directly. The three lines print the results shown in the comments.

The URL is now the single source of truth for the order filters, so a refresh keeps the unpaid filter and a copied link opens the same view.

How does a server-state cache keep frontend data fresh?

A server-state cache keeps data fresh by storing each server response under a key, tracking how old it is, and fetching it again at set moments. Screens read the cached copy at once while a fresh copy loads in the background.

TanStack Query, SWR and RTK Query are well-known libraries of this kind. Our guide on API caching libraries compares them. Here you need only the ideas they share, shown with TanStack Query 5.104.5, the latest release on 19 October 2026.

Query key. A query key is the name of one cached answer, like listings for seller 42 with these filters. Two components asking for the same key share one cache entry, and a request already in flight is not sent twice.

Stale time. Stale time is how long a cached answer counts as fresh. While fresh, the cache serves it with no network call. Once stale, the cache still shows it, but fetches a new copy when a trigger fires.

Invalidation. Invalidation means marking cached answers as stale on purpose, usually right after a save. Screens that are showing those answers refetch straight away. It is the attendant checking at the nursing station before calling the relatives.

TanStack Query's Important Defaults page shows its starting settings. Cached data counts as stale at once unless you set a stale time. Stale queries refetch when a new component that needs them appears on screen, when the window is refocused and when the network reconnects. The next section shows what refocused really means.

The same page says unused cache entries are removed after 5 minutes. Failed reads are retried 3 times with exponential backoff, meaning each wait is longer than the last, before the screen shows an error.

The source of version 5.104.5 adds detail the page leaves out. In the browser the waits are 1, 2 and 4 seconds. On the server, unused entries have no removal timer and reads are not retried. Saves are not retried anywhere unless you ask.

One read through the server-state cache

Notice the stale branch: the page shows the old copy at once and refetches behind it, so a stale answer is shown briefly, never hidden.

Diagram connections:

  • Page asks for listings to Copy in the cache?
  • Copy in the cache? to Still fresh? (Yes)
  • Copy in the cache? to Fetch from the API (No, show a loader)
  • Still fresh? to Show the cached copy (Yes)
  • Still fresh? to Fetch from the API (No, show copy and refetch)
  • Fetch from the API to Update every reader
  • Page asks for listings: The listings page needs data
  • Copy in the cache?: Look up the query key
  • Still fresh?: Compare its age with the stale time
  • Show the cached copy: No network call at all
  • Fetch from the API: Ask the marketplace API
  • Update every reader: All pages using this key re-render

Choosing a stale time is a product question, not a coding one. Ask how wrong the number may be, and for how long. The team leaves stock on the default of 0, and gives a workshop's profile 5 minutes, since it rarely changes.

Why can a second browser tab still show old stock?

Each browser tab runs its own copy of the app, with its own cache in memory. A save and invalidation in one tab updates only that tab. The other tab finds out only when its own refetch rules fire.

Pause and predict. A seller sets stock to 0 in tab 1, then clicks back to tab 2, which loaded 20 seconds ago. If stock had a 30-second stale time, what does tab 2 show?

It still shows 3. The copy in tab 2 is 20 seconds old, so it is still fresh, and with default settings a fresh copy is not refetched on focus. That is why the team keeps stock on a stale time of 0.

There is a second catch. In version 5, the default refocus refetch listens only for the browser's visibilitychange event, which fires when a tab is hidden or shown again. The window focus-refetching guide says so, and the source file focusManager.ts confirms it.

So switching browser tabs triggers it, but clicking between two windows that are both visible on screen does not. We ran each case in jsdom, a browser imitation that runs in Node, with the results below.

What we ran on TanStack Query 5.104.5 in Node 22 with jsdom (19 October 2026)
SituationWhat happened
Two screens ask for the same key at onceOne network request, one shared cache entry
Tab hidden, then visible again, default stale timeOne background refetch
A window focus event with no visibility changeNo refetch
Stale time set, tab visible again before it runs outNo refetch, old stock still shown
Save and invalidate in tab 1Tab 1 shows 0, tab 2 still shows 3
A read that keeps failing4 attempts in total, then an error
A save that fails1 attempt only, no retry

The tests used fast stand-ins for real timings, like a 300-millisecond stale time in place of 30 seconds. Each React app creates its own QueryClient, the object that holds the cache, so two QueryClients stood in for two tabs.

You have three ways to close the gap. Keep stale time at 0 for numbers that drive decisions, so the other tab refetches as soon as it is shown again. Two windows side by side still miss that refetch.

Or sync tabs with TanStack's broadcastQueryClient plugin. It sends each successful cache update to other tabs of the same origin, meaning the same scheme, host and port. It uses a BroadcastChannel, a browser API for messages between them.

As of 19 October 2026 that plugin is marked experimental, and its docs warn that breaking changes can come in minor and patch releases. Pin your version if you use it. The third way is always needed: the server rechecks stock on every order.

Check it in your own app: open DevTools, the browser's built-in developer tools, then the Network tab, and switch to another browser tab and back. With a stale time of 0 you should see one new listings request. Inside a longer stale time you should see none.

How do optimistic updates work without showing wrong data?

An optimistic update changes the cached data on screen right away, before the server confirms the save, and puts the old value back if the save fails. It makes the app feel instant while the server stays the final judge.

On the stock page, a seller changes a bed's stock from 5 to 4. Waiting for the server feels slow when sellers update dozens of rows. So the page shows 4 at once and confirms it in the background.

  1. Stop any refetch in flight

    Cancel running fetches for the seller's listings, so an older response cannot land and overwrite the new number.

  2. Take a snapshot

    Save the current cached listings. This is the undo copy if the save fails.

  3. Write the new value into the cache

    Change the bed's stock to 4 in every cached listings query. Every page using those keys shows 4 straight away.

  4. Send the save

    The request goes to the marketplace API. Most of the time it succeeds in under a second (made up).

  5. Roll back on failure

    If the server rejects it, put the snapshot back and show a short message saying the save failed.

  6. Refetch either way

    After success or failure, invalidate the listings, so the screen ends on what the server really holds.

Here is that flow with TanStack Query's useMutation hook. A hook is a React function that adds a feature to a component, and a mutation is a save. Listing, StockChange and api are the team's own types and helpers.

The key ['listings', sellerId] works as a prefix. It matches every filtered listings query for that seller, such as ['listings', 42, { status: 'unpaid' }], and leaves other sellers alone.

typescript
const queryClient = useQueryClient();

const saveStock = useMutation({
  mutationFn: (change: StockChange) => api.updateStock(change),
  onMutate: async (change) => {
    const key = ["listings", change.sellerId];
    await queryClient.cancelQueries({ queryKey: key });
    const snapshot = queryClient.getQueriesData<Listing[]>({ queryKey: key });
    queryClient.setQueriesData<Listing[]>({ queryKey: key }, (old) =>
      old?.map((l) => (l.id === change.listingId ? { ...l, stock: change.stock } : l)));
    return { snapshot };
  },
  onError: (_err, _change, ctx) => {
    ctx?.snapshot.forEach(([key, data]) => queryClient.setQueryData(key, data));
  },
  onSettled: (_data, _err, change) =>
    queryClient.invalidateQueries({ queryKey: ["listings", change.sellerId] }),
});

What just happened? We type-checked this code in strict mode and ran it on version 5.104.5 with two filtered views open. Both views showed 4 within 10 milliseconds, while the server still held 5.

When we made a save of 2 fail, both views showed 2 for a moment, then went back to 4, the value the server still held. The rollback and the refetch agreed, so the screen did not jump a second time.

The cancelQueries line earns its place. In a run without it, a slow read started just before the seller changed stock from 4 to 7. It landed late, and the screen went 7, then back to the old 4, then 7 again.

This sample is safe for one save at a time. We also ran two overlapping saves on different rows. When both succeeded, the second row went 8, back to 9, then 8. When the first save failed, its rollback briefly wiped out the second change.

For rapid edits, roll back only the changed row, and invalidate only when the last save settles. That version ran with no wrong value in between. The TanStack docs link a guide on concurrent optimistic updates.

The current TanStack docs write these callbacks with an extra context argument and call context.client instead of useQueryClient. Both forms type-check and run on 5.104.5.

Use optimistic updates only when saves almost always succeed and a rollback is easy to understand. Changing a stock count fits. A payout request does not, because showing money as sent before the server agrees would scare a seller.

When do you need a global store like Redux or Zustand?

You need a global store when many distant components share client-only state that changes often and has rules about how it changes. Once server data moves to a server-state cache, most dashboards need only a very small store.

The Redux FAQ says plainly that using local component state is fine. It lists questions to ask before putting data in the store. Do other parts of the app care about it? Is the same data driving several components? Do you want to cache it?

The same FAQ says most form state does not need to go in Redux, because it is probably not shared between components. Its classic example is an isDropdownOpen flag, which in most cases should stay in component state.

After the move, the team's store shrinks from 38 slices to 3 (made up). One holds the logged-in seller. One holds the theme and sidebar. One holds a bulk-edit tray, where sellers stage changes to many listings before saving them together.

That bulk-edit tray is the kind of state a store is good at. It is client-only, several panels read it, and it has rules, like no negative stock. TanStack's docs give similar cases: a visual designer or a music production app.

Look at the diagram for the decision the team now uses in code review. Start at the top with any new piece of state.

Where does this new piece of state go?

The team's code review checklist as a decision path. Notice that the first question is about ownership, not about which library to use.

Diagram connections:

  • Does the server own it? to Server-state cache (Yes)
  • Does the server own it? to Keep it after a refresh? (No)
  • Keep it after a refresh? to URL query string (Yes)
  • Keep it after a refresh? to Read by distant parts? (No)
  • Read by distant parts? to Small global store (Yes)
  • Read by distant parts? to Component or parent (No)
  • Does the server own it?: Stock, orders, payouts, reviews
  • Server-state cache: Query key plus a stale time
  • Keep it after a refresh?: Or share it as a link
  • URL query string: Checked when read, like readFilters
  • Read by distant parts?: Many pages or deep trees
  • Small global store: Seller, theme, bulk-edit tray
  • Component or parent: Open menus, drafts, hover

Is React Context enough for shared state?

React Context passes a value deep into the component tree without threading it through every level. It does not cache, refetch or track loading. It suits values that change rarely, like the theme or the logged-in seller.

The catch is how readers update. The React docs say every component that reads a context re-renders when its provider's value changes. The provider is the component that supplies that value. Memo, React's way to skip redrawing a component whose inputs did not change, does not stop it.

That was the slow search box, because the old store reached its readers through one Context. We rebuilt it both ways with React 19.3.1. With the search text in one Context, a single key press redrew all 60 memo-wrapped readers.

With Zustand 5.0.15, where each component reads only the field it shows through a selector function, the same key press redrew 1 component. The team went further. The search box now keeps the typed text in its own state, and passes it to the list only when the seller pauses typing.

What are the most common state management mistakes at scale?

The most common mistakes are copying server data into several places, storing values you could calculate, and putting every flag in one global store. Each looks harmless on day one and becomes a bug factory as the team grows.

  • Copying server data into a store. Each copy needs its own refresh logic, and one will be forgotten. Fix: one server-state cache, read everywhere.
  • Storing derived values. A saved out-of-stock count goes wrong when a listing changes. Fix: calculate it during render from the listings.
  • Hand-written loading flags. Thirty-eight isLoading fields drift apart. Fix: let the cache track loading, error and freshness for each query.
  • Filters in component state. A refresh or shared link loses them. Fix: keep them in the URL and check them when read.
  • Forgetting to invalidate after a save. The page that saved looks right, and other pages stay stale. Fix: invalidate by key prefix after every save.
  • Contradictory flags. isSaving and isSaved both true at once. Fix: one status field with clear values, like idle, saving, saved or failed.

The last fix follows the Redux style guide's rule to treat reducers as state machines. A reducer is the function that works out the next state from the current one and an action. A state machine has a fixed set of states and clear rules for moving between them. The guide's example keeps one status field that holds idle, loading, success or failure.

In TypeScript, a union type (a type that allows only a listed set of values) does the same job. A status that is both saving and saved cannot even be written.

The team also changed how it works, not just the code. New state needs a one-line comment saying which kind it is. Reviewers reject any slice that holds data from the API.

How do you explain frontend state management in a system design interview?

Explain it as sorting data by owner and lifetime, then giving each kind one home with a clear freshness rule. Use one real screen to show it, and call out where stale data could hurt users.

  1. List the data on one screen

    For a seller dashboard, write down listings, stock, orders, filters, the open modal, the theme and the logged-in seller.

  2. Label each item

    Mark it as server state, URL state or UI state. Say who owns it and who else can change it.

  3. Give each label a home

    Server state goes to a server-state cache, URL state to the query string, and UI state to the component or a small store.

  4. Set freshness rules

    Give each query a stale time based on how wrong it may be. Say when it refetches: when the tab is shown again, on reconnect, and after saves.

  5. Handle writes

    Describe one save, with an optimistic update, a snapshot, a rollback and an invalidation at the end.

  6. Name the limits

    Say that each tab has its own cache, and that the server rechecks stock on every order.

If the interviewer pushes on scale, talk about many teams too. Clear homes for each kind of state let teams build pages without touching each other's slices. Shared query keys keep their screens consistent.

Questions people ask

What is the difference between server state and client state?
Server state is data the server owns, which the browser only holds a copy of, like orders or stock counts. Client state, also called UI state, exists only in the browser, like an open menu. Server state can go stale and needs loading, errors and refetching. Client state has no server copy to drift from, so it rarely goes stale.
Do I still need Redux if I use a server-state library?
Often not, but sometimes yes. Once server data moves to a server-state cache, the client-only state left over is usually small. Component state, the URL and a small context often cover it. Keep a store like Redux or Zustand when many distant components share complex client-only state with rules, like a bulk-edit tray or a drawing tool.
Should form state go in a global store?
Usually no. Most forms are read and changed by one part of the screen, so their values belong in that component or in a form library. Put the starting values in from the server-state cache once, when the form opens. Then send the final values to the server on submit, and invalidate the cached data.
What is a stale time and how do I choose one?
Stale time is how long cached data counts as fresh before the cache will fetch a new copy. Choose it by asking how wrong the data may be, and for how long. A workshop profile can be minutes old. A stock count that drives decisions is safer at 0. Truly live numbers need polling, which means asking the server again every few seconds, or pushed updates.
Why does my other browser tab not update after a save?
Because each tab has its own cache in memory. Invalidation after a save refetches only in the tab that saved. The other tab refetches when it is shown again, and only if its copy is stale. Keep stale time at 0 for critical numbers, or sync tabs with a BroadcastChannel-based plugin.
What is URL state in frontend apps?
URL state is UI state stored in the address bar, usually in the query string, like ?status=unpaid&page=2. It survives a page refresh and can be shared as a link. If each change adds a history entry, the back button steps through the views too. Filters, sort order, tabs and page numbers fit well. Always check URL values when you read them, because anyone can type anything there.
When should I use optimistic updates?
Use them when a save almost always succeeds, the user makes many quick changes, and a rollback is easy to understand. Updating a stock count or ticking a checkbox fits well. Avoid them for money, bookings or anything where briefly showing success that did not happen would mislead or worry the user.
Is React Context a state management solution?
Not on its own for values that change often. Context passes a value deep into the component tree, but it does not cache, refetch or track loading. Every component that reads it re-renders when the value changes, even inside memo. Use it for rarely changing values like the theme. For busy shared state, a store with selectors re-renders only the readers whose slice changed.

Sources and further reading

Practise this with Lala

Practise