---
title: "AI Mutations Aren't Optimistic-UI Candidates: Make the 'Analyzing' State Honest"
canonical: https://dxdev.com/blog/analyzing-state-async-ai-not-optimistic-ui/
datePublished: 2026-02-24
---
I posted an update to a record, the form cleared, and the update was gone. It was still in the database, but it had vanished from the screen. The list above the form still showed yesterday's entries, and the thing I'd just typed was nowhere. I reloaded the page and there it was, sitting at the top, exactly where it should have been. The database had it the whole time. The screen never got the memo.

Two bugs were hiding in that one moment, and both came from the same wrong assumption: that I could treat an AI write like any other write. I couldn't. The save didn't just persist a row, it kicked off a background GPT call that re-decided the record's state. The model owned the outcome, and I was building the UI as if I did.

## The setup

This was a side project where each "node" tracks a task or idea, and posting an update to a node runs an inference pass. The handler does three things in order: infer, persist, then patch the parent node with whatever the model decided. An `inactive` item might become active again because the update mentions a deadline. A node's state can flip based on text I haven't read yet. The LLM call sits in the critical path between "user hit save" and "we know what this record now looks like."

The naive React Query mutation I'd written did what those mutations usually do. It fired the request, cleared the form, and trusted the cache. For a normal CRUD write you'd reach for optimistic UI here: render the new row immediately from the local payload, then quietly reconcile with the server response. Snappy, standard, correct most of the time.

It is wrong here.

## Why optimistic UI breaks on AI writes

Optimistic UI works when the client already knows the result. You set `done: true`, you render the checkbox checked, the server agrees, nobody notices the round trip. The optimism is safe because the client is the source of truth for the change.

An AI mutation inverts that. The whole point of the call is that the server (really the model behind it) computes something the client cannot predict. The suggested title, the inferred type, the new lifecycle state, the importance and priority scores. If I optimistically render the update, I have to render *something*, and the only something I have is a guess about what the model will say. When the model disagrees, the UI flickers from my fake to the real answer, and the user watches the record change shape for no reason they can see. That flicker is worse than waiting. It reads as a bug even when the data is correct, because the interface promised a result it didn't have.

So the rule I landed on: AI mutations are not optimistic-UI candidates. The model owns the outcome. You don't fake an outcome you don't own.

## Fix one: invalidate the list, stop trusting the cache

The invisible-entry half of the bug was the simpler half, and the more common one. After the mutation resolved, I never invalidated the list query. The new update was persisted server-side, the parent node was patched, and the client cache for "updates on this node" still held the pre-save snapshot. React Query had no reason to refetch, so it didn't.

The fix is one line in the mutation's success handler: invalidate the updates list query so it refetches. Once the inference finishes and the row is real, you throw away the stale cache and pull the truth. No optimistic insert, no client-side splice into the array, no guessing where the new row sorts. Just invalidate and let the server tell you the current state, including whatever the model changed on the parent node.

This is the part people skip because optimistic UI usually papers over it. If you optimistically insert the row, you never notice you forgot to invalidate, because your fake row is already sitting there. Strip out the optimism and the missing invalidation becomes a visible bug immediately, which is a point in favor of not faking it. The fake hides the real gap.

## Fix two: an honest "Analyzing" state

The second fix was the one that actually made the form feel right. While the inference runs, the UI shows an explicit "Analyzing" state. Not a generic spinner that could mean anything from "saving" to "the network died," and not a fake-instant render that might be wrong. A specific label that tells the user the truth: the model is thinking about what you just wrote.

This matters more than it looks. A spinner says "wait." "Analyzing" says "wait, and here is why, and the thing you're waiting for might change what you see." It sets the expectation that the result is being computed rather than merely fetched. When the record then comes back with a different state than the user might have assumed, that isn't a glitch, it's the analysis they watched happen. The honesty of the label buys you the right to reconcile afterward without it feeling broken.

It also reframes latency. AI calls are slow in a way users have started to accept, but only when you name the slowness. An unlabeled two-second pause reads as a stall. A two-second "Analyzing" reads as the product working.

## The shape of an AI mutation

Put together, the pattern for any mutation that hands the outcome to a model looks like this:

1. User submits. Show an explicit "Analyzing" (or "Thinking", or whatever names the actual work) state. Do not optimistically render a result you don't have.
2. The server runs inference, persists, and applies whatever the model decided to the affected records.
3. On success, invalidate the relevant queries and refetch. The refetch, not your client-side guess, is what renders the new state.
4. Reconcile. Whatever the model changed (state, type, ordering) arrives in the refetch and the UI reflects reality.

The mental model is reconcile-after, not predict-ahead. You're not racing the server to draw the answer first. You're telling the user the answer is being decided, then showing it the moment it exists.

## Where this generalizes

This isn't specific to one stack or one app. Any time a write triggers a model call that changes the record, the same two failures are waiting: the entry that goes invisible because you didn't invalidate, and the interface that lies about being instant because you reached for the optimistic pattern out of habit.

A few places I'd watch for it: a support ticket that gets auto-categorized on submit, where the category column changes after the model runs. A document upload that gets summarized and tagged, where the tags don't exist at insert time. A form that runs moderation or classification before deciding whether the record is even visible. In every one of those, the client genuinely does not know the post-write state, so faking it is faking data.

The tell is simple. Ask whether the client can compute the result of the mutation by itself. If yes, optimistic UI is fine, render away. If the answer lives inside a model call, it can't, and you should show a thinking state and reconcile after. The cost of getting this wrong isn't a crash. It's quieter and worse: users stop trusting that what they saved is what got saved, and a feature that works perfectly starts to feel unreliable. For an AI feature, where trust is already the thing you're fighting for, that's the expensive failure.

## Related

- [The dry-run LLM endpoint: infer before you persist](dry-run-llm-inference-before-you-persist): separating inference from the write so the UI reflects a real committed state
- [MOCK_INFERENCE=1: ship every LLM feature with an env-var kill switch on day one](mock-inference-llm-kill-switch-day-one): controlling LLM side-effects during development and testing
- [Postgres as Source of Truth for an AI Content Pipeline (Why a Markdown Folder Is a Dead End)](postgres-source-of-truth-for-ai-content-pipeline): why the database, not the client, owns AI-produced state
- [Reworking a vector-retrieval scoring formula: stop letting raw similarity be 100% of the base](vector-retrieval-scoring-weighted-base-not-similarity-plus-bonuses): LLM pipeline output that must be reconciled from a server result
- [Testing an LLM Feature Without a DB or an API Key](test-llm-feature-extract-predicates-mock-boundary): isolating the inference boundary so UI and server state can be tested independently
