Agent SkillsUsefulSoftwareCo/executor › effect-atom-optimistic-updates

effect-atom-optimistic-updates

GitHub

指导在 effect-atom 中实现乐观 UI 更新。使用 Atom.optimistic 和 Atom.optimisticFn 包装查询与突变,避免自定义 pending 状态导致的竞态问题。适用于需即时反馈的列表增删改场景,无需等待服务器响应。

.skills/effect-atom-optimistic-updates/SKILL.md UsefulSoftwareCo/executor

触发场景

需要实现乐观 UI 更新 处理列表查询与突变的并发操作 避免手动管理 pending 状态

安装

npx skills add UsefulSoftwareCo/executor --skill effect-atom-optimistic-updates -g -y
更多选项

非标准路径

npx skills add https://github.com/UsefulSoftwareCo/executor/tree/main/.skills/effect-atom-optimistic-updates -g -y

不安装直接使用

npx skills use UsefulSoftwareCo/executor@effect-atom-optimistic-updates

指定 Agent (Claude Code)

npx skills add UsefulSoftwareCo/executor --skill effect-atom-optimistic-updates -a claude-code -g -y

安装 repo 全部 skill

npx skills add UsefulSoftwareCo/executor --all -g -y

预览 repo 内 skill

npx skills add UsefulSoftwareCo/executor --list

SKILL.md

Frontmatter
{
    "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:

  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.

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.

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.

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

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.

版本历史

  • fd4fb02 当前 2026-07-05 10:55

同 Skill 集合

.agents/skills/stack/SKILL.md
.agents/skills/wrdn-effect-atom-optimistic/SKILL.md
.agents/skills/wrdn-effect-atom-reactivity-keys/SKILL.md
.agents/skills/wrdn-effect-promise-exit/SKILL.md
.agents/skills/wrdn-effect-raw-fetch-boundary/SKILL.md
.agents/skills/wrdn-effect-schema-boundaries/SKILL.md
.agents/skills/wrdn-effect-schema-inferred-types/SKILL.md
.agents/skills/wrdn-effect-typed-errors/SKILL.md
.agents/skills/wrdn-effect-value-inferred-types/SKILL.md
.agents/skills/wrdn-effect-vitest-tests/SKILL.md
.agents/skills/wrdn-package-boundaries/SKILL.md
.agents/skills/wrdn-typescript-type-safety/SKILL.md
.claude/skills/emulate/SKILL.md
.claude/skills/prod-telemetry/SKILL.md
.claude/skills/self-contained-modals/SKILL.md
.skills/cli-release/SKILL.md
.skills/effect-http-testing/SKILL.md
.skills/effect-use-pattern/SKILL.md
.skills/graphite/SKILL.md
.skills/warden-security-review/SKILL.md

元信息

文件数
0
版本
8bc037d
Hash
b7e5a6a7
收录时间
2026-07-05 10:55

首页 - Wiki
Copyright © 2011-2026 iteam. Current version is 2.155.2. UTC+08:00, 2026-07-18 01:55
浙ICP备14020137号-1 $访客地图$