| 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>) from packages/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, useRef holding "pending" / "placeholder" / "in-flight" / "optimistic" values keyed by row id
Atom.make of a list / map / set of pending entries, in the component or in packages/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 an optimisticFn reducer
- Confirm the optimistic atom exists or is missing. Open
packages/react/src/api/atoms.tsx and 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, and usePendingConnectionRemovals in packages/react/src/api/optimistic.tsx are legacy. New code should not extend them. Their continued existence is not a finding by itself; new consumers or new entries in PendingResource are.
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 in useState / useRef / a custom atom for the purpose of immediate UI feedback. Severity: medium.
- New entries in
PendingResource or new helpers in packages/react/src/api/optimistic.tsx. The file is closed for new patterns. New rows, new usePending<X> hooks, new use<X>WithPending hooks should instead be Atom.optimistic + Atom.optimisticFn families in atoms.tsx. Severity: medium.
try/finally cleanup of a placeholder around await doMutate(...). This shape is the tell. optimisticFn clears 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.optimisticFn reducer that derives next state from a captured snapshot of the parent atom instead of from the current argument. The reducer signature is (current, update) => W — the runtime reads the optimistic state itself and passes it as current, which already reflects in-flight transitions. Code that closes over a useAtomValue(...) snapshot or a captured policies variable instead of using current will see stale state under racing edits. A placeholder row id derived from Date.now() or random is fine; the bug is reducer state that ignores current. Severity: low.
Atom.optimistic wrapped via Atom.optimistic(policiesAtom(scopeId)) outside an Atom.family. Without Atom.family, every render builds a new optimistic atom and transitions don't share state. Severity: medium.
What NOT to Report
useState / useReducer for 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 no useAtomSet or await doMutate near 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.
useState for a "busy" / "submitting" boolean used to disable a button while the mutation runs. That is not optimistic state.
setTimeout / setInterval based 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
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
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,
}),
),
);
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
export const PendingResource = {
sources: "sources",
connectionRemovals: "connection-removals",
secrets: "secrets",
} 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
const optimistic = Atom.optimistic(secretsAtom(scopeId));
const value = useAtomValue(optimistic);
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 to packages/react/src/api/atoms.tsx (policiesOptimisticAtom) as the reference shape.
Group findings by severity. Lead with medium.