Installer avec Codex ou Claude Copiez ce prompt, collez-le dans Codex, Claude ou un autre assistant, puis laissez-le vérifier la page du skill et l'installer pour vous.
Une commande directe contourne le prompt de vérification. Examinez la source avant de l'exécuter.
setupPageWrapper sets pageSignal$ before calling your setup command
Your setup command receives the signal and can access pageSignal$ in components
Never manually set pageSignal$ in setup commands — the wrapper does it for you.
Computed Memoization — No Manual Cache Needed
ccstate computed automatically memoizes the last result. If none of the dependencies have changed, reading the computed returns the cached value without re-executing the callback. Do not add a manual Map or cache layer on top.
This is especially relevant for signal factories: a computed that calls createSomeSignals(id) won't re-create the signals unless id actually changes.
Storing Function Values in State — The Updater Gotcha
When you call set(atom$, value), ccstate checks if value is a function. If it is, ccstate treats it as an updater — it calls value(previousValue) and stores the return value, not the function itself. This is the same convention as React's setState(fn).
This means you cannot directly store a function in a state() atom using set(). The function will be executed immediately instead of stored.
The problem
const cleanup$ = state<(() =>void) | null>(null);
// ❌ BUG: ccstate calls the arrow function as an updater// It executes: (() => { reader.cancel(); audioCtx.close(); })(previousValue)// The return value (undefined) is stored, and the side effects fire immediatelyset(cleanup$, () => {
reader.cancel();
audioCtx.close();
});
This is especially dangerous because:
The side effects (cancel, close) execute immediately instead of being deferred
The stored value becomes undefined (the return value of the arrow function), not the function
There is no runtime error at the set() call site — the bug is silent
The fix: wrap in an updater that returns the function
const cleanup$ = state<(() =>void) | null>(null);
// ✅ Outer arrow is the updater; it returns the cleanup function to storeconstcleanupFn = () => {
reader.cancel();
audioCtx.close();
};
set(cleanup$, () => cleanupFn);
The outer () => cleanupFn is called as the updater — it receives previousValue (ignored) and returns cleanupFn, which is then stored in the atom.
Why this happens
From ccstate's core (ccstate/core/index.js):
if (typeof val === 'function') {
var updater = val;
newValue = updater(previousValue);
} else {
newValue = val;
}
This is by design — it mirrors React's useState updater pattern:
// React: setState(prev => prev + 1) — function is an updater, not the value// ccstate: set(count$, prev => prev + 1) — same convention
// ✅ Auto-fetches on first access, invalidates via counter bumpconst internalReload$ = state(0);
exportconst agents$ = computed(async (get) => {
get(internalReload$);
const result = awaitaccept(
get(zeroClient$)(contract).list(),
[200],
);
return result.body;
});
exportconst reloadAgents$ = command(({ set }) => {
set(internalReload$, (prev) => prev + 1);
});
Benefits:
No manual loading/error state — consumers use useLoadable() or useLastResolved() from ccstate-react
No explicit fetch calls in page setups — data loads lazily when first accessed
Invalidation via reloadAgents$ is a simple counter bump
Fewer files touched, fewer places to forget the fetch call
Consumer patterns in views:
// Loading from loadable stateconst agentsLoadable = useLoadable(agents$);
const loading = agentsLoadable.state === "loading";
// Last resolved value (keeps showing old data while reloading)const agents = useLastResolved(agents$) ?? [];
HTTP Error Handling with accept
All HTTP calls via zeroClient$ must use the accept utility function. This is the only permitted way to handle API response status codes. Manual status checks, try-catch for HTTP errors, and direct toast.error calls for API failures are all forbidden in the signals layer.
Core Pattern
accept takes a ts-rest call promise and a required non-empty array of accepted status codes. It returns a type-narrowed result containing only the accepted status codes. Any response not in the accept list is automatically:
Shown as a toast.error (with the server's error message), except for 401
responses handled by the authenticated client's sign-in recovery and the
dedicated force-upgrade response handled by the blocking update dialog
Thrown as an ApiError (so the calling code stops executing)
import { accept } from"../../lib/accept.ts";
// Signal: clean business logic, no manual error handlingexportconst inviteMember$ = command(
async ({ get, set }, email: string, role: OrgRole, signal: AbortSignal) => {
const client = get(zeroClient$)(zeroOrgInviteContract);
const result = awaitaccept(
client.invite({ body: { email, role } }),
[200],
);
// result type is narrowed to { status: 200, body: OrgMessageResponse }// If status was 400/403/500 → toast + throw already happened.// A 401 redirects to sign-in without an error toast.
toast.success(`Invitation sent to ${email}`);
set(refreshOrgMembers$);
},
);
accept is required — accept list must be explicit
Every zeroClient$ call must be wrapped in accept. You must declare at least one status code. This forces every call site to explicitly state what it considers success.
// ❌ Forbidden: raw status checksconst result = await client.invite({ body });
if (result.status !== 200) {
thrownewError("Failed");
}
// ❌ Forbidden: try-catch for HTTP errors in signalstry {
await client.invite({ body });
} catch (error) {
toast.error("Failed");
}
// ✅ Required: use acceptconst result = awaitaccept(client.invite({ body }), [200]);
Handling specific error codes (e.g. 404 → return null)
When a specific error code has business meaning, include it in the accept list:
exportconst getAgent$ = computed(async (get) => {
const client = get(zeroClient$)(zeroAgentsByIdContract);
const result = awaitaccept(client.get({ params: { id } }), [200, 404]);
if (result.status === 404) returnnull;
return result.body;
});
Fail fast for background fetches
For computed (background data fetching), call accept directly and let errors propagate. accept already handles API errors by showing the server message (except for 401 responses owned by sign-in recovery and the dedicated force-upgrade response owned by the blocking update dialog) and throwing an ApiError; application code should not catch or replace that error handling.
When accept throws, useLoadable / useLoadableSet transitions to { state: 'hasError', error: ApiError }. Views should use loadable state for loading, disabled, and success UI. Do not catch errors in the view layer, do not show replacement toasts, and do not add application-level error handling around API failures; accept and the authenticated client already handled the error and the flow should fail fast.
All zeroClient$ calls must use accept — no exceptions
accept list is required and non-empty — you must declare at least one status code
No manual throw / try-catch for HTTP errors in signals — accept handles it
No application-level API error handling — do not catch or replace accept errors
View layer uses loadable state for lifecycle UI — never .catch() for toast or inline error handling
AbortSignal Lifecycle and Ownership
Every AbortSignal must have a clear owner that will abort it. Orphaned signals cause polling loops that never stop and promises that leak past test boundaries.
Signal hierarchy
rootSignal$ (app lifecycle)
└── routeSignal (per-route, aborted on navigation)
└── pageSignal$ (exposed to components)
└── resetSignal() (per-operation, e.g. send/polling)
Two usage patterns of resetSignal()
resetSignal() creates an independent AbortController and aborts the previous one on each call. It has two normal usage patterns:
With parent signal: The signal is controlled by both the parent lifecycle and the next reset
Without parent signal: The signal is controlled only by the next reset (mutual exclusion) or explicit cancellation
How resetSignal works:
// From utils.tsreturncommand(({ get, set }, ...signals: AbortSignal[]) => {
get(controller$)?.abort(); // abort previousconst controller = newAbortController();
set(controller$, controller);
returnAbortSignal.any([controller.signal, ...signals]); // combine with parents
});
The core capability of resetSignal is mutual exclusion: each call aborts the previous signal. This naturally provides two abort paths:
Starting the next task automatically cancels the previous one (mutual exclusion)
Calling without data (i.e., not starting a new task) simply cancels the current one
Pattern 1: With parent signal — participating in lifecycle
When the operation needs to be aborted along with the page/route lifecycle, pass in a parent signal:
// Signal aborts on any of: page navigation, next resetconst signal = set(resetSending$, pageSignal);
Pattern 2: Without parent signal — pure cancellation control
When the operation does not need to be tied to the page lifecycle and only needs mutual exclusion and explicit cancellation, omit the parent:
Example 1: Cancel button for file upload (chat-draft.ts)
functioncreateChatAttachment(file: File): ZeroChatAttachment {
const resetSignal$ = resetSignal();
// Explicit cancel: no new task started, just abort the current uploadconst cancel$ = command(({ set }) => {
set(resetSignal$);
});
// Mutual exclusion start: starting a new upload auto-cancels the previous, also binds to page lifecycleconst upload$ = command(async ({ get, set }, signal: AbortSignal) => {
const uploadSignal = set(resetSignal$, signal);
// ... use uploadSignal for the upload ...
});
}
cancel$ omits the parent — its job is to abort the current upload when there is no next upload to start. upload$ passes the parent because page unmount should also abort the upload.
The parent is omitted here because the send operation needs to survive page navigation — if bound to pageSignal$, the route change would abort the in-flight send request.
Common mistake: floating polling loop
For long-running operations (like polling loops), a parent signal is required, otherwise the loop never stops (mutual exclusion only takes effect on the next call — if there is no next call, the loop leaks):
// ❌ resumeSignal has no parent — loop runs forever if resetSending$ isn't called againconst resumeSignal = set(resetSending$);
set(startLoop$, { runId }, resumeSignal);
// ✅ Pass the page/route signal so loop stops on navigationconst resumeSignal = set(resetSending$, signal);
set(startLoop$, { runId }, resumeSignal);
Detach, Floating Promises, and Test Cleanup
In tests, do not manually await clearAllDetached() to make assertions pass.
clearAllDetached() belongs to teardown, where it prevents one test's detached
work from leaking into the next test. If a test is flaky while it is still
running, treat that as a floating-promise bug: find the missing await, parent
signal, or explicit domain-level test synchronization point instead of adding
waits, manual clears, or extra detach() calls.
Never use .catch(() => {}) to silence floating promises
Enforced by ESLint rule: ccstate/no-empty-promise-catch
.catch(() => {}) technically satisfies @typescript-eslint/no-floating-promises (the promise is "handled"), but the empty handler means the promise is invisible to clearAllDetached() — it escapes test cleanup and can cause DOMException on teardown.
// ❌ Silences lint but escapes cleanup — caught by no-empty-promise-catchloadFile(file, signal).catch(() => {});
handleToggle(entry, enabled).catch(() => {});
// ✅ Properly tracked for cleanupdetach(loadFile(file, signal), Reason.DomCallback);
detach(handleToggle(entry, enabled), Reason.DomCallback);
If the promise has a .then() chain before it, wrap the entire chain:
// ❌ Empty catch at the endsaveData(signal)
.then(() => {
toast.success("Saved");
})
.catch(() => {});
// ✅ Wrap entire chain in detachdetach(
saveData(signal).then(() => {
toast.success("Saved");
}),
Reason.DomCallback,
);
Scope of detach() usage
detach() should only appear in the views layer (React components), not in the signals directory.
detach with Reason.DomCallback is designed for DOM event handlers — in React components, event callbacks cannot return a promise, so detach is needed to track the fire-and-forget promise.
In the signals layer, the caller can always await the return value or manage the lifecycle through the signal chain. If you find yourself needing detach in signals, it usually means the signal chain or command composition is flawed — fix the root cause instead of working around it with detach.
// ✅ Views layer: use detach in DOM event callbacksconsthandleClick = () => {
detach(commandFn(pageSignal), Reason.DomCallback);
};
// ❌ Signals layer: detach should not appear here, use await or signal chainexportconst someCommand$ = command(async ({ set }, signal) => {
detach(set(anotherCommand$, signal), Reason.Daemon); // ← misuse
});
// ✅ Signals layer: correct approach is to await directlyexportconst someCommand$ = command(async ({ set }, signal) => {
awaitset(anotherCommand$, signal);
});
detach() tracks promises for cleanup
detach(someAsyncWork(), Reason.DomCallback);
clearAllDetached() in afterEach awaits all tracked promises
Without detach, a fire-and-forget promise is a floating promise — invisible to cleanup
Floating promises are dangerous
// ❌ Floating promise — escapes all cleanup, causes DOMException on teardownset(startLoop$, { runId }, signal).catch((e) => { ... });
// ✅ Tracked by detach in the views layer — clearAllDetached will await itdetach(set(startLoop$, { runId }, signal), Reason.Daemon);
But don't use detach to paper over orphaned signals. Fix the signal chain first.
Test cleanup order matters
// ✅ Correct: abort detached promises BEFORE removing MSW handlersafterEach(async () => {
awaitclearAllDetached(); // 1. abort & await all detached promises
server.resetHandlers(); // 2. then remove mock handlers
});
// ❌ Wrong: promises try to fetch after handlers are gone → ECONNREFUSED / 401afterEach(() => {
server.resetHandlers(); // handlers gone// detached promises still running, hit real network
});
Extracting Shared Logic from Commands
When two or more commands share duplicated logic, extract it into a sub-command (command()), not a plain function that receives get/set.
Why not a plain function?
A plain helper that accepts get or set as parameters breaks the ccstate contract — get/set are scoped to the command callback and should not leak out. The ESLint rule ccstate/... flags this. More importantly, a plain function cannot participate in the signal/reactive graph.
Pass signal explicitly and use fetchOptions: { signal } for HTTP calls. This ensures the request is cancelled when the caller's signal aborts.
Keep AbortSignal out of args objects, options objects, and React props. For
repository-owned functions, pass it as the final positional parameter. React
components should read the lifecycle signal from its owning ccstate signal
(for example, useGet(pageSignal$)) instead of receiving it as a prop. Object
members remain appropriate only at fixed boundaries such as fetchOptions or
third-party SDK request options.
const parentCommand$ = command(async ({ set }, signal: AbortSignal) => {
const result = awaitset(sendRequest$, agentId, prompt, signal);
// No need for signal.throwIfAborted() here — if signal was aborted,// the fetch inside sendRequest$ already threw an AbortError.// Only add throwIfAborted() after operations that DON'T accept a signal.
});
Do not wrap accessors in domain closures
Wrapping get/set in helper-specific closures is the same contract break as
passing them directly. Avoid getFlow/setFlow-style arguments, args-object
properties such as { setFlow: set }, and shorthand objects such as { set }.
Prefer one of these patterns:
Pass atoms (State, Computed, Command) into a sub-command and read/write
them inside that command.
Use a signal factory when two feature variants need isolated state but shared
command logic.
The device-auth signals are the worked example: the Codex and Claude Code flows
instantiate one factory per org/personal variant, while API calls live in
module-scope sub-commands. ccstate/no-getter-setter-params catches explicit
Getter/Setter helper parameters, and ccstate/no-accessor-escape catches
call sites that pass, store, alias, return, or object-wrap callback accessors.
When to use signal.throwIfAborted()
Use it after any await that does NOT accept a signal — i.e., after operations that will complete even if the caller wants to abort:
const example$ = command(async ({ get, set }, signal: AbortSignal) => {
// ✅ fetch accepts signal → no throwIfAborted needed afterconst result = awaitset(sendRequest$, data, signal);
// ✅ get() on a computed is synchronous-ish but doesn't accept signalconst thread = awaitget(currentThread$);
signal.throwIfAborted(); // ← needed: get() doesn't know about our signal// ✅ set() on a sub-command that passes signal through → no throwIfAborted neededawaitset(anotherCommand$, thread.id, signal);
});
Rule of thumb: If the awaited operation receives your signal, it will throw on abort itself. If it doesn't, check manually after.
DOM Ref Pattern — onRef
When a signal stores a reference to a DOM element (e.g., a scroll container, a file input), always use onRef to wrap the setter command. Never write a command that directly accepts HTMLElement | null.
Why
React ref callbacks receive null when the element unmounts. A plain command that accepts el | null has no lifecycle hook — there is no place to remove event listeners or cancel side-effects tied to the element. onRef solves this by:
Filtering out null — the inner command only fires when the element mounts.
Providing an AbortSignal — aborted automatically when the element unmounts, so cleanup is trivial.
Returning a React-compatible cleanup function for ref callbacks (React 19+).
The resulting type is Command<(() => void) | undefined, [HTMLElement | null]> — it accepts null (for React ref callbacks) and returns a cleanup function when non-null.
Anti-pattern
// ❌ WRONG — no lifecycle, no cleanup mechanismconst setEl$ = command(({ set }, el: HTMLElement | null) => {
set(internalEl$, el);
});
View usage
Pass the useSet result directly as a ref — do not wrap it in an arrow function (which would discard the cleanup return value):
Refactored the signal handling to avoid global singletons when multiple signals exist within a single page.
In previous requirements, we only needed a single chat session per page, so we used global singleton signals. This was not an issue at the time.
However, as we refactor the code to support multiple chat sessions within a single page, we must implement the Signal Factory pattern to prevent global singleton conflicts.
Each factory call returns fresh state()/computed()/command() instances, so multiple instances can coexist without sharing state.
The Signals Factory allows a single page to contain multiple sets of Signals.
While this approach is more complex than using a singleton, it provides a viable solution for managing multiple distinct page instances within a single view.
No manual cache needed — ccstate computed memoizes the last result. As long as currentChatThreadId$ hasn't changed, the same ChatThreadSignals object is returned without re-creation.
Step 5 — Components consume via props:
exportfunctionZeroChatThreadPage({ thread }: { thread: ChatThreadSignals }) {
const messagesLoadable = useLastLoadable(thread.messages$);
const setScrollContainer = useSet(thread.setScrollContainer$);
// ... pass thread down to children ...
}
Key rules
Interface first — define a Signals interface listing only the public signals. Keep internal state() atoms private to the factory.
Sub-factories for each concern — split message state, scroll, draft, commands, etc. into separate functions. The main factory composes them.
Dependencies via parameters — sub-factories receive the signals they depend on as arguments, not module-level imports.
Pass as React props — the factory result is a plain object, so pass it as a prop. Use useGet(thread.someSignal$) / useSet(thread.someCommand$) in components.
key prop for remount — when creating the component element, use key: threadId so React remounts when the thread changes, avoiding stale hook state.
Allow dependency injection — accept optional existing signal groups (e.g., existingDraft?: DraftSignals) so the caller can share state across factories when needed.
Helpers that were only used by singletons can be inlined — if a hook or utility existed only to wrap singleton signals (e.g., useFileUploadHandlers), inline its logic directly into the component once signals are injected via props.