Server State Is a Cache: Normalization, Duplicates, and Invalidation You Can Trust

The previous article drew a hard line: state the client owns versus data it doesn't. Everything there was about the stuff you own — theme, drafts, preferences — where you are the source of truth and storage just remembers it. This one is about the other side of that line, and it's where a lot of otherwise-clean apps quietly turn to mush.
Here's the situation. You fetch a list of customers for a table. You fetch one customer for a detail page. There's a little avatar-and-name badge in the header showing the currently selected customer. That's three network responses. And customer #42 appears in all three. You now have three copies of one entity in memory, and — this is the part that bites — nothing keeps them in agreement. Edit the customer's name on the detail page, and the table still shows the old name until something refetches it. The badge shows a third version. Your UI is now lying to the user, and the bug report says "the name didn't update," which tells you nothing about why.
This is the server-state problem, and it is fundamentally different from the local-state problem. You don't own this data. It has an owner on the backend. Every copy you hold is a cache of a truth that lives somewhere else and can change without telling you.
The duplication trap
The instinct is to treat each fetch as its own little pile of data. List query returns an array, you render it. Detail query returns an object, you render it. Header does its own thing. Each is correct in isolation. The problem is that the same entity is now stored in multiple unrelated places, and a write to one has no path to the others.
The bug isn't that data goes stale. It's that you have more than one copy of the same truth, and no single place responsible for it.
There are two honest ways out, and picking the wrong one is where teams either over-engineer or under-engineer for years.
Option one: normalize into one source of truth
Normalization is the database idea, moved into the client. Instead of storing whatever shape each response happened to have, you break responses into entities keyed by id, store each entity exactly once, and have every view reference it by id.
// denormalized — the same customer duplicated across responses const listResponse = [{ id: 42, name: 'Acme Corp', tier: 'gold' }, /* ... */]; const detailResponse = { id: 42, name: 'Acme Corp', tier: 'gold', notes: '...' }; // normalized — one customer, referenced by id const cache = { customers: { 42: { id: 42, name: 'Acme Corp', tier: 'gold', notes: '...' }, }, // the list is now just an ordered array of ids into the store above customerList: [42, 17, 8], };
Now there is exactly one customers[42]. The table reads customerList and looks each id up. The detail page reads customers[42]. The header badge reads customers[42]. Update that one record and every view re-reads the same object — they can't disagree, because there's nothing to disagree with. This is what normalized caches like RTK Query, Apollo, and the normalizr-style approach give you: a mini in-memory database where entities live once and views are just queries into it.
The cost is real, though, and it's why I don't reach for it by default. You need an identity for everything (what's the id? what about entities without one?), you need to teach the cache how to normalize each response shape, and nested relationships get fiddly. You've essentially adopted a client-side ORM. For a CRM-scale app with heavy entity reuse and live updates, that's a good trade. For most apps, it's a lot of machinery for a problem you can solve more cheaply.
Option two: let the query cache own it, invalidate precisely
The cheaper answer — and the one this series defaults to, because it uses TanStack Query — is a document cache rather than a normalized one. Data is cached per query key, not per entity. ['customers'] is one cache entry; ['customer', 42] is another. There genuinely are two copies of customer #42. The trick is that you don't try to keep them manually in sync — you make the cache re-fetch the affected keys after a change, and you do it surgically.
The blunt instrument everyone reaches for first:
// after editing a customer — refetch literally everything customer-related await queryClient.invalidateQueries();
This "works" and it's a performance and UX disaster. Every table, every dropdown, every cached query on the screen refetches, spinners bloom everywhere, and you've turned one edit into a network storm. Invalidation is not a hammer. It's a scalpel:
const mutation = useMutation({ mutationFn: updateCustomer, onSuccess: (updated) => { // 1. patch the detail cache directly — no refetch needed, we have the data queryClient.setQueryData(['customer', updated.id], updated); // 2. mark just the list stale, so it refetches when next observed queryClient.invalidateQueries({ queryKey: ['customers'], exact: true }); }, });
Two precise moves. setQueryData writes the fresh entity straight into the detail cache, so the detail page updates instantly with zero network. invalidateQueries with a specific key marks only the list as stale — and critically, TanStack Query won't even refetch it until something is actually looking at it. Nothing else on the page moves. That precision is the whole skill. The library gives you a scalpel; most bugs come from swinging it like a hammer.
The mental shift: query keys are your schema
Here's the reframe that made this click for me. In a normalized cache, your entity ids are the schema — everything hangs off customers[42]. In a document cache, your query keys play that role. The structure and discipline of your query keys is what makes precise invalidation possible or impossible.
If your keys are ad hoc — ['customers'] here, ['customerList', filters] there, ['allCustomers'] in a third place — you can't invalidate precisely because you can't describe the set of things that went stale. But if keys are hierarchical and consistent, invalidation becomes expressive:
// a consistent, hierarchical key factory const customerKeys = { all: ['customers'] as const, lists: () => [...customerKeys.all, 'list'] as const, list: (filters: Filters) => [...customerKeys.lists(), filters] as const, details: () => [...customerKeys.all, 'detail'] as const, detail: (id: number) => [...customerKeys.details(), id] as const, }; // invalidate every customer list, regardless of filters, and nothing else queryClient.invalidateQueries({ queryKey: customerKeys.lists() });
Because TanStack Query matches keys by prefix, customerKeys.lists() invalidates every filtered list variant in one call without touching a single detail cache. The key factory turns "which things are now stale" into a sentence you can write. This is the same instinct as a good public API or module boundary: a little structure up front makes the operations you'll do a thousand times both possible and safe.
How to actually choose
I'll be direct, because this is where people waste the most effort. Reach for a normalized cache when three things are true together: the same entities are reused heavily across many views, they update frequently (real-time, collaborative, or lots of mutations), and staleness between views is genuinely user-visible and unacceptable. Trading dashboards, chat apps, collaborative editors, big CRMs — normalization earns its keep there.
For everything else — which is most apps — a document cache with disciplined query keys and precise invalidation is not a compromise, it's the right call. It has less machinery, less to learn, fewer ways to corrupt the cache, and it scales further than people expect. Honestly, the number of teams who adopted a full normalized cache and then used it like a document cache anyway is not small. Start with the simpler model and let real pain — not anticipated pain — push you to the heavier one.
Either way, the principle underneath both is identical, and it's the thing to actually remember: there is one truth, it lives on the server, and your job is to hold one coherent copy of it — not five copies you hope agree. Normalization enforces that with entity ids. A query cache enforces it with keys and invalidation. Pick your mechanism, but don't skip the principle, because "the name didn't update" is what skipping it looks like in the bug tracker.
Once you've got one coherent copy and precise invalidation, a door opens that used to be terrifying: updating the UI before the server confirms, and rolling back cleanly if it rejects. That's optimistic UI, and it's only sane once your cache has a single source of truth to optimistically edit and a precise way to reconcile when the truth comes back. That's next.
If your app has a "why is this showing the old value" bug right now, I'd bet it's a duplication problem wearing a staleness costume. Tell me where the copies live — list and detail, cache and store, two tabs — and I'll tell you which of these two models you actually needed.
Found this helpful?
Subscribe on Telegram for new posts, or reach out to discuss your project.