Skip to content

Optimistic writes without the choreography

Click “like” and the count goes up instantly. The request runs in the background. If it fails, the count snaps back and you get a toast. On success, the real count refetches. The button shows a pending state throughout.

This is the modern write: optimistic, reversible, with a pending flag. React 19 gives you the primitives — useActionState, useOptimistic, useTransition — but wiring them together correctly is its own small project.


import { useState, useOptimistic, useTransition } from "react"
export function LikeButton({ id, initial }: { id: string; initial: number }) {
const [likes, setLikes] = useState(initial)
const [optimisticLikes, applyOptimistic] = useOptimistic(likes)
const [isPending, startTransition] = useTransition()
const like = () => {
startTransition(async () => {
applyOptimistic(likes + 1) // paint instantly
try {
const next = await api.like(id)
setLikes(next) // real commit
refetchLikeCount() // invalidate the source of truth
} catch (e) {
toast(String(e)) // useOptimistic auto-reverts; handle the rest
}
})
}
return <button onClick={like} disabled={isPending}>{optimisticLikes}</button>
}

Three hooks, a shadow pair (likes + optimisticLikes), and a precise ordering — optimistic-inside-the-transition, commit-after-await, revert-in-catch. Get the order wrong and the count flickers, double-counts, or never reverts.

export component LikeButton {
param id: string
state likes: number = 0
resource likeCount(id) => api.likeCount(id)
command like() => api.like(id)
optimistic { likes = likes + 1 } // paint instantly, auto-revert on throw
invalidate [likeCount] // refetch the truth on success
rollback(e) { toast(e) } // failure side effects (no re-throw)
view {
<button onClick={like} disabled={like.pending}>{likes}</button>
}
}
Compiled React 19 — this is the file in your repo
import { useEffect, useOptimistic, useState, useTransition } from "react"
import { __getResourceCache, useResource } from "@reactra/resource"
import { useRoute } from "@reactra/router"
export const LikeButton = () => {
const __route = useRoute()
const id = __route.params.id
const [__base_likes, setLikes] = useState((0) as number)
const [likes, __setOpt_likes] = useOptimistic(__base_likes)
const likeCount = useResource(id, (id) => (api.likeCount(id)), { resourceName: "likeCount" })
const [__pending_like, __start_like] = useTransition();
const like = (__payload?: unknown) => __start_like(async () => { __setOpt_likes(likes + 1); try { await (async () => (api.like(id)))(__payload); setLikes(likes + 1); __getResourceCache().invalidate(["likeCount"]); } catch (__e) { ((e) => { toast(e) })(__e); } });
like.pending = __pending_like
useEffect(() => { const h = globalThis.__REACTRA_TEST__; if (h) h.update("LikeButton", { likes, likeCount }) })
return (<>
<button onClick={like} disabled={like.pending}>{likes}</button>
</>)
}
export default LikeButton

The optimistic update is one plain assignment. The shadow pair, the transition, the commit ordering, the revert, the refetch — generated.


  • The shadow pair (likes + optimisticLikes) → you write likes; the compiler keeps the mirror
  • useTransition + the “optimistic must be inside the transition” rule → the command form
  • The ordering (apply → await → commit → revert) you hand-sequence → generated, the same way every time
  • Manual refetch/invalidate plumbinginvalidate [likeCount]
  • The pending flag wiringlike.pending

Same interaction. None of the choreography.


command is the write-side counterpart to resource, and like resource it compiles to React’s own primitives: the block form (command f() { … }) to useActionState; the arrow form to useTransition, with optimisticuseOptimistic, invalidate → resource cache eviction, rollback → the failure handler. A plain command with no observability adds zero runtime — it’s the hooks you’d have written.

You write the write. The compiler writes the part where the order matters.