| name | effect-atom-optimistic-updates |
| description | Pattern for implementing optimistic UI updates with effect-atom in this codebase. Use when adding optimistic behavior to a query atom + its mutations (action toggles, list adds/removes, inline edits). DO NOT roll your own pending-state with React state, Maps, or custom merge helpers — `Atom.optimistic` + `Atom.optimisticFn` already handle racing, refresh, and waiting correctly. |
effect-atom Optimistic Updates
The @effect-atom/atom-react library ships first-class optimistic support via
Atom.optimistic and Atom.optimisticFn. Use them directly. Do not write a
custom "pending entries" layer (Maps, useState, useRef) on top of useAtomValue
— it will not handle racing updates correctly, and the existing helpers in
packages/react/src/api/optimistic.tsx are legacy patterns kept only for
sources/connections.
When to use
- A list query atom (
Atom.optimistic wraps the query)
- Plus one or more mutation atoms that change rows in that list (create / update / delete)
- And you want the UI to reflect the change immediately on click, before the server roundtrip
If you only need the UI to be "eventually consistent" (i.e. you're fine waiting
~200ms for the server response and the existing reactivity refetch), skip
optimistic — just use the mutation directly with reactivityKeys.
Why not roll your own
The naive approach — track pending: Array<{id, value}> in a separate atom and
merge it with the server result — has a subtle race that bites on rapid edits:
- User clicks "set action = block" → mutation A fires, pending entry written
- User clicks "set action = approve" → mutation B fires, pending entry overwritten with B
- A's response returns first → finally-block clears the pending entry → UI flickers back to the server's "block" value
- B's response returns → UI shows "approve"
Step 3 is the bug. Fixing it correctly requires per-call entry ids and "last
entry per row id wins" merging — at which point you've reimplemented a worse
version of Atom.optimistic's transition tracking.
Atom.optimistic solves this because the runtime reads the current optimistic
state (including any in-flight transitions) and passes it to your reducer as
current, so B stacks on top of A correctly. When all transitions settle, it
calls refresh(self) to pull the server's authoritative state.
Pattern
Define optimistic atoms next to the underlying query and mutations in the
atoms.tsx (or equivalent) module. Each scope-keyed list gets a family that
wraps the query with Atom.optimistic, and each mutation gets a family that
pipes the optimistic atom through Atom.optimisticFn with a reducer.
import { Atom, Result } from "@effect-atom/atom-react";
import type { ScopeId, PolicyId, ToolPolicyAction } from "@executor/sdk";
import { ExecutorApiClient } from "./client";
export const policiesAtom = (scopeId: ScopeId) =>
ExecutorApiClient.query("policies", "list", {
path: { scopeId },
timeToLive: "30 seconds",
reactivityKeys: [ReactivityKey.policies],
});
export const createPolicy = ExecutorApiClient.mutation("policies", "create");
export const updatePolicy = ExecutorApiClient.mutation("policies", "update");
export const removePolicy = ExecutorApiClient.mutation("policies", "remove");
export const policiesOptimisticAtom = Atom.family((scopeId: ScopeId) =>
Atom.optimistic(policiesAtom(scopeId)),
);
export const updatePolicyOptimistic = Atom.family((scopeId: ScopeId) =>
policiesOptimisticAtom(scopeId).pipe(
Atom.optimisticFn({
reducer: (
current,
arg: {
path: { scopeId: ScopeId; policyId: PolicyId };
payload: { action?: ToolPolicyAction };
reactivityKeys?: ReadonlyArray<unknown>;
},
) =>
Result.map(current, (rows) =>
rows.map((r) =>
r.id === arg.path.policyId && arg.payload.action !== undefined
? { ...r, action: arg.payload.action }
: r,
),
),
fn: updatePolicy,
}),
),
);
Consuming in components
Read from the optimistic atom; write through the optimistic mutation. The
existing reactivityKeys plumbing still applies — pass them in the call.
import { useAtomValue, useAtomSet } from "@effect-atom/atom-react";
import { policiesOptimisticAtom, updatePolicyOptimistic } from "../api/atoms";
import { policyWriteKeys } from "../api/reactivity-keys";
export function PoliciesPage() {
const scopeId = useScope();
const policies = useAtomValue(policiesOptimisticAtom(scopeId));
const doUpdate = useAtomSet(updatePolicyOptimistic(scopeId), { mode: "promise" });
const handleUpdate = async (id: string, action: ToolPolicyAction) => {
await doUpdate({
path: { scopeId, policyId: PolicyId.make(id) },
payload: { action },
reactivityKeys: policyWriteKeys,
});
};
}
Reducer rules
current is the FULL Result, not the unwrapped value. Use Result.map
to update inside the success case — the wrapper preserves Initial/Failure
states correctly.
- The reducer is called for every transition, including ones that stack on
top of in-flight ones. Read
current and produce next — don't track
"the previous optimistic value" yourself.
- The reducer signature must match the mutation's arg shape. Effect-atom
passes the raw mutation arg (e.g.
{ path, payload, reactivityKeys }) to
both the reducer and the underlying fn. Don't try to build a "nicer" arg
shape unless you also wrap the underlying mutation.
- Be pure. No side effects, no calls to
Date.now() for stable values,
no random ids unless you need a placeholder row id (see "Adds" below).
Adds (server mints the id)
For create flows the server assigns the canonical id. The reducer inserts a
placeholder with a temp id — the post-commit refresh replaces it with the
canonical row.
export const createPolicyOptimistic = Atom.family((scopeId: ScopeId) =>
policiesOptimisticAtom(scopeId).pipe(
Atom.optimisticFn({
reducer: (
current,
arg: {
path: { scopeId: ScopeId };
payload: { pattern: string; action: ToolPolicyAction };
reactivityKeys?: ReadonlyArray<unknown>;
},
) =>
Result.map(current, (rows) => [
{
id: PolicyId.make(`pending-${Math.random().toString(36).slice(2)}`),
scopeId,
pattern: arg.payload.pattern,
action: arg.payload.action,
position: -Number.MAX_SAFE_INTEGER,
createdAt: Date.now(),
updatedAt: Date.now(),
},
...rows,
]),
fn: createPolicy,
}),
),
);
The placeholder doesn't need to roundtrip through the id field unless your
list rendering keys on it (it usually does — <Row key={p.id}>). A unique
prefix like pending- is fine.
Removes
export const removePolicyOptimistic = Atom.family((scopeId: ScopeId) =>
policiesOptimisticAtom(scopeId).pipe(
Atom.optimisticFn({
reducer: (
current,
arg: {
path: { scopeId: ScopeId; policyId: PolicyId };
reactivityKeys?: ReadonlyArray<unknown>;
},
) => Result.map(current, (rows) => rows.filter((r) => r.id !== arg.path.policyId)),
fn: removePolicy,
}),
),
);
How racing is handled (mental model)
You don't have to think about this — it works — but understanding helps:
Atom.optimistic(self) wraps the underlying atom and tracks a transitions set
- Each
Atom.optimisticFn call creates one shared transition state per (scope, mutation)
- A call: runtime reads the current optimistic state (including in-flight transitions), invokes
reducer(current, arg) → value, sets transition to Success(value, waiting=true), calls the underlying mutation fn with arg
- The next call to the same optimisticFn sees the prior call's optimistic value as
current — so it stacks on top
- When
fn settles, both calls' subscribers fire, the transition flips to non-waiting, and refresh(self) pulls the server state
- The server's authoritative response replaces the optimistic state via the
underlying atom's normal subscribe path
Net result: rapid edits look smooth, the last edit wins both visually and on
the server, no flickers, no manual cleanup.
Things to avoid
- ❌
useState/useRef to hold pending values
- ❌
Map / Set of in-flight ids
- ❌ Custom
mergePending helpers
- ❌
try/finally blocks that "clear the placeholder" — optimistic clears for you
- ❌ Reading
policiesAtom directly in the component while writing through updatePolicyOptimistic — they have different transition state, you'll see jumps
Reference implementation
packages/react/src/api/atoms.tsx (search for policiesOptimisticAtom) and
packages/react/src/pages/policies.tsx show the full pattern: optimistic
read, optimistic create/update/remove, no custom state.
When you must NOT use this
- The mutation has cross-cutting effects on data that isn't in the same list
(e.g. a single mutation invalidates
tools AND policies). Reactivity keys
still handle that — optimistic only paints the list-local change.
- You need to show transient UI state that isn't a row property (toasts,
dirty indicators per field, pending counts). Those belong in component state,
not in the atom layer.