Optimistic updates with automatic rollback on error, a layered model for concurrent mutations, and pluggable conflict resolution — framework-agnostic, with thin adapters for React Query and SWR.
Optimistic UI is easy to write and hard to make correct. The moment a mutation fails mid-flight — or two edits to the same entity race — a naive setQueryData leaves the cache in a state that matches neither the server nor the user's intent. optimistic-kit gives you a small transaction engine that models optimistic edits as a stack of layers over a confirmed base, so any single edit can roll back or commit without disturbing the others.
Deep-dive guides on the cache patterns behind this tool live at frontendcache.com.
Optimistic UI in development looks bulletproof because development is too kind — mutations succeed instantly and one at a time. Production is not:
- A
PATCH /todos/42is applied optimistically, the request fails 3 seconds later, and youronErrorhandler restores a stale snapshot — clobbering a second optimistic edit the user made while the first was in flight. - Two fields of the same entity are edited in quick succession. Each mutation snapshots the cache, applies its patch, and on settle writes back its snapshot-plus-patch — silently discarding the other's change (a lost update).
- The server confirms a write but returns a value that diverges from what you predicted (a concurrent writer bumped the row). You need a policy for that — sometimes the server wins, sometimes the newer version wins, sometimes you merge — not an ad-hoc
if.
The root cause is modeling optimistic state as "the cache, mutated in place" and rollback as "the cache I remembered earlier". With more than one edit in flight, neither is well-defined. optimistic-kit models it instead as visible = base + [layer, layer, …], a pure function of an ordered stack — so rollback is "drop that layer" and commit is "fold that layer into the base", each independent of every other in-flight edit.
Not published to a registry — clone and build:
git clone https://github.com/frontendcache/optimistic-kit.git
cd optimistic-kit
npm install
npm run buildThen import from the built output (./dist). The core has zero runtime dependencies; the React Query and SWR adapters use hand-written structural types, so neither library is bundled or required.
import {
OptimisticManager,
createOptimisticTransaction,
serverWins,
clientWins,
lastWriteWins,
merge,
} from "./dist/index.js";Requires Node >=20.
The OptimisticManager is the engine. It holds a confirmed base and a stack of in-flight layers; getState() is always base folded through every layer, in order.
import { OptimisticManager, lastWriteWins } from "./dist/index.js";
interface Todo { title: string; done: boolean; version: number }
const mgr = new OptimisticManager<Todo>({ title: "buy milk", done: false, version: 1 });
// Two optimistic edits are in flight at the same time.
const rename = mgr.begin((t) => ({ ...t, title: "buy oat milk" }));
const complete = mgr.begin((t) => ({ ...t, done: true }));
mgr.getState(); // { title: "buy oat milk", done: true, version: 1 } — both visible
// The rename request fails. Roll back JUST that layer.
rename.rollback();
mgr.getState(); // { title: "buy milk", done: true, version: 1 } — the completion survives
// The completion request succeeds; the server returns the canonical row.
// A concurrent writer had already bumped the version, so let the newer write win.
complete.commit(
{ title: "buy milk", done: true, version: 5 },
{ conflict: lastWriteWins((t) => t.version) },
);
mgr.base; // { title: "buy milk", done: true, version: 5 } — confirmed
mgr.pending; // 0That "roll back one, keep the other" behavior is the correctness property a single-snapshot approach cannot give you. The relevant deep-dive is rolling back optimistic updates on error.
For the simpler one-mutation-at-a-time case, createOptimisticTransaction wraps an external store directly and restores the exact captured snapshot on error:
import { createOptimisticTransaction, serverWins } from "./dist/index.js";
let cache = { title: "buy milk", done: false, version: 1 };
const tx = createOptimisticTransaction({
snapshot: () => cache,
apply: (t) => ({ ...t, done: true }),
write: (next) => { cache = next; },
conflict: serverWins,
});
// cache is now { ...done: true } immediately.
try {
const server = await api.completeTodo();
tx.commit(server); // reconcile via the conflict strategy
} catch {
tx.rollback(); // restore the exact pre-mutation snapshot
}optimistic-kit/react-query returns the onMutate / onError / onSuccess / onSettled handlers to spread into useMutation. It snapshots the query, applies the optimistic patch, rolls back automatically on error, and reconciles the server response through your chosen conflict strategy.
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { optimisticMutation } from "optimistic-kit/react-query";
import { lastWriteWins } from "optimistic-kit";
function useRenameTodo(id: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (title: string) => api.patchTodo(id, { title }),
...optimisticMutation<Todo, string, Todo>({
queryClient,
queryKey: ["todo", id],
updater: (prev, title) => ({ ...prev!, title }),
reconcile: (serverTodo) => serverTodo,
conflict: lastWriteWins((t) => t.version),
}),
});
}Pass a shared OptimisticManager in the manager option to get true layered rollback when several mutations over the same key overlap; without it, the adapter falls back to React Query's standard per-mutation snapshot restore.
optimistic-kit/swr wraps SWR's bound mutate, keeping its optimisticData / rollbackOnError behavior and adding conflict-aware reconciliation of the server response (and optional layered rollback via a shared manager).
import { useSWRConfig } from "swr";
import { optimisticMutate } from "optimistic-kit/swr";
import { lastWriteWins } from "optimistic-kit";
function useRenameTodo(id: string, current: Todo) {
const { mutate } = useSWRConfig();
return (title: string) =>
optimisticMutate(mutate, {
key: ["todo", id],
current,
optimisticData: { ...current, title },
mutationFn: () => api.patchTodo(id, { title }),
conflict: lastWriteWins((t) => t.version),
});
}| Export | Description |
|---|---|
new OptimisticManager<T>(base) |
Stateful layered engine over a confirmed base. |
.begin(updater, { id?, meta? }) |
Push an optimistic layer; returns a LayerHandle. |
.getState() |
Visible state = base folded through all layers. |
.base / .pending / .layers |
Confirmed base, in-flight count, read-only layer list. |
.rollback(id) |
Drop one layer; other layers untouched. |
.commit(id, server?, { conflict? }) |
Fold a layer into the base, reconciling if a server value is given. |
.setBase(next) |
Replace the confirmed base (e.g. after a background refetch); layers are re-applied on top. |
.subscribe(fn) |
Observe visible-state changes; returns an unsubscribe. |
applyOptimistic(base, layers) |
Pure reducer producing the visible state. |
createOptimisticTransaction(spec) |
Single-mutation helper bound to an external store; exact-snapshot rollback. |
createLayer(updater, opts?) / nextLayerId(prefix?) |
Low-level layer construction. |
A LayerHandle returned from begin exposes .rollback() and .commit(server?, options?) so you can settle exactly the layer you started.
A ConflictStrategy<T> decides the confirmed value from { base, optimistic, server } when a mutation settles with a server response.
| Strategy | Behavior |
|---|---|
serverWins |
The server response is authoritative. The safe default. |
clientWins |
Keep the optimistic prediction even if the server disagrees (use with care — can mask lost updates). |
lastWriteWins(getVersion) |
Whichever side has the higher version/timestamp wins; ties go to the server. |
merge(fn) |
Delegate to a custom field-level merge — e.g. take the server's canonical fields but keep a still-in-flight local edit. |
Strategies are plain objects, so writing your own is a one-liner: { resolve: ({ base, optimistic, server }) => /* T */ }.
Why layers instead of a single snapshot? A single snapshot answers "what was the cache before this mutation?" — but with two mutations in flight, that question has two different answers and restoring either one destroys the other's edit. Modeling the visible state as base + [ordered layers] makes rollback and commit local operations: dropping or folding one layer is defined independently of every other layer. The confirmed base only ever advances when a mutation is actually acknowledged. This is the difference between "optimistic UI that usually works" and "optimistic UI that is correct under concurrency."
Why express each layer's patch relative to the base, not the visible state? So that commit can fold a layer into the base without recomputing the others, and so that rollback is a pure splice. A layer's apply runs over whatever state precedes it in the fold, which for a field-setting patch (s => ({ ...s, title })) is exactly the intuitive result regardless of ordering.
Why zero runtime dependencies and structural adapter types? The core should be usable from any cache — React Query, SWR, Apollo, Zustand, or a plain object. The adapters describe the slice of QueryClient / mutate they touch with hand-written interfaces, so importing optimistic-kit/react-query never pulls @tanstack/react-query into your bundle and never pins you to a version.
Why keep createOptimisticTransaction when the manager is more powerful? Most mutations are not concurrent. For those, the snapshot-restore transaction is less machinery and its rollback restores the exact object reference you captured. Reach for the manager when edits to the same entity can overlap.
Deep-dive guides on the server-synchronization patterns this library implements:
- Cache invalidation & server synchronization — the pillar covering how client caches stay in sync with the server.
- Rolling back optimistic updates on error — the rollback correctness problem this tool exists to solve.
- Syncing offline mutations on reconnect — replaying a queue of layered mutations after the network returns.
- Background refetch strategies — why
setBasemust preserve in-flight layers when fresh server data arrives.
npm install
npm run build # tsc → dist
npm run typecheck # tsc --noEmit
npm test # node:test under tsxMIT — Copyright (c) 2026 frontendcache