ccstate
# ccstate The ccstate skill documents patterns and best practices for state management in the vm0 platform, with a focus on handling asynchronous DOM event callbacks safely. Use this skill when implementing React components that trigger async commands from DOM events, particularly to understand the proper use of the `detach()` function with `Reason.DomCallback` instead of the `void` operator to handle floating promises while maintaining TypeScript lint compliance.
git clone --depth 1 https://github.com/vm0-ai/vm0 /tmp/ccstate && cp -r /tmp/ccstate/.claude/skills/ccstate ~/.claude/skills/ccstateSKILL.md
# ccstate Patterns and Best Practices
This document records common patterns and best practices when using ccstate in the vm0 platform.
## DOM Callback Pattern
When handling DOM events (like button clicks) that trigger async commands, follow this pattern:
### Problem
DOM event handlers that call async commands will trigger TypeScript lint error `@typescript-eslint/no-floating-promises`.
### Solution
Use the `detach()` function with `Reason.DomCallback` to explicitly mark the promise as intentionally fire-and-forget.
### Pattern
```typescript
import { useSet, useGet } from "ccstate-react";
import { pageSignal$ } from "../../signals/page-signal.ts";
import { detach, Reason } from "../../signals/utils.ts";
import { someCommand$ } from "../../signals/some-command.ts";
function MyComponent() {
const commandFn = useSet(someCommand$);
const pageSignal = useGet(pageSignal$);
const handleClick = () => {
detach(commandFn(pageSignal), Reason.DomCallback);
};
return <button onClick={handleClick}>Click me</button>;
}
```
### Key Points
0. This pattern only applies to React views — it is forbidden to use in the signals directory
1. **Always use `pageSignal$`**: Get the page signal using `useGet(pageSignal$)` instead of creating a new `AbortController`
2. **Use `detach()` instead of `void`**: The `detach()` function properly handles promise rejection and tracks the promise for testing
3. **Use `Reason.DomCallback`**: This enum value indicates the promise is from a DOM event handler
4. **Never use `void` operator**: Using `void` silences the lint error but doesn't properly handle the promise
## Related Patterns
### Getting pageSignal$ in Components
```typescript
import { useGet } from "ccstate-react";
import { pageSignal$ } from "../../signals/page-signal.ts";
function MyComponent() {
const pageSignal = useGet(pageSignal$);
// Use pageSignal to call commands
}
```
### pageSignal$ is Automatically Set by Route System
**Important**: You do NOT need to manually set `pageSignal$` in your setup commands. The route system automatically handles this through `setupPageWrapper`.
```typescript
// ✅ Correct: setupPageWrapper automatically sets pageSignal$
export const setupLogsPage$ = command(({ set }, signal: AbortSignal) => {
// NO need to call set(setPageSignal$, signal) - it's automatic!
// Just do your page-specific initialization
set(initLogs$, signal);
set(updatePage$, createElement(LogsPage));
});
// In bootstrap.ts, routes use setupAuthPageWrapper which calls setupPageWrapper:
const ROUTE_CONFIG = [
{
path: "/logs",
setup: setupAuthPageWrapper(setupLogsPage$), // Wrapper sets pageSignal$ automatically
},
];
```
**How it works**:
1. Route navigation triggers `loadRoute$` (in route.ts)
2. `loadRoute$` calls `setupAuthPageWrapper(setupLogsPage$)`
3. `setupAuthPageWrapper` internally calls `setupPageWrapper`
4. `setupPageWrapper` sets `pageSignal$` before calling your setup command
5. 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.**
```typescript
// ❌ Redundant cache — computed already memoizes
const cache = new Map<string, Result>();
export const result$ = computed((get) => {
const key = get(someKey$);
if (cache.has(key)) return cache.get(key)!;
const value = expensiveCreate(key);
cache.set(key, value);
return value;
});
// ✅ Just create — computed won't re-run if someKey$ hasn't changed
export const result$ = computed((get) => {
const key = get(someKey$);
return expensiveCreate(key);
});
```
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
```typescript
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 immediately
set(cleanup$, () => {
reader.cancel();
audioCtx.close();
});
```
This is especially dangerous because:
1. The side effects (cancel, close) execute **immediately** instead of being deferred
2. The stored value becomes `undefined` (the return value of the arrow function), not the function
3. There is no runtime error at the `set()` call site — the bug is silent
### The fix: wrap in an updater that returns the function
```typescript
const cleanup$ = state<(() => void) | null>(null);
// ✅ Outer arrow is the updater; it returns the cleanup function to store
const cleanupFn = () => {
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`):
```javascript
if (typeof val === 'function') {
var updater = val;
newValue = updater(previousValue);
} else {
newValue = val;
}
```
This is by design — it mirrors React's `useState` updater pattern:
```typescript
// React: setState(prev => prevClean Defensive Try-Catch Blocks
Alias for tech-debt issue
Alias for tech-debt research
Design patterns and conventions for the vm0 CLI user experience
Deep code review and quality analysis for vm0 project
Complete pre-commit workflow - run quality checks (format, lint, type, test) and validate/create conventional commit messages
Database migrations and Drizzle ORM guidelines for the vm0 project
Feature switch system guide for gating new user-facing features behind feature flags