State management in 2026, and the split that actually matters

A PR that copied server data into a Redux slice, the stale bug it caused, and how I split server from client state, plus when RTK still earns its place.

A month ago a PR stopped me in review. A junior I mentor had added the list of shared links to a Redux slice, the same list we already fetch through RTK Query. The reasoning was reasonable enough: keep it in the store so any component can read it. He subscribed to the query, pushed the response into the slice from a useEffect, and components read from the slice.

// the antipattern from that PR: server data copied into a slice
const { data } = useGetSharedLinksQuery();
useEffect(() => {
  if (data) dispatch(setLinks(data)); // a second copy of the same data
}, [data]);
// components then read from the slice, which goes stale while RTK Query's cache is fresh

It worked. It kept working right up until someone revoked a link in another tab. The next time the tab regained focus RTK Query refetched and updated its cache, while the slice held the old list. Two sources of truth, and the UI was rendering the stale one.

I didn’t write a paragraph about why the slice was redundant. I left one question on the PR: whose data is this, ours or the backend’s? The bug wasn’t in the useEffect or the subscription. It was in putting server data into a store built for client state. That confusion is the single thing I flag most in review, and most of state management in 2026 is arranged around not making it.

Two things we kept calling state

Five years ago anything that wasn’t a prop got called state. Redux was a general-purpose bucket: the backend response went in it, the open-modal flag went in it, the form draft went in it. That’s fine while the app is small. At the scale of a consumer file manager that approach falls apart, because those things have completely different lifecycles.

Server state isn’t mine to control. It lives on the backend, arrives asynchronously, and can go stale at any moment because another client, another tab, or a background job changed it. It needs request deduplication, a cache with a TTL, invalidation, refetch on focus, loading and error status, retries. It’s a snapshot of remote data that has to be kept in sync.

Client state is entirely mine. Whether a drawer is open, which rows are selected, what the user typed into a filter but hasn’t applied yet. It’s synchronous, it lives exactly as long as the UI needs it, and it never needs invalidating. You can run it through the same machinery as server state, but then you pay for invalidation where there’s nothing to invalidate, and hand-write a cache where a library would have done it for you.

The map right now

Lay out what teams actually reach for in 2026 and the picture is simple.

Server state goes to a dedicated library. TanStack Query if your store isn’t tied to Redux. RTK Query if you’re already in the Redux Toolkit ecosystem and don’t want a second tool. Apollo if you’re on GraphQL and want a normalized cache keyed by schema. All three do the same job: a cache keyed by request, dedup, invalidation, background refetch, status. The difference is the integration and whether you’re on GraphQL or REST.

Client state goes to something lighter. useState and useReducer for local. Context when a value is needed by a subtree and changes rarely. Zustand or Jotai when you genuinely have global client state that many components read and that changes often. Zustand gives you one store with selectors; Jotai gives you atoms composed from the bottom up. Picking between those two is usually about team taste for me, less about capability.

Effector sits a bit apart: state modeled as a graph of events, stores, and effects, with the logic living outside components. It has a real following, particularly in the Russian-speaking community I came up in, and it holds up well on complex client logic. Valtio, from the same group as Zustand, offers a proxy-based take if that’s more your style.

A word on Context, because it gets overreached. I keep it for things that change rarely: theme, locale, current user. It’s a poor fit for anything hot, because any change to the value re-renders every consumer at once, with no selectors. That gap is what Zustand and Jotai close: you subscribe to a slice instead of the whole value.

Redux Toolkit sits in both columns at once, which is the source of the endless confusion. RTK Query is server state. Slices and the store are client state. When someone says “we use Redux” they usually mean both, and arguing about whether you “need Redux” without separating the two goes nowhere.

Where Redux Toolkit still belongs

Burying Redux became fashionable, and it’s wrong. Our file store at Cloud is still on RTK, and I see no reason to rewrite it.

File workflows are a normalized entity model. Files, folders, shares, permissions, the current selection, operations in flight. The same entities show up in a dozen places: the tree, the list, the preview, the toolbar, the trash. They need shared selectors, optimistic updates with rollback when an operation fails, careful invalidation after batch operations. When we reworked the trash around batch restore and permanent delete, support contacts about lost data dropped by roughly 24%, and that held because of a predictable store where the operation state and the list state never drifted apart. You can write all of this on Zustand too, but the coupling here is dense enough that RTK’s machinery — normalized slices, time-travel devtools, middleware, typed thunks — pays for itself.

The rule I settled on: RTK earns its place when there’s a lot of client state, it’s normalized, and it’s tightly coupled across features. If your state is five flags and the current tab, Redux there is a holdover, kept out of habit.

Maybe you don’t need a state library

The most underrated answer to “which store should we use” is none.

Move server state into a query library and a surprising amount of “global state” just disappears. Then you find that half of what’s left is most naturally kept in the URL.

