Daniel sent us a long one this time, and it's worth reading in full because it's exactly the kind of bug that makes you stare at a screen at two in the morning questioning your career choices. Here's what he wrote.
I run a home inventory app. You open an item, upload a photograph, and there's a tab with a little badge showing how many media files the item has. The badge doesn't move. Refresh the page and it's correct. My first instinct was that this is what WebSockets are for — that live-updating UI requires some kind of persistent connection to the server, and my app is missing that layer. I've since been told that's the wrong layer entirely, and I'd like the episode to start from the corrected picture rather than relitigate it. WebSockets answer a different question: how does my browser find out about a change it didn't make — someone else edits the record and my screen updates. That's push, and it genuinely needs a server-initiated channel. But my bug isn't that. I uploaded the photo. My browser initiated the request and got a response back. It already knows. Nothing needs pushing to me. The number is stale because what's on screen was derived from a snapshot of data fetched when the page loaded, and after the upload nothing told that snapshot it was out of date. Bolting on a WebSocket would paper over it by having the server announce a change I was already aware of.
So the transport isn't the interesting part, and I don't want the episode to become WebSockets versus polling versus server-sent events. Here's my question — what is the actual chain of machinery that lets a number on a screen change by itself, from the moment data arrives in the browser, through however the framework knows that a particular piece of text on the page depends on that data, to the redraw, and at which links in that chain does it typically break?
That's a good prompt. He's already done the hard diagnostic work — he knows what the bug isn't.
He really has. And the fact that he caught himself before reaching for WebSockets puts him ahead of... honestly, most production codebases I've seen. The instinct to add a real-time layer when the UI goes stale is incredibly common, and it's almost always wrong.
So the prompt already tells us what this isn't. Let's pin down what it actually is.
The core distinction is server state versus derived state. Server state is the source of truth on the backend — the actual list of media files attached to that inventory item. Derived state is what gets computed on screen from a snapshot of that truth. The badge showing the media count — that's derived state. It's a projection of data that was fetched at page load.
And the snapshot is the key word there. The page loaded, it asked the server for the item's data, the server handed back a JSON blob with the media array and the count, and the app rendered that into a badge. That JSON blob is now frozen in time. It's a photograph of what the server knew at the moment of the request.
Right. The upload then happens. The browser sends the photo, the server processes it, the server sends back a response — probably a two hundred with the updated item data, or at minimum a success with the new count. That response lands in the browser. The browser knows. But knowing is not the same as applying.
And this is where the chain starts. Let's walk it link by link, starting the moment that upload response lands in the browser.
Link one is the network response itself. The upload finishes, the promise resolves, the callback fires. You've got the new data in your hands — a JSON object with the updated media count, maybe the full media array. You're in a handler function. What you do next is entirely up to your code. The framework doesn't step in and say oh, you got new data, let me propagate that everywhere. The response lands in a single callback scope and goes nowhere unless you explicitly route it.
And in a plain fetch-based app — which is what most people build before they reach for a state management library — the handler often just does something local with the response. It updates a form field, it shows a success toast, it clears the file input. And then the response object gets garbage collected. The page's original data snapshot — the one the badge reads from — was never touched.
That's link two, and it's the most common break point. The cache or store. The app fetched the item data when the page loaded. That data lives somewhere — in a useState hook, in a Redux slice, in a component's local state. The badge reads the media count from that stored snapshot. The upload handler receives new data, but it never writes that new data back to the store the badge reads from. The new count exists in memory for about three milliseconds inside the handler, and then it's gone.
The data arrived. It was never applied. And the snapshot sits there, perfectly preserved, like a museum exhibit of what the item looked like ten minutes ago.
Now link three is where the framework's machinery comes in — the dependency graph. React, Vue, Svelte — they all track which components read which pieces of data. The badge component reads the media count. The framework knows this. It has a subscription, essentially, that says badge depends on item dot media dot length or whatever the access path is.
But here's the thing — the framework only re-renders the badge if the specific data it reads changes. Not if some adjacent data changes. Not if the store object gets a new property. Only if the exact value the badge accesses is different by reference.
And that's where things get subtle. Let's say your upload handler does try to update the store. It takes the response data and does something like setItem with the new item object. The framework sees that item changed. But does the badge re-render? It depends. If the badge reads item dot media dot length, and item dot media is a new array with a new length, then yes — the dependency graph detects the change, the badge gets scheduled for re-render, and we move to link four.
But if the handler updates the wrong thing — say it sets a separate uploadCount state that the badge doesn't read — the dependency graph sees no change to the data the badge actually depends on. The badge sits there. The framework did its job correctly. It just wasn't told about the right data.
Link four is the render and commit phase. The framework has scheduled a re-render for the badge. It runs the component function, gets the new virtual DOM, diffs it against the previous one, and commits the changed text node to the actual DOM. The number on screen updates. This is the part that usually works fine — assuming we got through links two and three.
But there's a trap at link four too, and it's memoization. React dot memo, useMemo, useCallback — these are performance optimizations that skip re-renders when the props or dependencies haven't changed by reference. And the classic footgun is updating the value inside an object but keeping the same object reference.
Oh, this one is brutal. You mutate the item object in place — item dot media dot length becomes four — but the object reference is the same. React memo does a shallow comparison, sees the same reference, and skips the re-render entirely. The data changed. The UI didn't. And you're staring at the screen wondering if you're losing your mind.
I've done that. You add a console dot log, you see the new value, the component doesn't re-render, and for about thirty seconds you question whether React itself is broken.
It's not broken. It's doing exactly what you told it to do — you told it to skip re-renders when the reference is unchanged, and then you mutated the object in place instead of creating a new one. The framework can't read your mind. It can only compare references.
So those are the four links. Network response lands, the handler writes to the store, the dependency graph detects the change, the render commits to the DOM. And in Daniel's app, the break is almost certainly at link two. The upload handler gets the response, does something local with it, and never writes it back to the snapshot the badge reads from.
Let's walk his exact bug. The page loads. A useEffect or a loader fetches the item data. The response — an item object with a media array — gets stored in state. The badge component reads media dot length from that stored item. It renders, say, the number three. The user uploads a photo. The upload handler fires, the server responds with a two hundred and maybe the updated item. The handler updates some local upload state — sets uploading to false, clears the file input, shows a success message. But it never calls setItem with the new item data. The original item snapshot is untouched. The badge still reads media dot length from that snapshot. It still says three.
Refresh the page, the initial fetch runs again, the server now returns an item with four media files, the badge renders four. The bug vanishes. Until the next upload.
And this is where the React Query model — TanStack Query now — completely reframes the problem. The core insight from TkDodo's blog, and specifically his post on automatic query invalidation after mutations, is that server state is a cache. It's not application state. It's a local copy of something whose source of truth lives on a server. And like any cache, it has a staleness problem. The question isn't how do I update the badge. The question is how do I tell the cache that its copy of the item data is now stale.
That's the shift. Instead of imperatively pushing the new count into the UI, you declaratively invalidate the query. You say this data is no longer fresh, go get it again. And the library handles the refetch, the cache update, and the re-render.
In React Query, the pattern is a useMutation hook paired with a useQuery hook. The query fetches the item data and caches it under a query key — something like item and the item ID. The badge reads from that cached query result. The mutation handles the upload. And in the mutation's onSuccess callback, you call queryClient dot invalidateQueries with that same query key. The mutation returns, the cache is marked stale, React Query refetches the item data in the background, the cache updates, and the badge re-renders with the new count.
And the beautiful thing is, you didn't write any logic to update the badge. You didn't compute the new count. You didn't manually set anything. You just told the system the cache is dirty, and the system healed itself.
That's the self-healing UI. The badge is derived from the query result. The query result is a cache of the server state. When the cache is invalidated, the query refetches, the cache updates, and everything derived from it updates automatically. The badge doesn't need to know about the upload. It just needs to read from a cache that knows when it's stale.
So the chain has four links, and the break is almost always in the middle two. But here's where it gets interesting — the naive fix for that break creates a whole new class of bugs.
The optimistic update trap. The developer sees the stale badge, realizes the upload handler isn't updating the count, and adds a line — setCount of newCount right there in the handler. The badge updates instantly. It works. Until it doesn't.
What breaks?
The server rejects the upload. Or the server deduplicates the photo and returns a count of three when the UI already shows four. Or the server applies a transform — maybe it compresses the image and generates a thumbnail, and the media count includes the thumbnail, so the server's count is actually higher than what the client computed. Now the UI shows a value the server never accepted. The badge is wrong in the opposite direction.
And now you've got two sources of truth. The server has one count, the client has another, and they've drifted apart. Refresh the page and the number changes — that's the moment the user loses trust in your app.
This is why derive, don't store is the rule that prevents this entire class of bug. The badge count should not be stored anywhere. It should be computed at render time from the media array. If the app stores the media array and derives the count, then updating the array automatically fixes the badge. If the app stores the count as a separate piece of state, every mutation has to remember to update it — and mutations will forget.
The count is a projection. It's not a fact. The facts are the media files themselves. The count is just a convenience derived from those facts.
And this principle scales. In a small app with one badge, manually setting the count in the upload handler feels fine. It's one line. But in a real app, you've got a list view showing the count, a detail view showing the count, a dashboard widget showing the count, maybe a search index that filters by items with more than N media files. Now you've got four components reading the same derived value, and four places that need to remember to update it. That's a distributed consistency problem, and humans are terrible at distributed consistency.
The framework's cache plus invalidation is the single source of truth. You update the cache in one place — by invalidating it and letting it refetch — and every component that reads from it updates automatically. You don't have to remember which components read what. The dependency graph handles that.
There's another pattern worth mentioning here — stale while revalidate. It's the name of the SWR library, and it's built into React Query's default behavior. When you invalidate a query, the cache doesn't immediately clear. It keeps showing the old data while the refetch happens in the background. Once the refetch returns, the cache updates and the UI transitions to the new data.
Which means the badge might show the old count for a beat after the upload completes, and then tick up to the new count. That's not a bug. That's good UX. The alternative — showing a loading spinner on the badge every time you upload a photo — is far more jarring.
The user uploaded a photo. They don't need to see a spinner on the media tab. They need to see the badge eventually reflect the new count, and a half-second delay while the refetch happens is completely acceptable. The stale while revalidate pattern gives you responsiveness and correctness without the jank.
Let's talk about where this chain breaks in production, because the debugging signatures are distinct for each link.
Break one — the handler never triggers invalidation. This is the most common. The mutation succeeds, the response comes back, and nothing tells the cache it's dirty. The signature is that the UI never updates until you refresh or navigate away and back. The fix is adding the invalidateQueries call in the mutation's onSuccess.
Break two — the invalidation targets the wrong query key. You call invalidateQueries with item and the ID, but the query was cached under items plural, or under a different key structure. The invalidation fires, nothing matches, the cache sits there fresh and wrong. The signature looks exactly like break one, which makes it maddening to debug.
React Query DevTools are your friend here. You can see exactly which queries are cached under which keys, and whether an invalidation actually hit anything.
Break three — the component reads from a different store than the one being updated. This happens a lot in apps that mix state management approaches. The upload handler updates a Redux slice, but the badge reads from a React Query cache. Or vice versa. The data updates correctly in one store, and the badge never sees it because it's looking at the other store.
The signature here is that you can see the new data in one part of the app — maybe the upload form shows the new count in its success message — but the badge elsewhere on the page is frozen. Two components, two different data sources, one updated and one didn't.
Break four — memoization blocks the re-render. The data updates, the dependency graph fires, but React dot memo does a shallow comparison and skips it. The signature is that adding a console dot log to the component shows it's not even being called. The component is frozen not because the data is wrong, but because React decided it didn't need to run.
The fix for break four is usually to make sure you're creating new references when you update data — spread operators, map, filter, anything that returns a new object or array rather than mutating the old one. Or, if the memoization is unnecessary, just remove it. Not every component needs React dot memo.
I want to go back to something Daniel said in the prompt — the line about bolting on a WebSocket would paper over the problem by having the server announce a change the browser was already aware of. That's such a clean way to put it. The browser knows. It initiated the request. It got the response. The data is right there. The problem isn't that the browser needs to be told. The problem is that the app's local copy of the truth went stale and nobody told it.
And that reframe — from transport to state — is the whole episode. Most stale UI bugs are not transport problems. They're cache invalidation problems. The browser has the data. The question is whether the data made it into the right slot in the state graph, and whether the framework knows that the component depends on that slot.
Let's talk about the invalidation mindset, because it's a different way to architect data flow. The old way is imperative — when the upload succeeds, set the badge to four, set the media list to include the new file, update the timestamp, update the storage counter. You're manually syncing every piece of derived state. The new way is declarative — when the upload succeeds, invalidate the item query. That's it. The cache refetches, and everything derived from it recomputes.
The mental model shift is from what do I need to update to what is now stale. The former requires you to know every component that reads from the data. The latter requires you to know the data's identity — its query key — and nothing else. The system handles the propagation.
And this is why libraries like React Query and SWR have taken over. They give you invalidation as a first-class concept. You don't have to build a cache invalidation mechanism yourself. You don't have to track which components depend on which server data. The library does it, and it does it correctly, and you get to stop thinking about it.
If you're hand-rolling your data fetching — just fetch in a useEffect and store the result in useState — you can still apply the invalidation mindset. Build a tiny invalidation mechanism. A version counter that increments when a mutation succeeds. A refetch trigger that the mutation handler calls. Something that tells the fetch layer its data is stale, so it knows to re-fetch the next time the component mounts or the next time you check. It doesn't have to be a library. But the library makes it so much easier that it's hard to justify not using one.
The other thing the library gives you is deduplication. If three components all read from the same query key, and you invalidate that key, the library fires one refetch, not three. If you're hand-rolling, you might end up with three separate fetch calls, or worse, three separate copies of the data that can drift independently.
Request deduplication is one of those things that doesn't matter until it suddenly really matters. You add a fourth component that reads the item data, and now you've got four fetches on every page load. Then you add pagination and sorting and filtering, and suddenly your hand-rolled solution is a thousand lines of edge cases that React Query handles in about twelve lines of configuration.
All of this distills down to three rules you can apply to your app tomorrow.
Rule one — never store derived values in state. Counts, totals, filtered lists, sorted arrays — these are projections of source data. Compute them at render time from the source array. If the source updates, the derived values update automatically. If you store them separately, you've created a second source of truth that will eventually drift.
Rule two — treat server data as a cache with a staleness problem. Use a library that gives you invalidation as a first-class concept. React Query, SWR, Apollo Client for GraphQL — they all have this. If you're hand-rolling, build a version counter or a refetch trigger into your fetch layer. The key is that mutations must announce that they've made something stale.
Rule three — when the UI is stale, debug the chain in order. Did the response arrive? Did the handler write it to the store the component reads? Does the component's dependency graph include that data? Is memoization blocking the re-render? The break is almost always at link two or three. It's almost never the transport. Don't add WebSockets to fix a cache invalidation bug.
The mental model underneath all three rules is this — ask what does the UI derive from instead of how do I push the new value in. The former leads to self-healing UIs where one invalidation fixes everything. The latter leads to a patchwork of manual syncs that break in production.
I think there's a clinical analogy here. When you treat a symptom instead of the underlying condition — you give a painkiller for a headache without checking blood pressure — the patient feels better temporarily, but the real problem keeps worsening. Manually setting the badge count is the painkiller. Cache invalidation is treating the hypertension.
That's... actually a useful analogy. I usually roll my eyes at the medical comparisons.
I have a few good ones. Retired pediatrician, remember.
I know. I've heard them all.
Some of them are excellent.
Some of them are about rashes.
Rashes are diagnostically rich.
Moving on. If you take one thing from this episode, it's that the badge didn't move because the snapshot it reads from was frozen in time, and nothing told the snapshot it was out of date. The fix isn't a WebSocket. It's not even a state update. It's teaching your app to know when its own data is stale.
The one thing I'd add — derive, don't store. If the badge count is computed from the media array at render time, and the media array lives in a cache that gets invalidated after uploads, the badge fixes itself. You never write a line of code that updates it.
Here's a question for listeners — have you hit a stale UI bug where the fix felt like it needed a WebSocket but actually needed a cache invalidation? We'd love to hear those stories.
The broader theme here is the line between real-time and correct. Most apps don't need push. They need their local state to stop lying to them. And once you make that shift — from transport to staleness — a whole category of bugs just evaporates.
Thanks to our producer Hilbert Flumingtop for keeping this show running.
This has been My Weird Prompts. Rate and subscribe wherever you get your podcasts, and send your weird prompts to show at my weird prompts dot com.
We'll be back soon.