atomirx
npx skills add https://github.com/linq2js/atomirx --skill atomirx
Agent 安装分布
Skill 文档
atomirx State Management
Philosophy: You Don’t Care About Async/Sync
Atomirx abstracts away the async/sync distinction. In reactive contexts, you write sync code regardless of whether atoms contain sync values or Promises.
| Context | Your Code | Async Handling |
|---|---|---|
useSelector, derived, effect |
Sync â just read() |
Suspense handles it |
| Services | Receive values as parameters | Don’t read atoms |
| Outside reactive context | await .get() |
Explicit when needed |
// You don't care if user$ contains sync value or Promise
const userName$ = derived(({ read }) => read(user$).name);
// Same pattern works for any atom
function MyComponent() {
const name = useSelector(userName$); // Sync code, Suspense handles async
return <div>{name}</div>;
}
This is why naming doesn’t use Async$ suffix â the abstraction makes it irrelevant.
Bootstrap Pattern (DevTools)
DevTools must initialize BEFORE any atoms are created to properly track all reactive primitives.
// main.tsx
async function main() {
// 1. Render devtools FIRST (before any atom imports)
if (process.env.NODE_ENV !== 'production') {
const { renderDevtools } = await import("atomirx/react-devtools");
await renderDevtools();
}
// 2. THEN import React and App (which contains atoms)
const React = await import("react");
const ReactDOM = await import("react-dom/client");
const { default: App } = await import("./App");
// 3. Render app
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
}
main();
Why this order matters:
onCreateHookmust be set up before atoms are created- DevTools uses
onCreateHookto track atom/derived/effect creation - If atoms are imported before devtools, they won’t appear in the panel
For apps with bootstrap logic:
// bootstrap.ts
export async function bootstrap() {
// Initialize services, fetch config, etc.
await initializeServices();
}
// main.tsx
async function main() {
// 1. DevTools first
if (process.env.NODE_ENV !== 'production') {
const { renderDevtools } = await import("atomirx/react-devtools");
await renderDevtools();
}
// 2. Bootstrap (may create atoms)
const { bootstrap } = await import("./bootstrap");
await bootstrap();
// 3. Import and render app
const React = await import("react");
const ReactDOM = await import("react-dom/client");
const { default: App } = await import("./App");
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
}
main();
Core Primitives
| Primitive | Purpose | Subscription |
|---|---|---|
atom<T>(initial) |
Mutable state | No |
derived(fn) |
Computed value | Yes (lazy) |
effect(fn) |
Side effects on changes | Yes (eager) |
event<T>(opts) |
Block until fired | Yes |
select(fn) |
One-time read, no subscription | No |
pool(fn, opts) |
Parameterized atoms with GC | Per-entry |
batch(fn) |
Group updates, single notify | No |
define(fn) |
Lazy singleton factory | No |
onCreateHook |
Track atom/effect creation | No |
onErrorHook |
Global error handling | No |
Events (Block Until Fired)
Events block computation until fire() is called. Useful for user-driven workflows.
event vs atom<Promise<T>>
| Aspect | atom<Promise<T>> |
event<T> |
|---|---|---|
| Initial state | Promise you provide | Pending (no data) |
| Set value | .set(promise) |
.fire(data) |
| First update | Replaces promise | Resolves pending |
| Get last value | Track yourself | .last() built-in |
| Use case | Async data fetching | User signals |
Event API
| Method | Description |
|---|---|
fire(payload) |
Fire event with payload |
get() |
Current promise (may be resolved) |
next() |
Pending promise for next meaningful fire |
on(listener) |
Subscribe to changes |
last() |
Last fired payload |
fireCount |
Count of meaningful fires |
sealed() |
True if once and already fired |
get() vs next()
get() |
next() |
|
|---|---|---|
| Returns | Current promise | Pending promise |
| After fire | Same resolved | New pending |
| Use case | Reactive | Imperative |
next() respects equals – duplicate fires won’t resolve.
import { event, derived, effect } from "atomirx";
// Create event
const submitEvent = event<FormData>({ meta: { key: "form.submit" } });
const cancelEvent = event({ meta: { key: "form.cancel" } }); // void payload
// In derived - suspends until fire()
const result$ = derived(({ read }) => {
const data = read(submitEvent); // Blocks until submitEvent.fire(data)
return processForm(data);
});
// In effect - suspends until fire()
effect(({ read }) => {
const data = read(submitEvent);
console.log("Form submitted:", data);
});
// Race multiple events
const outcome$ = derived(({ race }) => {
const { key, value } = race({
submit: submitEvent,
cancel: cancelEvent,
});
return key === "cancel" ? null : processForm(value);
});
// Fire the event (from user action)
function handleSubmit(data: FormData) {
submitEvent.fire(data);
}
// Imperative awaiting with next()
async function processClicks() {
while (true) {
const data = await clickEvent.next();
handleClick(data);
}
}
once Option (One-Time Events)
const initEvent = event<Config>({ once: true });
initEvent.fire(config); // Promise resolved
initEvent.fire(config2); // No-op (already fired)
initEvent.get(); // Same resolved promise
initEvent.next(); // Same resolved promise
// Late subscriber - triggers immediately
initEvent.on(() => console.log("init done")); // Logs immediately
| Behavior | once: false |
once: true |
|---|---|---|
Multiple fire() |
New promises | First only |
next() after fire |
New pending | Same resolved |
on() after fire |
Normal | Triggers immediately |
Key Points:
read(event)suspends untilfire()is called (like waiting for staleValue)- Works with
race(),all()for waiting on multiple user actions - Each
fire()after the first creates a new promise â triggers reactive updates - Use
equalsoption to dedupe identical payloads - Use
once: truefor one-time events (init, login) - NOT for promise flow control (timeouts, retries) â use
abortable()for those
SelectContext Methods
Works identically in derived(), effect(), useSelector(), rx(). Learn once, use everywhere.
| Method | Signature | Behavior |
|---|---|---|
read() |
read(atom$) |
Read + track dependency |
ready() |
ready(atom$) or ready(array) |
Wait for non-null (suspends) |
from() |
from(pool, params) |
Get ScopedAtom from pool |
track() |
track(atom$) |
Track without reading |
untrack() |
untrack(atom$) or fn |
Read/exec without tracking |
safe() |
safe(() => expr) |
Catch errors, preserve Suspense |
all() |
all([a$, b$]) |
Wait for all (Promise.all) |
any() |
any({ a: a$, b: b$ }) |
First ready (discriminated union) |
race() |
race({ a: a$, b: b$ }) |
First settled (discriminated union) |
settled() |
settled([a$, b$]) |
All results (Promise.allSettled) |
state() |
state(atom$) |
Get state without throwing |
and() |
and([cond1, cond2]) |
Logical AND, short-circuit |
or() |
or([cond1, cond2]) |
Logical OR, short-circuit |
// Same pattern works everywhere
const pattern = ({ read, all, safe }) => {
const [user, posts] = all([user$, posts$]);
const [err, parsed] = safe(() => JSON.parse(read(config$)));
return { user, posts, config: err ? null : parsed };
};
const combined$ = derived(pattern);
const data = useSelector(pattern);
effect(pattern);
{
rx(pattern);
}
race()/any() Discriminated Union
race() and any() return discriminated unions â checking key narrows value type:
const result = race({ num: numAtom$, str: strAtom$ });
if (result.key === "num") {
result.value; // narrowed to number
} else {
result.value; // narrowed to string
}
// Tuple destructuring also works
const [winner, value] = result;
ready() with Async Utilities
Use ready() to ensure values from all(), race(), or any() are non-null:
// ready() + all() â suspend if any value is null/undefined
const [user, posts] = ready(all([user$, posts$]));
// ready() + race() â suspend if winning value is null, preserves narrowing
const result = ready(race({ cache: cache$, api: api$ }));
if (result.key === "cache") {
result.value; // narrowed to cache type (non-null)
}
read() vs ready() vs state()
| Method | On null/undefined | On loading | Use Case |
|---|---|---|---|
read() |
Returns null | Throws Promise | Always need value |
ready() |
Suspends | Throws Promise | Wait for data |
state() |
Returns state obj | Returns state | Manual loading/error |
Key Rules
- MUST use define() for all state/logic. Global classes OK, variables MUST be in
define(). - MUST use batch() for multiple atom updates.
- MUST group useSelector calls into single selector.
- useAction deps: pass atoms, use .get() inside for auto re-dispatch.
- NEVER try/catch with read() â breaks Suspense. Use
safe(). - MUST co-locate mutations in store that owns the atom.
- MUST export readonly atoms via
readonly({ atom$ }). - SelectContext is sync only â NEVER use in setTimeout/Promise.then.
- Services vs Stores â Services are stateless, Stores have atoms.
- NEVER import service factories â use
define(), invoke with(). - Single effect, single workflow â split multiple workflows.
- MUST define meta.key for debugging:
{ meta: { key: "store.name" } }. - MUST use .override() for hooks, never assign
.currentdirectly. - MUST use useStable() â NEVER use React’s useCallback.
- Use pool for parameterized state instead of manual Maps.
meta.key (REQUIRED)
// â
DO: Define meta.key
const user$ = atom<User | null>(null, { meta: { key: "auth.user" } });
const isAuth$ = derived(({ read }) => !!read(user$), { meta: { key: "auth.isAuthenticated" } });
effect(({ read }) => { ... }, { meta: { key: "auth.persistSession" } });
const userPool = pool((id: string) => fetchUser(id), { gcTime: 60_000, meta: { key: "users" } });
// â DON'T: Skip meta.key
const user$ = atom<User | null>(null);
useSelector Grouping
// â
DO: Single useSelector
const { user, posts, settings } = useSelector(({ read }) => ({
user: read(user$),
posts: read(posts$),
settings: read(settings$),
}));
// â DON'T: Multiple calls
const user = useSelector(user$);
const posts = useSelector(posts$);
useAction with Atoms
// â
DO: Pass atoms to deps, use .get() inside
const load = useAction(async () => atom1$.get() + (await atom2$.get()), {
deps: [atom1$, atom2$],
lazy: false,
});
// â DON'T: useSelector values in deps
const { v1, v2 } = useSelector(({ read }) => ({
v1: read(atom1$),
v2: read(atom2$),
}));
const load = useAction(async () => v1 + v2, { deps: [v1, v2], lazy: false });
batch() for Multiple Updates
// â
DO: Batch
batch(() => {
user$.set(newUser);
settings$.set(newSettings);
lastUpdated$.set(Date.now());
});
// â DON'T: Separate updates
user$.set(newUser);
settings$.set(newSettings);
useStable() (REQUIRED)
MUST use useStable() instead of React’s useCallback/useMemo.
// â FORBIDDEN
const handleSubmit = useCallback(
() => auth.register(username),
[auth, username]
);
// â
REQUIRED
const stable = useStable({
onSubmit: () => auth.register(username),
onLogin: () => auth.login(),
config: { timeout: 5000, retries: 3 },
columns: [{ key: "name", label: "Name" }],
});
pool() for Parameterized State
// â
DO: Use pool
const userPool = pool((id: string) => fetchUser(id), {
gcTime: 60_000,
meta: { key: "users" },
});
userPool.get("user-1");
userPool.set("user-1", newUser);
// In reactive context use from()
const userPosts$ = derived(({ read, from }) => {
const user$ = from(userPool, "user-1");
return read(user$).posts;
});
// â DON'T: Manual Map
const userCache = new Map<string, MutableAtom<User>>();
and()/or() for Boolean Logic
// â
DO: Use and()/or()
const canEdit$ = derived(({ and }) => and([isLoggedIn$, hasPermission$]));
const hasData$ = derived(({ or }) => or([cacheData$, apiData$]));
// Lazy evaluation
const canDelete$ = derived(({ and }) =>
and([
isLoggedIn$,
() => hasDeletePermission$, // Only evaluated if logged in
])
);
// â DON'T: Manual logic
const canEdit$ = derived(
({ read }) => read(isLoggedIn$) && read(hasPermission$)
);
untrack() for Non-Reactive Reads
// â
DO: Use untrack() when you need to read without re-computing
const combined$ = derived(({ read, untrack }) => {
const count = read(count$); // Tracks count$ - re-computes on change
const config = untrack(config$); // Does NOT track - no re-compute on change
return count * config.multiplier;
});
// Also works with functions for multiple reads
const snapshot$ = derived(({ read, untrack }) => {
const liveData = read(liveData$); // Tracked
const snapshot = untrack(() => {
// None of these are tracked
return { a: read(a$), b: read(b$), c: read(c$) };
});
return { liveData, snapshot };
});
define() for Services and Stores
// â
STORE (has atoms)
export const counterStore = define(() => {
const count$ = atom(0, { meta: { key: "counter.count" } });
return {
...readonly({ count$ }),
increment: () => count$.set((x) => x + 1),
};
});
// â
SERVICE (stateless)
export const storageService = define(
(): StorageService => ({
get: (key) => localStorage.getItem(key),
set: (key, val) => localStorage.setItem(key, val),
})
);
// â FORBIDDEN: Factory pattern
import { getAuthService } from "@/services/auth";
const auth = getAuthService(); // WRONG
// â
REQUIRED: Module invocation
import { authService } from "@/services/auth.service";
const auth = authService(); // Correct
Hooks (.override() REQUIRED)
// â FORBIDDEN: Direct assignment
onCreateHook.current = (info) => { ... };
// â
REQUIRED: Use .override()
onCreateHook.override((prev) => (info) => {
prev?.(info);
console.log(`Created ${info.type}: ${info.key}`);
});
onErrorHook.override((prev) => (info) => {
prev?.(info);
Sentry.captureException(info.error);
});
Finding Things
| To Find | Search Pattern |
|---|---|
| Atom definitions | atom< or atom( |
| Derived atoms | derived(( |
| Effects | effect(( |
| Pools | pool(( |
| Stores | define(() => in *.store.ts |
| Services | define(() => in *.service.ts |
| Atom usages | read(, ready(, all([, any({, race({, settled([ |
| Non-reactive reads | untrack( |
| Pool usages | from(poolName, |
| Mutations | Find store owner, check return statement |
| Hook setup | onCreateHook.override, onErrorHook.override |
Debugging “Why doesn’t X update?”
- Find atom being set â search
.set( - Find subscribers â search
read(atomName$),ready(atomName$) - Check derived is subscribed â used in
useSelector? - Check effect cleanup â doesn’t prevent re-run?
- For pools â check if entry was GC’d, verify
from()usage
Common Issues
| Symptom | Likely Cause | Fix |
|---|---|---|
| Derived never updates | No active subscription | Use useSelector in component |
| Effect runs infinitely | Setting atom it reads | Use select() for non-reactive |
| ready() never resolves | Value never becomes non-null | Check data flow |
| Stale closure | Reading atom in callback | Use .get() in callbacks |
| Suspense not working | try/catch around read() | Use safe() instead |
| Hook not firing | Direct .current assign |
Use .override() instead |
| Missing hook calls | Hook chain broken | Always call prev?.(info) |
| Pool entry missing | GC’d before access | Increase gcTime |
| ScopedAtom error | Used outside context | Only use from() inside derived |
| Too many re-computes | Tracking unnecessary deps | Use untrack() for config |
| DevTools missing atoms | Atoms created before hook | Use bootstrap pattern (see above) |
Naming Conventions
| Type | Variable | File | Contains |
|---|---|---|---|
| Service | authService |
auth.service.ts |
Pure functions only |
| Store | authStore |
auth.store.ts |
Atoms, derived, effects |
| Type | Suffix | Example |
|---|---|---|
| Atom (sync or async) | $ |
count$, user$, products$ |
| Derived | $ |
doubled$, userName$ |
| Pool | Pool |
userPool, productPool |
| Service | Service |
authService (NO atoms) |
| Store | Store |
authStore (HAS atoms) |
| Actions | verb-led | navigateTo, invalidate |
Why no Async$? Atomirx abstracts async/sync â you don’t care in SelectContext (Suspense handles it).
File Structure
src/
âââ services/ # Stateless
â âââ auth/
â â âââ auth.service.ts
â âââ crypto/
â âââ crypto.service.ts
âââ stores/ # Stateful
âââ auth.store.ts
âââ todos.store.ts
âââ sync.store.ts