At Avito Travel the hotel filters lived in query params. Not a copy in a store synced to the URL, but the URL as the only source: the component reads from the string and writes back on change.

// the URL is the only source; nothing duplicated into a store (Next App Router)
const params = useSearchParams();
const router = useRouter();
const sort = params.get('sort') ?? 'date';

const setSort = (next: string) => {
  const q = new URLSearchParams(params);
  q.set('sort', next);
  router.replace(`?${q}`);
};
// nuqs makes these search params type-safe

That fixed deep links, restoring context after a back navigation, and sharing a link to a result set. Repeat searches were a symptom of lost context, and they dropped 14%. There was no store for filters at all: useState for the input draft and the URL for the applied value.

On the same product we filtered room offers on the client, with no backend round-trip. It looks like a candidate for a store, but it’s really a derived value: the displayed list is computed from the loaded list and the selected filters.

// a derived value, not state: computed from two things already on hand
const visible = useMemo(
  () => offers.filter((o) => matches(o, filters)),
  [offers, filters],
);

Keeping it in a store means storing something recomputed at any time from two things already on hand. Time to pick a room dropped 17%, and no separate state for the result was ever introduced.

After those deductions, what’s left for genuinely global client state is a thin layer: theme, current user, a bit of onboarding. Context or a small Zustand store covers it, and you don’t need a full-blown state manager for it.

Where signals and server components pull

Two forces are pulling on this picture, each its own way.

Server components remove a class of state from the browser entirely. If data is read and rendered on the server in an RSC, the client doesn’t need a cache for it: there’s no client-side load, so there’s nothing to cache. Mutations move to Server Actions, invalidation to the framework’s revalidate. This doesn’t retire TanStack Query where there’s live client interactivity, but the chunk of “global state” that used to end up in a store can now stop short of the client altogether. In Next with the App Router, the server/client boundary went from a metaphor to a literal line in the component tree.

Signals pull from the other direction. The idea is fine-grained reactivity: a value knows its subscribers, and when it changes only the things that depend on it recompute, without running a render from the top. I’ve seen this before. MobX, which I shipped on an earlier project, is essentially this: observables plus automatic dependency tracking. SolidJS has been built on signals from the start, Angular moved to them recently, Preact has them, and there’s a TC39 proposal to make signals a language primitive. React hasn’t gone to signals. Its answer to the same performance problem is the React Compiler, which memoizes for you and leaves the “the whole component re-renders” model in place.

Where this converges, I honestly don’t know. My bet is that server components keep eating server state out of client store libraries, and signals stay under the hood of frameworks and store engines rather than surfacing in application code for most teams.

How I choose, per feature

When a new feature lands I walk the state top down, and the store library shows up near the end.

First: is this server data? Then a query library, and no copy in a global store. Nine state-management arguments out of ten close on this step.

Next: can it go in the URL? If the state is worth sharing by link or restoring after a reload (a filter, a tab, an opened item, a pagination page), it belongs in the URL.

Next: is it local to one subtree? Then useState or useReducer next to where it’s used, threaded through Context if needed. I’ve learned not to lift state higher than the components that use it.

Only then: is it really global client state that many components read and that changes often? Then Zustand or Jotai, or RTK if the feature already has a normalized, coupled store and needs its machinery.

That order also answers the PR from the start. The list of links is server data, step one, query library, no slice.

Where I don’t have a clean rule

The boundary between server and client state is crisp in a sentence and leaky in practice, and it leaks exactly where things get interesting.

Take editing. We built a PDF editor on top of the viewer, and by our measurement it added about 10% to retention in file workflows. The document comes from the backend, so it’s server state. But while the user edits it there’s a local editable buffer that has nothing to do with the server until save. That’s the legitimate copy of server data into client state, the very thing I rejected the PR for. The rule “never copy server data into a store” breaks here: you can’t build a form, a draft, or an optimistic update without a copy. The difference between a good copy and the bug from the start is thin. In a form the copy is explicit and deliberate, with a clear lifecycle; in that PR it appeared by accident, as a side effect of “let it sit in the store.”

Optimistic updates are the same story, sharper. When I apply a rename locally before the server answers, whose state is that? It’s the query cache I’m hand-tweaking, and it’s my guess about the future response, at the same time. The boundary runs straight through a single operation, and both libraries have a claim on that state. I don’t have a clean answer for where it should live. In practice I keep the optimistic part inside the query layer’s cache, because invalidation is its job anyway, but the seam there is always noticeable, and in code review it’s where a desync turns up most often.

So there’s no single store in my head anymore, and that’s fine. For a new feature I almost never start by choosing a library. I sort the state by type first, and by the time the question of what to put global state in comes up, there’s usually almost none left. The one thing I still argue about is where to draw the seam between the query cache and the client store when optimistic updates and drafts sit on both. I haven’t found a good general answer, and I suspect there isn’t one. It’s per-feature every time.