TLDR: If your server action succeeds but the UI reverts anyway — it's not a broken save. React 19 is resetting your uncontrolled input after revalidation. Controlled local state is the fix.
The Setup
I was building a KOL (Key Opinion Leader, think: medical speakers and partners) CRM for an ecommerce business — a tool their team uses to track relationships with influential clinicians.
One of the features: a relationship warmth slider. Drag it from 0 to 100. UI updates instantly. Value persists. Done.
Simple enough, right?
The Wall
Here's what actually happened.
You'd drag the slider to 80. It would appear to stick. Then — just a beat later — it snapped back to wherever it started.
But here's what made it maddening: the save was working. I'd check Supabase directly and the value was there, written correctly. Refresh the page? Shows 80. Everything fine… except the screen in front of you was lying through its teeth.
What I Tried First
My first instinct: force-remount the component with key={Math.random()}.
It technically stopped the snap. But it nuked focus and scroll position on every save — the slider JUMPED. Worse UX than the original bug. Classic "fix the symptom, break everything else" move. (I've done this approximately one million times.)
What Was Actually Happening
React 19's server actions — how Next.js handles form mutations — reset uncontrolled inputs after revalidation.
The save would fire, Supabase would write the value, Next.js would revalidate the query, React would re-render… and just overwrite whatever the user dragged to, because the slider had no local controlled state of its own.
There's a trifecta that gives it away every time:
- ✅ Server action runs, DB is updated
- 🔁 UI "reverts" / snaps back / goes blank
- ✅ Page refresh shows the correct value
That's not a broken save. That's optimistic UI with no armor on it.
The Fix That Actually Worked
Two things, together:
- Hold the value in controlled local state — so the slider position never depends on the server's rerender cycle
- Remove the
awaiton the save call — fire-and-forget, so the UI never waits on the round trip
const [warmth, setWarmth] = useState(kol.warmth ?? 0)
// on slider change:
setWarmth(value) // instant, local
saveWarmth(kolId, value) // fire-and-forget — no await
The visible state now lives in the component. The server revalidation can do whatever it wants — the UI doesn't even blink.
It Wasn't a One-Off
A few days later, the same bug surfaced on the iOS app: assigned topics would visually vanish right after save. Same root cause — optimistic add, no local state to survive the server response.
So yeah. It wasn't a warmth-slider problem. It was a pattern problem.
Why This Matters
Optimistic UI is supposed to feel like magic — you act, the screen responds, the server catches up quietly behind the scenes.
But if your committed value isn't anchored in controlled local state, you don't actually have optimistic UI.
You have a ticking desync — and to anyone using your app, it looks exactly like a broken save.
The fix is four lines of code. The debugging spiral before you understand WHY? Considerably more.