# Effect Atom Optimistic Updates

> 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.

- Skill: `usefulsoftwareco/effect-atom-optimistic-updates` (Agent Skill)
- Install (CLI): `npx skillmds@latest add usefulsoftwareco/effect-atom-optimistic-updates`
- Raw SKILL.md: https://api.skillmd.com/api/skills/usefulsoftwareco/effect-atom-optimistic-updates/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: usefulsoftwareco (https://skillmd.com/u/usefulsoftwareco)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/usefulsoftwareco/effect-atom-optimistic-updates

---


# 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:

1. User clicks "set action = block" → mutation A fires, pending entry written
2. User clicks "set action = approve" → mutation B fires, pending entry overwritten with B
3. **A's response returns first** → finally-block clears the pending entry → UI flickers back to the server's "block" value
4. 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.

```typescript
import { Atom, Result } from "@effect-atom/atom-react";
import type { ScopeId, PolicyId, ToolPolicyAction } from "@executor/sdk";

import { ExecutorApiClient } from "./client";

// 1. The plain query atom — same as before.
export const policiesAtom = (scopeId: ScopeId) =>
  ExecutorApiClient.query("policies", "list", {
    path: { scopeId },
    timeToLive: "30 seconds",
    reactivityKeys: [ReactivityKey.policies],
  });

// 2. Plain mutations — same as before. These are the underlying `fn` for
//    the optimistic wrappers below.
export const createPolicy = ExecutorApiClient.mutation("policies", "create");
export const updatePolicy = ExecutorApiClient.mutation("policies", "update");
export const removePolicy = ExecutorApiClient.mutation("policies", "remove");

// 3. Optimistic read atom. `Atom.family` memoizes per-scope so every consumer
//    references the same optimistic atom instance and shares transition state.
export const policiesOptimisticAtom = Atom.family((scopeId: ScopeId) =>
  Atom.optimistic(policiesAtom(scopeId)),
);

// 4. Optimistic mutation. The reducer takes the same arg as the underlying
//    mutation and returns the next list state. `Result.map` keeps the
//    Result wrapper intact.
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.

```typescript
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();

  // Read: this Result reflects in-flight optimistic state on top of server data.
  const policies = useAtomValue(policiesOptimisticAtom(scopeId));

  // Write: same call signature as the underlying mutation.
  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

1. **`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.
2. **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.
3. **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.
4. **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.

```typescript
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, // sort to top
            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

```typescript
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.

