wrdn-effect-atom-optimistic
GitHub检测React代码中手动实现的乐观更新逻辑,确保其使用effect-atom的Atom.optimistic或optimisticFn替代。旨在修复并发突变导致的状态竞态和UI闪烁问题,强制遵循代码库规范。
Trigger Scenarios
Install
npx skills add UsefulSoftwareCo/executor --skill wrdn-effect-atom-optimistic -g -y
SKILL.md
Frontmatter
{
"name": "wrdn-effect-atom-optimistic",
"description": "Detects hand-rolled optimistic-update plumbing in React code that should be using effect-atom's Atom.optimistic and Atom.optimisticFn instead. Run on diffs touching packages\/react\/src\/api\/atoms.tsx, packages\/react\/src\/pages\/**\/*.tsx, or any file that imports an effect-atom mutation atom from .\/api\/atoms. The hand-rolled patterns race on concurrent mutations and the codebase has chosen the effect-atom primitives as the canonical answer.",
"allowed-tools": "Read Grep Glob Bash"
}
You audit React code in this repo for one thing: did the author roll their own optimistic-update layer on top of an effect-atom query atom instead of using Atom.optimistic / Atom.optimisticFn?
This is not a security skill. It is a correctness skill. The hand-rolled patterns have a known race condition: concurrent mutations on the same row stomp each other's done() calls and the UI flickers back to a stale server value. The fix is the effect-atom primitives, which track transitions and refresh authoritatively. The repo already migrated policies and codified the pattern at .skills/effect-atom-optimistic-updates/SKILL.md.
Trace. Do not pattern-match a useState and call it a day. The signal is "this state is tracking an in-flight mutation alongside an effect-atom query," not "this component uses local state."
Trace before reporting
- Find the mutation. Is the component calling
useAtomSet(<somethingMutation>)frompackages/react/src/api/atoms.tsx? If not, this skill does not apply. - Find the read. Is the same component or its parent reading the matching list via
useAtomValue(<sameThingAtom>(scopeId))? If yes, the optimistic substitute exists or should exist. - Find the bookkeeping. Look for any of these alongside the mutation call:
useState,useReducer,useRefholding "pending" / "placeholder" / "in-flight" / "optimistic" values keyed by row idAtom.makeof a list / map / set of pending entries, in the component or inpackages/react/src/api/optimistic.tsx- Calls into
usePendingResource,usePoliciesWithPending,usePendingPolicies,mergePending, or any helper named like that try { await doMutate(...) } finally { placeholder.done() }shapes- Manual id minting (
pending-${...},crypto.randomUUID) in the page-level handler rather than inside anoptimisticFnreducer
- Confirm the optimistic atom exists or is missing. Open
packages/react/src/api/atoms.tsxand check for<thing>OptimisticAtom = Atom.family(scopeId => Atom.optimistic(<thing>Atom(scopeId))). If a sibling resource (sources, secrets, policies) has the optimistic wrapper and this one doesn't, the bug is the missing wrapper plus the hand-rolled substitute. - Check for grandfathered code.
usePendingSources,useSourcesWithPending,useConnectionsWithPendingRemovals, andusePendingConnectionRemovalsinpackages/react/src/api/optimistic.tsxare legacy. New code should not extend them. Their continued existence is not a finding by itself; new consumers or new entries inPendingResourceare.
When the trace cannot resolve with the files at hand, drop the finding.
What to Report
- Hand-rolled pending state next to an effect-atom mutation. Component imports a mutation from
./api/atoms(e.g.updatePolicy,createSecret,removeConnection) and tracks the in-flight value inuseState/useRef/ a custom atom for the purpose of immediate UI feedback. Severity: medium. - New entries in
PendingResourceor new helpers inpackages/react/src/api/optimistic.tsx. The file is closed for new patterns. New rows, newusePending<X>hooks, newuse<X>WithPendinghooks should instead beAtom.optimistic+Atom.optimisticFnfamilies inatoms.tsx. Severity: medium. try/finallycleanup of a placeholder aroundawait doMutate(...). This shape is the tell.optimisticFnclears its own transition; manual cleanup means the author is reimplementing it. Severity: medium.- Reading
<thing>Atom(scopeId)in a component that also writes through<thing>OptimisticAtom's mutations. The reads and writes must both go through the optimistic family or both bypass it; mixing them produces visual jumps. Severity: medium. Atom.optimisticFnreducer that derives next state from a captured snapshot of the parent atom instead of from thecurrentargument. The reducer signature is(current, update) => W— the runtime reads the optimistic state itself and passes it ascurrent, which already reflects in-flight transitions. Code that closes over auseAtomValue(...)snapshot or a capturedpoliciesvariable instead of usingcurrentwill see stale state under racing edits. A placeholder row id derived fromDate.now()or random is fine; the bug is reducer state that ignorescurrent. Severity: low.Atom.optimisticwrapped viaAtom.optimistic(policiesAtom(scopeId))outside anAtom.family. WithoutAtom.family, every render builds a new optimistic atom and transitions don't share state. Severity: medium.
What NOT to Report
useState/useReducerfor UI-local state that has nothing to do with mutation lifecycle: form input values, modal open flags, hover state, derived display values. Read the surrounding code; if there is nouseAtomSetorawait doMutatenear the state, it is not in scope.- The legacy hooks themselves in
packages/react/src/api/optimistic.tsx(useSourcesWithPending,useConnectionsWithPendingRemovals,usePendingSources,usePendingConnectionRemovals). Existing call sites are grandfathered. Only flag new call sites or new helpers added to that file. useStatefor a "busy" / "submitting" boolean used to disable a button while the mutation runs. That is not optimistic state.setTimeout/setIntervalbased debouncing or rate-limiting around a mutation. Different concern.- Toast / error-message state. UI feedback, not optimistic data.
- Server-only code (
apps/cloud,apps/local,packages/core/**). This skill is React-specific; do not flag backend handlers, plugin storage, or test helpers. - Storybook files, test files, and example-only code. The pattern matters in shipped UI; not in fixtures.
Severity ladder
| Level | Criteria |
|---|---|
| medium | New optimistic-mutation code that bypasses Atom.optimistic / Atom.optimisticFn and rolls its own pending state. Or a mixed read/write where the read goes through the plain query atom and the write goes through the optimistic mutation. |
| low | Subtle defects in an Atom.optimisticFn reducer that work today but degrade under racing (clock-based identity, missing Atom.family wrapper, computed-once captures of scopeId). |
Do not invent high. Pick low when in doubt and explain why.
Reference patterns (TypeScript)
The repo's reference implementation lives in packages/react/src/api/atoms.tsx (search for policiesOptimisticAtom, updatePolicyOptimistic).
Bad: hand-rolled pending state
// packages/react/src/pages/secrets.tsx (hypothetical)
import { useState } from "react";
import { useAtomSet, useAtomValue } from "@effect-atom/atom-react";
import { secretsAtom, updateSecret } from "../api/atoms";
export function SecretsPage() {
const scopeId = useScope();
const secrets = useAtomValue(secretsAtom(scopeId));
const doUpdate = useAtomSet(updateSecret, { mode: "promise" });
const [pendingValue, setPendingValue] = useState<Map<string, string>>(new Map());
const handleEdit = async (id: string, value: string) => {
setPendingValue((m) => new Map(m).set(id, value));
try {
await doUpdate({ path: { scopeId, secretId: id }, payload: { value } });
} finally {
setPendingValue((m) => {
const next = new Map(m);
next.delete(id);
return next;
});
}
};
// ...
}
The Map keyed by id, the try/finally, the manual cleanup. All three signal a hand-rolled optimistic layer. Two fast edits to the same secret race: A's finally deletes B's pending entry, UI flickers to A's server value before B settles.
Safe: effect-atom primitives
// packages/react/src/api/atoms.tsx
export const secretsOptimisticAtom = Atom.family((scopeId: ScopeId) =>
Atom.optimistic(secretsAtom(scopeId)),
);
export const updateSecretOptimistic = Atom.family((scopeId: ScopeId) =>
secretsOptimisticAtom(scopeId).pipe(
Atom.optimisticFn({
reducer: (current, arg: { path: { secretId: SecretId }; payload: { value: string } }) =>
Result.map(current, (rows) =>
rows.map((r) => (r.id === arg.path.secretId ? { ...r, value: arg.payload.value } : r)),
),
fn: updateSecret,
}),
),
);
// packages/react/src/pages/secrets.tsx
const secrets = useAtomValue(secretsOptimisticAtom(scopeId));
const doUpdate = useAtomSet(updateSecretOptimistic(scopeId), { mode: "promise" });
const handleEdit = (id: SecretId, value: string) =>
doUpdate({ path: { scopeId, secretId: id }, payload: { value } });
No local state, no try/finally, no manual cleanup. Multiple edits stack: the runtime feeds each reducer call current reflecting prior in-flight transitions, so the second edit sees the optimistic value of the first.
Bad: extending the legacy layer
// packages/react/src/api/optimistic.tsx (hypothetical addition)
export const PendingResource = {
sources: "sources",
connectionRemovals: "connection-removals",
secrets: "secrets", // <-- new
} as const;
export interface PendingSecret {
readonly value: string;
}
export const useSecretsWithPending = (scopeId: ScopeId) => {
/* ... */
};
export const usePendingSecrets = () => {
/* ... */
};
Flag any new entry in PendingResource and any new use<X>WithPending / usePending<X> hook. The file is closed for new patterns.
Subtle: missing Atom.family wrapper
// Bad: builds a fresh optimistic atom every render. No transition state survives.
const optimistic = Atom.optimistic(secretsAtom(scopeId));
const value = useAtomValue(optimistic);
// Safe: Atom.family memoizes per scopeId so transitions persist.
export const secretsOptimisticAtom = Atom.family((scopeId: ScopeId) =>
Atom.optimistic(secretsAtom(scopeId)),
);
const value = useAtomValue(secretsOptimisticAtom(scopeId));
Output Requirements
For each finding:
- File and line of the offending code.
- Severity from the ladder above.
- What is wrong, in one sentence.
- Trace: which mutation atom, which read atom, which symptom (race / stale flicker / unmemoized atom).
- Fix: name the optimistic family that should exist, or the change that lifts the page's hand-rolled state into
atoms.tsx. Point topackages/react/src/api/atoms.tsx(policiesOptimisticAtom) as the reference shape.
Group findings by severity. Lead with medium.
Version History
- fd4fb02 Current 2026-07-05 10:55


