This is the full developer documentation for Reatom # Reatom full framework documentation summary > A short overview of all Reatom features # Reatom full framework documentation summary [Section titled “Reatom full framework documentation summary”](#reatom-full-framework-documentation-summary) This documentation for `@reatom/core@1001` package and some ecosystem around it. ## Goal and fit [Section titled “Goal and fit”](#goal-and-fit) * From small widgets to complex SPAs, one universal model. * Portable state and logic across frameworks and runtimes. * Simple testing and mocking with explicit context tools. * Isomorphic and SSR-friendly with predictable async control. * Composable primitives, minimal API surface, high leverage extensions. This summary is intentionally **compact**. The full handbook and reference cover deeper API details, recipes, and adapters in [site](https://v1001.reatom.dev) `/docs/start/*`, `/docs/handbook/*`, and `/docs/reference/*`. ## Core primitives and mental model [Section titled “Core primitives and mental model”](#core-primitives-and-mental-model) Reatom build on top of main single main primitive - “atom”, that manage **immutable** state. Other primitives inherits atom: * **computed**: lazy derived state with dependency tracking * **effect**: computed that auto-subscribes for side effects * **action**: callable event, also observable * **extend**: attach capabilities, methods, or middleware ### Minimal core example [Section titled “Minimal core example”](#minimal-core-example) ```ts import { atom, computed, action, effect, wrap } from '@reatom/core' // define simple changeable state const list = atom([], 'list') // put the atom name in the second argument for better debugging // define action for imperative side effects or complex mappings const fetchList = action(async (filters: { page: number }) => { return await wrap(api.getList(filters)) }, 'list.fetch') // note how we chain relative atoms and actions names // extend atom with actions or just methods const page = atom(0, 'list.page').extend( (target /* <-- target is the extendable atom */) => ({ reset() { // update atom with "set" method target.set(0) }, prev() { // update atom with current state mapping with callback in "set" target.set((value) => Math.max(0, value - 1)) }, next() { target.set((value) => value + 1) }, // assign other relative atoms if needed isPrevAvailable: computed( () => target() > 0, `${target.name}.isPrevAvailable`, ), isNextAvailable: computed( () => target() < list().length - 1, `${target.name}.isNextAvailable`, ), }), ) // Run effect to fetch list when page changes effect(() => { const filters = { page: page() } fetchList(filters) }, 'list.effect') ``` The code bellow shows Reatom abilities - it simple and clean. But this example has some bad practices: * The page atom bind methods instead of actions. It is not critical, but recommended to use actions any data transformations and state updates. > Important: do not create “identity” actions that just forward data to atoms. Direct **atom.set** is preferred and still keeps nice logging and debugging via async context. * Manual data fetching / getting / querying is **antipattern** in Reatom. It is much better for idempotent operations, even with async data, use `computed`. ```ts const list = computed(async () => { const filters = { page: page() } return await wrap(api.getList(filters)) }, 'list') ``` It’s cleaner and more efficient, as the computed subscribes and refetch the list only when have a subscription. But how to get the result state from the promise and track loading and error states? Reatom provides **withAsyncData** extension for this. ### extend example [Section titled “extend example”](#extend-example) ```ts import { atom, computed, withAsyncData } from '@reatom/core' const page = atom(1, 'list.page') const list = computed(async () => { const filters = { page: page() } return await wrap(api.getList(filters)) }, 'list').extend(withAsyncData({ initState: [] })) ``` Now we have extra atoms and actions to manage the list resource: * **list.data()**: the fetched list data * **list.ready()**: false by default and when the list is loading, true when the list is loaded * **list.error()**: the error if the list fetching failed * **list.retry()**: retry the list fetching (computeds can retry without `cacheParams`) * **list.reset()**: reset the list fetch and data to the initial state > you can use `list.data.reset` separately to reset the data only * **list.status()**: union of loading / error / data states — **opt-in**, available only when extended with `{ status: true }`; otherwise use `.ready()` / `.error()` Also withAsyncData used `withAbort` under the hood, that prevent race conditions. **Important**: computed + withAsyncData is the main recommended way to fetch data with Reatom. > **Feature agent default**: when adding async read/query data for a feature, component, widget, page, or route-adjacent model, start with `computed(async () => ...)` extended by `withAsyncData()`. Do not begin with `effect`, `ref`, or imperative mount-time fetch code unless you have a specific reason. Use `action(...).extend(withAsync())` for mutations / commands instead. `withAsyncData` accepts partial parameters: * `initState` - undefined by default * `mapPayload` - function to transform the payload into the data state, “identity” by default * other options from `withAsync` `withAsyncData` is superset of `withAsync` (+ `withAbort`), that used for async operations in general. ## withAsync [Section titled “withAsync”](#withasync) The base extension for async mutations and side effects. Accepted options: * `parseError` - function to transform the error into a specific error type * `emptyError` - the initial error state * `resetError` - when to reset the error state * `status` - whether to enable the `status` atom (false by default for performance reasons) * `cacheParams` - whether to enable caching of the last called parameters (false by default to prevent mem leaks), used by `retry` action ```ts import { action, withAsync, wrap } from '@reatom/core' const submit = action(async (payload: MyForm) => { const response = await wrap( fetch('/api/contact', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload), }), ) if (!response.ok) { throw new Error(`Failed to submit: ${response.statusText}`) } }, 'myForm.submit').extend(withAsync()) ``` Key points * **submit.error()** - the same base atom * **submit.ready()** true by default for withAsync * **submit.status()** - **opt-in**, requires `withAsync({ status: true })`; otherwise use `.ready()` / `.error()` * **submit.retry()** - **opt-in for actions**, requires `withAsync({ cacheParams: true })`; without it `retry` throws at call time. Computeds (`withAsyncData`) can retry without this option. * **submit.onFulfill**, **submit.onReject**, **submit.onSettle** - additional actions for precise logging and tracking, that can be “hooked” with `withCallHook` for additional logic (available in `withAsyncData` too) * **withAsync** does not add abort by default, add **withAbort** if needed > Note: `.status()` is opt-in via `{ status: true }`; action `.retry()` is opt-in for actions via `{ cacheParams: true }`. Both default to `false` for performance / memory reasons. See the reatom-review skill `SKILL.md` “common rewrites” section for the full rule. ## **wrap** rules [Section titled “wrap rules”](#wrap-rules) **wrap** preserves async context for actions, effects, and atom updates. It is important to use wrap everywhere, even if it not necessary and can’t brake something, it increase logs tracing and debugging capabilities. Rules of thumb * Use **wrap** on every async boundary that touches atoms or actions. * Use **wrap** for promise results and callbacks after await or in then. * Do not chain after **wrap**. Wrap each step. Bad * `await wrap(fetch(url)).then((res) => res.json())` * `fetch(url).then((res) => !res.ok && error.set(res.statusText))` * `addEventListener('click', () => doSome())` * `withCallHook(wrap(() => doSome()))` - bad, do not wrap callbacks inside reatom methods and hooks Good * `await wrap(fetch(url).then((res) => res.json()))` * `fetch(url).then(wrap((res) => !res.ok && error.set(res.statusText)))` * `addEventListener('click', wrap(() => doSome()))`, or even better `onEvent(button, 'click', () => doSome())` * `withCallHook(() => doSome())` ## Primitives quick usage [Section titled “Primitives quick usage”](#primitives-quick-usage) A nice helpers to manage typical data structures and values. ```ts import { reatomBoolean, reatomEnum } from '@reatom/core' // Atom with boolean state and handful actions const isModalOpen = reatomBoolean(false, 'isModalOpen') isModalOpen.setTrue() isModalOpen.setFalse() isModalOpen.toggle() // Atom with powerful type inference, useful for replacing native enums const priority = reatomEnum(['low', 'medium', 'high'], 'priority') priority() // 'low' | 'medium' | 'high' priority.enum // { low: 'low', medium: 'medium', high: 'high' } // actions priority.reset() priority.setLow() priority.setMedium() priority.setHigh() ``` Notes * **reatomBoolean** adds **setTrue**, **setFalse**, and **toggle** to keep updates semantic. * **reatomEnum** is perfect for literal list union inference in TypeScript. Good practice * Always name **atoms**, **actions**, and **computed** values for tracing and logging. * Use **action** for complex flows and side effects, **atom.set** for local updates. * Avoid one-line actions that only forward data to atoms. Direct **atom.set** is preferred and still keeps a clear cause via async context. * Prefer **computed** for derived values, **effect** for side effects. Tricky parts * **computed** is lazy: it recalculates only when it is connected. * **effect** tracks dependencies and auto-clean on abort or unmount. ## Atomization [Section titled “Atomization”](#atomization) Atomization means: keep immutable structure as plain data, but lift mutable fields into atoms. Rule of thumb * Mutable properties -> atoms. * Readonly properties -> primitives. Simple example ```ts import { atom, type Atom } from '@reatom/core' type UserDto = { id: string; name: string } type UserModel = { id: string; name: Atom } const user = atom(null, 'user').extend((target) => ({ fromDto(dto: UserDto) { const name = atom(dto.name, `user.name`).extend( withChangeHook((name) => api.updateUserName(dto.id, name)), ) return user.set({ id: dto.id, name }) }, })) // after fetch: // user.fromDto(dto) // later in UI or actions: user()?.name.set('New name') ``` Showcase: list updates without full array recreation ```ts import { action, atom } from '@reatom/core' const users = atom>([], 'users').extend((target) => ({ fromDto(dto: Array) { return target.set( dto.map((user) => ({ id: user.id, name: atom(user.name, `users#${user.id}.name`), // note, we can "atomize" action too! remove: action(() => { target.set((state) => state.filter((u) => u.id !== user.id)) api.deleteUser(user.id) }, `users#${user.id}.remove`), })), ) }, })) ``` This pattern avoids O(n) immutable name changes for each field edit and keeps updates focused on exactly the changed part. This data and actions modelling helps to archive the best part of OOP principles without the complexity of classes and so on. **Bad pattern**: normalize backend data, create separate additional list of elements states (“selected” / “checked” and so on). **Good pattern**: atomize backend data, expand each element with additional atoms for local states (“selected” / “checked” and so on). **Atomization is a main pattern with Reatom**, use it actively for dynamic editable structures, create factories for complex data structures and actions, nest and compose them for complex features. Some naming tips: * use “reatomSome” / “reatomOther” as a shortcut to “createSomeAtom” / “createOtherAction”, “reatomMyForm” instead of “createMyForm” * duplicate the depth of the structure in the name, like “users.paging.current”, use `#${ID}` pattern for dynamically created atoms and actions, like `goods.list#${id}.addToCart`. * Put the parent name to the factory to support proper name nesting, like `reatomUser(userDto, 'users' + userDto.id)` ## Lifecycle and extension hooks [Section titled “Lifecycle and extension hooks”](#lifecycle-and-extension-hooks) ### **withConnectHook** [Section titled “withConnectHook”](#withconnecthook) Runs a callback in “effect” phase when an atom gets its first subscriber, and auto-cleans on disconnect. Use **withConnectHook** to lazy-start background work when data is actually needed. Useful cases: * Start polling only while a screen is mounted or data is subscribed. * Attach and detach external listeners, websockets, or subscriptions. Features: * Run `effect` / `onEvent` inside, they will be aborted on disconnect * Use `wrap` inside, it will be aborted on disconnect * use `abortVar.subscribe(cb)` to subscribe for disconnect, or just return the cleanup callback * `withDisconnectHook(cb)` is a shortcut to `withConnectHook(() => () => cb())` Tricky: * **withConnectHook** fires only on the first subscriber. Example: ```ts import { computed, withAsyncData, withConnectHook } from '@reatom/core' const data = computed(async () => { /* */ }, 'data').extend( withAsyncData(), // polling example withConnectHook(async (target) => { while (true) { await wrap(sleep(1000)) // will be aborted on disconnect target.retry() } }), ) ``` ### **withChangeHook** [Section titled “withChangeHook”](#withchangehook) Runs a callback in “hooks” phase on every state change. Good for stable cross-module wiring, not for dynamic factories. Useful cases * Synchronize the atom state to outer resource / consumer. Tricky * Do not use for atoms synchronization, use “computed” / “withComputed” instead * Use **effect** with **ifChanged** for dynamic contexts. * Fires only on successful updates. If **set** throws (validation, parse, etc.), the hook is not called — use **withErrorHook** instead. ### **withCallHook** [Section titled “withCallHook”](#withcallhook) Runs a callback in “hooks” phase on every action call. Same as **withChangeHook**, but for actions with good params and payload inference. ### **withInit** and **isInit** [Section titled “withInit and isInit”](#withinit-and-isinit) Attach dynamic initial state after creation and detect init phase. ```ts import { atom } from '@reatom/core' // No need to use withInit for regular atoms, just put the state creation callback, instead of init state const date = atom(() => new Date(), 'date')) ``` ```ts import { reatomSet, withInit } from '@reatom/core' // Use withInit to attach lazy initial state to an existing atom const someSet = reatomSet(new Set(), 'someSet').extend( withInit((state) => { const snapshot = localStorage.getItem('someSet') return snapshot ? new Set(JSON.parse(snapshot)) : state }), ) // btw, it is better to use withLocalStorage for the store sync ``` `isInit()` useful in computed or change hook. ### **withComputed** [Section titled “withComputed”](#withcomputed) Adds writable computed behavior to a changeable atom: it derives next state from reactive reads, but still lets direct writes pass through the same state. ```ts import { atom, withComputed } from '@reatom/core' type Tab = { id: string } const tabs = atom>([], 'tabs') const currentTab = atom(null, 'currentTab').extend( // focus on the last tab, when the atom initialized or the tabs list changed withComputed((state) => tabs().at(-1) ?? state), ) ``` ```ts import { atom, withComputed } from '@reatom/core' const search = atom('', 'search') const page = atom(1, 'page').extend( withComputed(() => { search() // do not use the search state, but drop the page state on search change return 1 }), ) ``` ## Event sampling and orchestration [Section titled “Event sampling and orchestration”](#event-sampling-and-orchestration) Reatom treats actions as reactive events. Combined with `take`, `onEvent`, `race`, and `abortVar`, you write procedural async flows that read state, await events, and handle concurrency — with automatic abort and cleanup. ### **take** [Section titled “take”](#take) Awaits the next atom update or action call inside an async action/effect. Resolves with the new value (atom) or payload (action). * `await wrap(take(someAtom))` — next state change * `await wrap(take(someAction))` — next call payload * Second arg is a filter: resolves only when it returns truthy. `throwAbort()` inside the filter cancels the wait if the action is aborted. ```ts if (!formIsValid()) { await wrap(take(formIsValid, (valid) => valid || throwAbort())) } await wrap(fetch('/api/submit', { method: 'POST' })) ``` ### **onEvent** [Section titled “onEvent”](#onevent) Bridges DOM/external events into Reatom’s abort-aware context. Listeners auto-clean on abort or disconnect. A better version of `addEventListener`! * `onEvent(target, type, cb)` — subscribe, returns unsubscribe * `onEvent(target, type)` — returns a promise, resolves on next event ```ts const webhookPromise = onEvent(paymentEvents, 'payment.completed') await wrap(fetch('/api/charge', { method: 'POST', body })) const confirmation = await wrap(webhookPromise) ``` ### **race** and **abortVar.createAndRun** [Section titled “race and abortVar.createAndRun”](#race-and-abortvarcreateandrun) `abortVar.createAndRun(fn, ...args)` — runs `fn` and returns a `ControlledPromise` with an attached `AbortController`. `race(...controlledPromises)` — resolves with the first to settle, aborts all others with reason `"race"`. All code after `wrap` in losing functions never executes. ```ts const a = abortVar.createAndRun(translateGoogle, text, lang) const b = abortVar.createAndRun(translateDeepL, text, lang) const result = await wrap(race(a, b)) ``` ### **withAbort** strategies [Section titled “withAbort strategies”](#withabort-strategies) * `withAbort()` / `withAbort('last-in-win')` — default: aborts previous call when a new one starts (debounce-like) * `withAbort('first-in-win')` — ignores new calls while previous is running (throttle-like) * `withAbort('manual')` — no auto-abort; call `action.abort()` yourself (polling, long-running) * `withAbort('finally')` — aborts all child operations when the action completes, including fire-and-forget ones > **Debounce without debounce:** Reatom replaces traditional `debounce(fn, ms)` with a procedural pattern — put `await wrap(sleep(ms))` before the work inside an action with `withAbort()`. Each new call aborts the sleeping previous one, giving the same delay-then-execute behavior but with natural control flow: conditional delays, immediate value extraction, and full debuggability. See the [Sampling handbook](/docs/handbook/sampling) for a side-by-side comparison. > **Note:** Abort errors (e.g. from route loaders on navigation away, or `withAbort` when cancelling) may appear as unhandled rejections in the console. This is not a bug in Reatom — it usually means an async/promise somewhere in the chain is not caught. Sometimes these can be safely ignored (e.g. aborted fetches when navigating away). ### **framePromise** [Section titled “framePromise”](#framepromise) Returns a promise that resolves/rejects with the current action or atom frame’s final result. Attach `.catch` / `.finally` at the top of the body instead of wrapping everything in try-catch. An alternative of `using` in some cases. ```ts const processOrder = action(async (orderId: string) => { framePromise().catch((error) => showErrorNotification(error)) const order = await wrap(fetchOrder(orderId)) await wrap(chargeCustomer(order)) return order }, 'processOrder') ``` ### **ifChanged** and **getCalls** [Section titled “ifChanged and getCalls”](#ifchanged-and-getcalls) Use inside **computed** or **effect** to react only to actual changes or new calls. * `ifChanged(atom, cb)` — runs `cb` only when atom value changed since last run * `getCalls(action)` — returns calls from the current batch (not a history store) ### Combined example [Section titled “Combined example”](#combined-example) ```ts import { action, atom, effect, getCalls, ifChanged, onEvent, take, wrap, } from '@reatom/core' type CheckoutRequest = { orderId: string; requestedAt: number } const checkoutRequested = action((orderId: string): CheckoutRequest => { return { orderId, requestedAt: Date.now() } }, 'checkout.requested') const confirmButton = atom(null, 'confirmButton') const lastOrderId = atom('', 'lastOrderId') const checkoutFlow = action(async () => { const request = await wrap(take(checkoutRequested)) const response = await wrap(fetch(`/api/orders/${request.orderId}/pay`)) const payload: { receiptId: string } = await wrap(response.json()) const element = confirmButton() if (element) { await wrap(onEvent(element, 'click')) } lastOrderId.set(payload.receiptId) return payload.receiptId }, 'checkout.flow') effect(() => { ifChanged(lastOrderId, (nextId) => { if (nextId) console.log({ lastOrderId: nextId }) }) }, 'checkout.lastOrderId') effect(() => { getCalls(checkoutRequested).forEach(({ payload }) => { console.log({ checkoutRequested: payload.orderId }) }) }, 'checkout.requested.calls') ``` Tricky * **take** and **onEvent** return promises — always `await wrap(...)` them inside async actions or effects. * **getCalls** only returns calls in the current batch, it is not a history store. * **ifChanged** only inside **effect** or **computed** with a few dependencies. * **race** requires `ControlledPromise` from `abortVar.createAndRun`, not plain promises. ## Memoization: **memo** and **memoKey** [Section titled “Memoization: memo and memoKey”](#memoization-memo-and-memokey) **memo** creates internal computed state inside a **computed** or **action**, scoped to the host atom. **memoKey** stores arbitrary per-atom values by key. ```ts import { computed, memo, memoKey } from '@reatom/core' type Order = { total: number } type ApiClient = { baseUrl: string } const orders = computed((): Order[] => [], 'orders') const stats = computed(() => { const items = orders() const total = memo(() => items.reduce((sum, item) => sum + item.total, 0)) return { total } }, 'orders.stats') const client = computed(() => { return memoKey('client', (): ApiClient => ({ baseUrl: '/api' })) }, 'api.client') ``` Tricky * Use **memo** only inside **effect** or **computed** with a few dependencies. * **memo** uses the first callback only. Use stable closures. * Use a custom key when the same callback body is used multiple times. ## Forms: base usage and reactive validation [Section titled “Forms: base usage and reactive validation”](#forms-base-usage-and-reactive-validation) Forms are built from fields, field sets, and a **submit** action. Key primitives * **reatomField**: single field with state, value, focus, validation, disabled * **reatomFieldSet**: grouped fields with aggregate focus and validation * **reatomForm**: field set plus submit, schema validation, and form options ### Base form with schema and submit [Section titled “Base form with schema and submit”](#base-form-with-schema-and-submit) ```ts import { reatomField, reatomForm, wrap } from '@reatom/core' import { z } from 'zod' type AuthResult = { token: string } const registerForm = reatomForm( { email: '', password: '', confirmPassword: reatomField('', { validate({ state }) { if (state.length > 0 && state === registerForm.fields.password()) { return undefined } return 'Passwords do not match' }, }), handle: reatomField('', { async validate({ state }) { // this function executes in abortable context await wrap(sleep(300)) // debounce const response = await wrap(fetch(`/users/${state}`)) if (response.status === 200) { return 'Handle already taken' } if (response.status === 404) { return undefined } return 'Error checking handle' }, }), }, { name: 'registerForm', validateOnBlur: true, schema: z.object({ email: z.string().email(), password: z.string().min(8), confirmPassword: z.string().min(1), }), onSubmit: async (values): Promise => { const response = await wrap( fetch('/api/register', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(values), }), ) const payload: AuthResult = await wrap(response.json()) return payload }, }, ) ``` Reactive validation note * The validate callback tracks atoms it reads after the first trigger. * This enables dependent validation without manual wiring. Submit notes * **submit** is async and expects errors to be thrown. * **submit.error()** holds the latest error. * **form.reset()** cancels submit and resets submitted state. ### React binding [Section titled “React binding”](#react-binding) ```tsx import { reatomComponent, bindField } from '@reatom/react' import { registerForm } from './registerForm' export const RegisterForm = reatomComponent(() => { const { fields, submit, validation } = registerForm const ready = submit.ready() const error = submit.error() return (
{ event.preventDefault() submit() }} > {validation().errors.length > 0 &&
Fix validation errors
} {error &&
{error.message}
}
) }, 'RegisterForm') ``` Tricky * Validation errors for schema are distributed by path. * Triggered state for field sets is true only when all fields were triggered. ## Routing [Section titled “Routing”](#routing) **reatomRoute** creates route atoms: reactive state that matches URL patterns, extracts typed params, loads data, and composes into layouts. Everything is auto-cancellable and reactive. ### Routes, nesting, search, validation [Section titled “Routes, nesting, search, validation”](#routes-nesting-search-validation) ```ts import { reatomRoute, urlAtom, wrap } from '@reatom/core' import { z } from 'zod' // simple path — returns {} when matched, null when not const homeRoute = reatomRoute('') // path with params — returns { userId: string } or null const userRoute = reatomRoute('users/:userId') // optional param const postRoute = reatomRoute('posts/:postId?') // reading state userRoute() // { userId: '123' } | null userRoute.exact() // true only when URL is exactly /users/123 userRoute.match() // true when URL starts with /users/123 // navigation userRoute.go({ userId: '123' }) // push to /users/123 userRoute.go({ userId: '123' }, true) // replace history entry userRoute.path({ userId: '123' }) // build URL string without navigating // urlAtom intercepts clicks for SPA navigation by default, use .path() in href // nested routes — chain .reatomRoute(), paths auto-compose, params inherit const dashboardRoute = reatomRoute('dashboard') const usersRoute = dashboardRoute.reatomRoute('users') const userEditRoute = usersRoute.reatomRoute(':userId').reatomRoute('edit') // userEditRoute.go({ userId: '123' }) → /dashboard/users/123/edit // search params with zod — query string validation and transform const goodsRoute = reatomRoute({ path: 'goods/:category', search: z.object({ sort: z.enum(['asc', 'desc']).optional() }), }) // goodsRoute.go({ category: 'tech', sort: 'asc' }) → /goods/tech?sort=asc // goodsRoute() → { category: 'tech', sort: 'asc' } // search-only routes (no path) preserve current pathname — great for global modals const dialogRoute = reatomRoute({ search: z.object({ dialog: z.enum(['login', 'signup']).optional() }), }) // user at /profile/123 → dialogRoute.go({ dialog: 'login' }) → /profile/123?dialog=login // nested search-only routes navigate to parent path if user is elsewhere // params validation and transform with zod (or any Standard Schema) const issueRoute = reatomRoute({ path: 'issue/:issueId', params: z.object({ issueId: z.string().regex(/^\d+$/).transform(Number) }), }) // issueRoute.go({ issueId: '123' }) → issueRoute() returns { issueId: 123 } (number!) // if validation fails → route returns null // URL params are always strings — use .transform() or z.coerce for type conversion ``` ### Loaders — auto data fetching [Section titled “Loaders — auto data fetching”](#loaders--auto-data-fetching) Route loaders are async computeds with `withAsyncData` built-in. They run when route matches, auto-abort on navigation away. Nested loaders await parents and receive merged params. Effects inside loaders also auto-abort on navigation. Loader API (same as `withAsyncData`): **route.loader.data()**, **.ready()**, **.error()**, **.retry()**, **.status()**. Without explicit loader, `await wrap(route.loader())` returns validated params. ### Render and outlet — component composition [Section titled “Render and outlet — component composition”](#render-and-outlet--component-composition) Routes define `render` for framework-agnostic component composition. `render(self)` receives the route: `self()` for params (non-null inside render), `self.loader` for the loader data. Works with any renderer (tagged templates, JSX, hyperscript). Two kinds of routes: * **Layout routes** (`layout: true`) — render on any match, use `self.outlet()` to wrap child content. Use for shells, sidebars, protection layers. * **Page routes** (default, also called **feature routes**) — render only on exact match. When a child is active, the page steps aside and its content bubbles up to the nearest layout’s `outlet()`. Typical app structure: root layout → optional auth/protection layers (also layout) → page routes. Entire app renders from root: `computed(() => layoutRoute.render())`. ### Protected routes and modal gates [Section titled “Protected routes and modal gates”](#protected-routes-and-modal-gates) Protected routes use `params()` callback returning `null` to block the route and all descendants. Reactive: re-runs when read atoms change — use for auth, roles, feature flags, wizards. Protection routes are layout routes — they forward children via `outlet()`. Stack layers by nesting: layout → auth → admin → feature. Modal gate — route without URL path, `params(arg)` callback controls activation via `.go({ data })` / `.go()` (deactivate). State in memory, no URL pollution. > **Antipattern**: manual `if (!route.match()) return null` checks in components (like a `UsersPage` that reads route state and conditionally renders). Use the `render` option instead — it handles mounting/unmounting and loader state automatically. ### urlAtom and global state [Section titled “urlAtom and global state”](#urlatom-and-global-state) `urlAtom.go('/path')` navigates, `urlAtom()` reads `{ pathname, search, hash }`, `urlAtom.catchLinks(false)` disables SPA link interception, `urlAtom.routes` is a registry of all created routes. `isSomeLoaderPending` tracks global loading state across all route loaders. ### Full SPA example [Section titled “Full SPA example”](#full-spa-example) Setup logging system: ```ts // setup.ts — import this file before others in the repo root! import { connectLogger, log } from '@reatom/core' if (import.meta.env.MODE === 'development') connectLogger() declare global { var LOG: typeof log } globalThis.LOG = log ``` `log` forwards args to `console.log` when `connectLogger()` is active. Helpers: ```ts LOG('debug', payload) // group title: "LOG" LOG.label('fetch payload', response) // group title: "fetch payload" LOG.state('user', data) // logs only when `data` changes for that name ``` Routes: routes.ts ```ts import { computed, reatomRoute, withAsyncData, wrap } from '@reatom/core' import { z } from 'zod' type User = { id: string; name: string; role: string } // layout — no path, always active, renders outlet export const layoutRoute = reatomRoute({ layout: true, render({ outlet }) { return html`
My App
${outlet()}
` }, }) // public login page export const loginRoute = layoutRoute.reatomRoute({ path: 'login', render() { return html`
Login Form
` }, }) // auth state const user = computed(async () => { const token = localStorage.getItem('token') if (!token) return null return await wrap(fetch('/api/me').then((r) => r.json())) }, 'user').extend(withAsyncData()) // protected route — blocks all children when not authenticated export const protectedRoute = layoutRoute.reatomRoute({ layout: true, params() { const userData = user.data() if (!userData) { if (user.ready() && !loginRoute.match()) loginRoute.go() return null } if (loginRoute.match()) dashboardRoute.go() return userData }, render(self) { return self.outlet() }, }) export const dashboardRoute = protectedRoute.reatomRoute({ path: 'dashboard', render() { return html`

Dashboard

` }, }) // users list with search params and loader export const usersRoute = protectedRoute.reatomRoute({ path: 'users', search: z.object({ q: z.string().optional(), page: z.string().regex(/^\d+$/).transform(Number).default('1'), }), async loader({ q, page }) { const response = await wrap( fetch(`/api/users?q=${encodeURIComponent(q ?? '')}&page=${page}`), ) return await wrap(response.json()) }, render(self) { const { isPending, data } = self.status() if (isPending) return html`
Loading users...
` return html`

Users

` }, }) // user detail with validated params and loader export const userRoute = usersRoute.reatomRoute({ path: ':userId', params: z.object({ userId: z.string().regex(/^\d+$/) }), async loader({ userId }) { const response = await wrap(fetch(`/api/users/${userId}`)) return (await wrap(response.json())) as User }, render(self) { const { isFirstPending, data, error } = self.status() // do not show loading for revalidation if (isFirstPending) return html`
Loading user...
` if (error) return html`
Error: ${error.message}
` return html`

${user.name}

${user.role}
` }, }) // modal gate — state in memory, no URL pollution export const confirmModal = protectedRoute.reatomRoute({ params({ message }: { message?: string }) { return message ? { message } : null }, render(self) { return html`${self().message}` }, }) // confirmModal.go({ message: 'Sure?' }) → opens, confirmModal.go() → closes ``` ```ts // App.ts — entire app rendering from root route const App = computed(() => html`${layoutRoute.render()}`) ``` Route loaders are async computed with auto-cancel. The **factory pattern** (creating atoms/forms inside loaders) gives global accessibility with automatic cleanup — best of both local and global state. ## URL sync and persistence helpers [Section titled “URL sync and persistence helpers”](#url-sync-and-persistence-helpers) ### **withSearchParams** for list filters [Section titled “withSearchParams for list filters”](#withsearchparams-for-list-filters) ```ts import { atom, withSearchParams } from '@reatom/core' type Sort = 'popular' | 'new' | 'price' const query = atom('', 'catalog.query').extend(withSearchParams('q')) const page = atom(1, 'catalog.page').extend( withSearchParams('page', { parse: (value) => Number(value ?? '1'), serialize: (value) => (value === 1 ? undefined : String(value)), }), ) const sort = atom('popular', 'catalog.sort').extend( withSearchParams('sort', (value) => value === 'new' || value === 'price' || value === 'popular' ? value : 'popular', ), ) ``` ### **withLocalStorage** for preferences [Section titled “withLocalStorage for preferences”](#withlocalstorage-for-preferences) ```ts import { atom, withLocalStorage } from '@reatom/core' type Theme = 'light' | 'dark' const theme = atom('light', 'theme').extend(withLocalStorage('theme')) ``` ## Suspense notes [Section titled “Suspense notes”](#suspense-notes) Use suspense for global initialization, not for dynamic page data. * **withSuspense** adds **.suspended()** that throws promise for Suspense. * **withSuspenseInit** turns async init atoms into sync after init. * **withSuspenseRetry** retries actions that touch suspended atoms. * Use **preserve** to keep previous data during refresh. * Avoid non-idempotent side effects inside **withSuspenseRetry**. ## Transactions notes [Section titled “Transactions notes”](#transactions-notes) Transactions support optimistic updates with rollback. * **withRollback** on atoms tracks state changes. * **withTransaction** on actions triggers rollback on errors. * **action.rollback()** rolls back only the last call of that action. * **action.stop()** commits the last call and clears rollback queue. * Abort does not trigger rollback. Example ```ts import { action, atom, withAsync, withRollback, withTransaction, wrap, } from '@reatom/core' type Todo = { id: string; title: string } const todos = atom([], 'todos').extend(withRollback()) const saveTodo = action(async (todo: Todo) => { todos.set((items) => [...items, todo]) const response = await wrap( fetch('/api/todos', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(todo), }), ) const result: Todo = await wrap(response.json()) return result }, 'todos.save').extend(withAsync(), withTransaction()) ``` ## SSR and testing [Section titled “SSR and testing”](#ssr-and-testing) * **context.start** creates isolated contexts for SSR requests or tests. * **clearStack** forces explicit **wrap** usage, useful for strict isolation, not recommended by default. * **context.reset** clears the default global context between tests run (if you not using clearStack). ## v3 migration highlights [Section titled “v3 migration highlights”](#v3-migration-highlights) * Implicit context is default since v1000; **ctx** is not used. * **ctx.schedule**(promise) -> **wrap**(promise) * **ctx.spy**(atom) -> **atom**() * **ctx.get**(atom) -> **peek**(atom) * **atom**(callback) -> **computed**(callback) * **atom**(ctx, value) -> **atom.set**(value) * **ctx.spy**(atom, cb) -> **ifChanged**(atom, cb) * **ctx.spy**(action, cb) -> **getCalls**(action).forEach(cb) * **reatomAsync**(cb) -> **action**(cb).extend(**withAsync**()) * **reatomResource**(cb) -> **computed**(cb).extend(**withAsyncData**()) * **reaction** -> **effect** * **atom.onChange**(cb) -> **atom.extend**(**withChangeHook**(cb)) * **onConnect**(atom, cb) -> **atom.extend**(**withConnectHook**(cb)) * **withConcurrency** -> **withAbort** * **onCtxAbort** -> **abortVar.subscribe** ## Other APIs (not detailed here) [Section titled “Other APIs (not detailed here)”](#other-apis-not-detailed-here) This list is intentionally brief. See the full handbook and reference for additional features, recipes, adapters, and edge cases in the docs: . Core * **addGlobalExtension** for global cross-cutting behavior * **withActions** for attaching methods as actions * **withMiddleware** and **withParams** for middleware and parameter transforms * **bind** for lightweight context binding * **context**, **clearStack**, **mock**, **anonymizeNames** for isolation and testing * **isAtom**, **isAction**, **isComputed**, **isConnected**, **named** for introspection Extensions * **withAbort** for abortable actions and computeds * **withErrorHook** for reacting to failed updates and action calls * **withMemo** to stabilize computed outputs * **withDynamicSubscription** to avoid unnecessary connections * **withSuspense** and **withSuspenseRetry** for Suspense integration * **addChangeHook**, **addCallHook**, and **addErrorHook** for dynamic hook wiring * **withDisconnectHook** for explicit disconnect actions Methods * **abortVar** and **variable** for async context variables * **peek** for non-reactive reads * **schedule** and **retry** for controlled reevaluation * **deatomize** to unwrap atoms into plain objects * **reatomLens** and **reatomObservable** for interop patterns * **framePromise** and **getStackTrace** for advanced debugging * **isCausedBy** to guard against self-triggered updates * **retryComputed** to reevaluate a computed atom. Note that computed without dependencies will be never reevaluated without this method. Routing (extras beyond the main Routing section above) * **searchParamsAtom** and **withSearchParams** for standalone URL search state sync * **urlAtom** hooks, link interception config, hash routing * **is404** for unmatched URL detection * **isSomeLoaderPending** for global loading indicator across all route loaders Primitives * **reatomArray**, **reatomBoolean**, **reatomEnum**, **reatomNumber**, **reatomString** * **reatomMap**, **reatomSet**, **reatomRecord**, **reatomLinkedList** Persistence * **reatomPersist** for custom storage adapters * **withLocalStorage** and **withSessionStorage** for web storage * **withIndexedDb** for IndexedDB persistence * **withBroadcastChannel** for cross-tab sync * **withCookie** and **withCookieStore** for cookie-backed state * **createMemStorage** for in-memory persistence in tests Web * **onLineAtom** for network status * **reatomMediaQuery** for media query binding * **reatomWebSocket** for websocket state * **rAF** for requestAnimationFrame scheduling * **fetch** wrapper for consistent context usage Utils * General helpers for equality, abort errors, timers, and typed helpers # Introduction > Introduction to Reatom Welcome to the awesome world of the Reatom library! 🤗 This powerful tool is designed to become your go-to resource for building anything from tiny libraries to full-blown applications. We know the drill: usually, you’d have to keep reinventing the wheel with high-level patterns or depend on external libraries. Both are tough to balance perfectly for interface equality, semantic compatibility, performance, error handling, debugging, logging, test setup, and mocking. To make life easier, we’ve crafted the perfect building blocks (atoms and actions) and a bunch of packages on top of them. These tools tackle the tough stuff so you can focus on being creative. This start guide will walk you through the basic features of Reatom and key ecosystem helpers, such as forms and routing. For more advanced use cases, check out the [guides](/docs/guides/), for a full list of features check out the [reference](/docs/reference/) page. But first of all, check out the [base](/start/base/) guide to get started. # Actions > Reatom actions and code organization Action is a base Reatom primitive that **increases the quality of your code** in many ways: organization and readability, debugability, extensibility. The beauty of Reatom is that you don’t need to use actions for simple updates, like `(value) => myAtom.set(value)`. Actions are useful for complex operations, like data mappings, API calls and other side effects. You can call actions anywhere just like regular functions. You can describe its parameters just like with regular functions. You can type your action function with TypeScript generics as usual. `action` itself is a simple decorator which adds some extra features to your function, but does not limit you in any way. ```ts import { atom, action } from '@reatom/core' export const list = atom([]) const isListLoading = atom(false) const loadList = action(async (page: number) => { isListLoading.set(true) try { const response = await fetch(`/api/list?page=${page}`) const payload = await response.json() list.set(payload) } finally { isListLoading.set(false) } }) loadList(1) // Promise ``` Note that `action` is an optional feature and not required in your code, but it is always nice to use it. ## Naming [Section titled “Naming”](#naming) Most Reatom units accept an optional name for debugging purposes. We highly recommend using it, as it helps to debug the runtime dataflow. ```ts export const list = atom([], 'list') const isListLoading = atom(false, 'isListLoading') const loadList = action(async (page: number) => { // ... }, 'loadList') ``` That’s better! Tip If you use some LLM code assistant it will help you to write related names for you, but we have an eslint plugin to automate it. You can find the plugin and useful logger in [out tooling section](/start/tooling/). ## Extend [Section titled “Extend”](#extend) Under the hood action is a special type of atom; it gives us the ability to reuse many patterns and extensions. In the next chapter, we will get to know extensions more closely, but for now, let’s learn how to better organize our code. `extend` accept a callback with the processed target, which return an object to assign to the target. ```ts import { atom, action } from '@reatom/core' export const list = atom([], 'list').extend((target) => { const isLoading = atom( false, // compute the name from the target `${target.name}.isLoading`, ) const load = action(async (page: number) => { // ... }, `${target.name}.load`) // return things that you want to assign to the current atom return { isLoading, load, } }) ``` Now you can access your states in a clean and readable way: src/component/Paging.tsx ```tsx import React from 'react' import { reatomComponent } from '@reatom/react' import { list } from './model' const Paging = reatomComponent(() => { const [page, setPage] = React.useState(1) React.useEffect(() => { list.load(page) }, [page]) const isLoading = list.isLoading() return ( ) }) const List = reatomComponent(() => (
{list().map(/* ... */)}
)) ``` Awesome, now you can couple relative states with relative components without a props drilling! But this is just the beginning, `.extend` can give us much more! Check out the next section to learn more about it. # Getting started > Learn the base Reatom primitives ## Installation [Section titled “Installation”](#installation) Reatom is a framework agnostic library with various adapters for different frameworks. By default all docs and examples are written for React, but you can reuse each code example with any other framework. ```bash npm install @reatom/core @reatom/react ``` ## Template [Section titled “Template”](#template) For a fast start you can use our template with react.dev and mantine.dev and a set of example features: [![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/reatom/reatom/tree/v1001/examples/react-search) ## Core primitives [Section titled “Core primitives”](#core-primitives) ### Atom [Section titled “Atom”](#atom) Reatom has a lot of advanced features under the hood, but they are hidden by default and you can start just with the atom - base state container. ```typescript import { atom } from '@reatom/core' const counter = atom(0) // Read the atom state console.log(counter()) // Log: 0 // Write a new state to the atom counter.set(1) console.log(counter()) // Log: 1 // Process and update the atom state in a function counter.set((state) => state + 5) console.log(counter()) // Log: 6 ``` ### Computed [Section titled “Computed”](#computed) The most valuable feature of any signal-based library is the ability to create lazy memoized computations. ```typescript import { atom, computed } from '@reatom/core' const counter = atom(0) const isEven = computed(() => counter() % 2 === 0) console.log(isEven()) // Log: true counter.set(1) // Log nothing, the computed has no subscription // Trigger the computation implicitly console.log(isEven()) // Log: false ``` To “activate” a computed you need to subscribe to it. Note, that all reactive computations appear in the next microtask tick, after a dependency change. ```typescript // Now any change of the counter will trigger the computation // and the subscription callback (if the state really changed) isEven.subscribe((state) => console.log(state)) ``` But in most cases you don’t need to subscribe to atoms manually, you probably want to use them in a high-level computed, such as effects or a UI component, let’s dive into it. ### Effects [Section titled “Effects”](#effects) Effects are a way to react to changes in the state. They are similar to computed, but run immediately after creation. Basically it is just `computed(cb).subscribe()`, but with some extra features which we will investigate later. It is much more useful than just `.subscribe` as you can track many atoms in any combinations in one place. ```typescript import { atom, computed, effect } from '@reatom/core' const counter = atom(0) const isEven = computed(() => counter() % 2 === 0) effect(() => { console.log(`${counter()} is ${isEven() ? 'even' : 'odd'}`) }) ``` Typical use case is to run some long-lived processes, such as an API polling, or a timer, which should work independently of a UI. ## Using with framework [Section titled “Using with framework”](#using-with-framework) ```tsx import { atom, computed } from '@reatom/core' import { reatomComponent } from '@reatom/react' const counter = atom(0) const isEven = computed(() => counter() % 2 === 0) const Counter = reatomComponent(() => (

{counter()} is {isEven() ? 'even' : 'odd'}

)) ``` `reatomComponent` is just a special variant of `computed` that perfectly integrates with React. **The coolest thing** about `reatomComponent` is that you can use reactive states (atoms) in any order without the rules of hooks! ## Conclusion [Section titled “Conclusion”](#conclusion) **That’s all!** If you need a small, performant and useful reactive primitive and nothing more, you can stay with what we discovered just now and move to the [tooling](/start/tooling/) section to get nice logging of your app. If you what to dive deeper and learn more Reatom features, go to the [actions](/start/actions/) section. # Extensions > Reatom extensions system Extensions are **powerful add-ons** that enhance your atoms and actions with common functionality. Instead of writing the same patterns over and over, extensions provide ready-made solutions for async operations, persistence, caching, and much more. The beauty of extensions is that they compose perfectly - you can combine multiple extensions on the same atom to get exactly the behavior you need. Let’s rewrite the data loading example from the [actions](/start/actions/) section using extensions, which add async tracking: src/model.ts ```ts import { atom, action, withAsyncData } from '@reatom/core' const fetchList = action(async (page: number) => { const response = await fetch(`/api/data?page=${page}`) return await response.json() }, 'fetchList').extend(withAsyncData({ initState: [] })) fetchList.ready() // `false` during the fetch fetchList.data() // the fetch result fetchList.error() // `Error` or `undefined`, depends on the fetch result // Use it in the same way fetchList(1) // Promise ``` Some extensions can be used only with atoms (like `withMemo`), some only with actions (like `withCallHook`), but many extensions can be used with both! ## withAsyncData [Section titled “withAsyncData”](#withasyncdata) Let’s explore the list loading further. What if we want to add more parameters to the fetching? We could add another argument and pass it through the calling chain, but let’s make it more reliable using the Reatom approach with implicit reactive coupling. src/model.ts ```ts import { atom, computed, withAsyncData } from '@reatom/core' const search = atom('', 'search') const page = atom(1, 'page') const listResource = computed(async () => { const response = await fetch(`/api/data?search=${search()}&page=${page()}`) return await response.json() }, 'listResource').extend(withAsyncData({ initState: [] })) ``` Notice how we reduce the amount of code and make the entire flow more optimal! `listResource` is an async computed that reruns only when `page` or `search` changes and **when the data is needed**. By using a computed, we make the effect lazy, meaning it will run only when `listResource.ready()`, `listResource.data()`, or `listResource.error()` is called and used in a component or effect. ## withSearchParams [Section titled “withSearchParams”](#withsearchparams) Let’s enhance our extension system further. We have a few improvements to make: * Let’s sync the parameters with URL search parameters * Let’s reset the page state when search changes * Let’s add handy actions for pagination src/model.ts ```ts import { atom, withSearchParams, withComputed, isInit, computed, withAsyncData } from '@reatom/core' const search = atom('', 'search').extend(withSearchParams('search')) const page = atom(1, 'page').extend( withSearchParams('page'), withComputed((state) => { search() // subscribe to the search changes // do NOT reset the persisted state on init return isInit() ? state : 1 }), target => ({ next: () => target.set(state => state + 1), prev: () => target.set(state => Math.max(1, state - 1)), }) ) const listResource = computed(async () => { const response = await fetch(`/api/data?search=${search()}&page=${page()}`) return await response.json() }, 'listResource').extend(withAsyncData({ initState: [] })) ``` Perfect! That’s quite comprehensive. In any other framework or library, implementing this seemingly simple logic would be much more complex, but Reatom provides all the utilities you need to solve it elegantly. Let’s examine how to use this loading model in a component. ## Framework bindings [Section titled “Framework bindings”](#framework-bindings) Now let’s connect our reactive model to the UI using `reatomComponent`. This is a regular React component enhanced with computed capabilities - it automatically tracks atom dependencies and triggers re-renders only when subscribed atoms change, ensuring optimal performance. You can call atoms directly as functions and use their actions just like regular functions - no hooks required, no restrictions on conditional logic or loops. At the same time, you can use regular React hooks, accept props, and do anything you would normally do in a React component. src/Results.tsx ```tsx import React from 'react' import { reatomComponent } from '@reatom/react' import { search, page, listResource } from './model' const Filters = reatomComponent(() => (
search.set(e.target.value)} placeholder="Search..." />
{page()}
)) const List = reatomComponent(() => (
{listResource.ready() ||
Loading...
}
    {listResource.data().map((item, index) => (
  • {/* render your item */}
  • ))}
)) ``` ## Conclusion [Section titled “Conclusion”](#conclusion) One of the key features of `withAsyncData` is that it automatically aborts the previous request when a new one is initiated. So when a user types quickly in the search field and triggers multiple requests, only the most recent one will be processed! > You can dive deeper into the rabbit hole of concurrency management in the [async context](/handbook/async-context/) article. However, when you need to *put* / *post* data, you don’t need the autoabort strategy and the result data storing, for this cases you should use `withAsync` extension for your async actions, which only tracks the loading status and possible errors. Reatom ecosystem has a lot of other extensions, try to search the docs! But sometimes, you need a little more, not an extension for one atom or actions, but a factory to build a set of complex models. Check the next section to learn about form management! # Forms > Getting started with forms in Reatom Reatom has a very advanced form management system to handle complex cases in a type-safe and performant way. You can read more about it in the [form handbook section](/handbook/forms/introduction). But in this guide, we’ll introduce only the basics. ## Creating a form [Section titled “Creating a form”](#creating-a-form) loginForm.ts ```ts import { reatomForm } from '@reatom/core' export const loginForm = reatomForm( { username: '', password: '', passwordDouble: '', }, { validate({ password, passwordDouble }) { if (password !== passwordDouble) { return 'Passwords do not match' } }, onSubmit: async ( values /*: { username: string, password: string, passwordDouble: string }*/, ) => { return await api.login(values) }, validateOnBlur: true, name: 'loginForm', }, ) ``` The first argument defines your form structure (`initState`). It doesn’t have to be flat - you can nest fields in logical groups using objects. For each key, define the default value, and Reatom will derive the field type from the primitive value. Each field value can be configured by passing a `reatomField` factory with various options (including individual validation) instead of a primitive value. But for primitive values, Reatom creates a field atom automatically. This is called “atomization” and it gives us many advantages. ## Form structure [Section titled “Form structure”](#form-structure) The form instance itself (`loginForm`) has a `submit` action, of course, and computed validation and focus states. It computes from the individual field atoms, which you can find in the `loginForm.fields` object. ```ts loginForm.fields satisfies { username: FieldAtom password: FieldAtom passwordDouble: FieldAtom } ``` Each field atom includes meta atoms like `validation`, `focus`, and others, which you can use for precise control over the form and each field. ## Framework bindings [Section titled “Framework bindings”](#framework-bindings) LoginForm.tsx ```tsx import { reatomComponent, bindField } from '@reatom/react' import { Button, TextInput, PasswordInput, Stack, Alert } from '@mantine/core' import { loginForm } from './loginForm' export const LoginForm = reatomComponent(() => { const { submit, fields } = loginForm return (
{ e.preventDefault() loginForm.submit() }} >
) }) ``` This is a simple example, but note that since we have each field as separate atoms, we can create a separate component for each of them and it would be highly optimized and flexible. You can check out a live example in [StackBlitz](https://stackblitz.com/github/reatom/reatom/tree/v1001/examples/react-search). For native DOM JSX, see the [JSX reference — Forms](/reference/jsx#forms). Next, you’ll want to learn our routing system in the next page ;) # Routing > Dead simple and powerful Reatom router for your application state. Reatom provides a powerful yet simple way to manage your application’s routes and the state associated with them. This guide will introduce you to the basics, focusing on how routing can help manage data lifecycles, such as for forms. ## Defining a Route [Section titled “Defining a Route”](#defining-a-route) You create routes using the `route` function, a route becomes active when the URL matches its path. Let’s imagine a login page: src/routes.ts ```ts import { reatomRoute } from '@reatom/core' export const loginRoute = reatomRoute({ path: '/login', }) ``` When the user navigates to `/login`, `loginRoute()` will return an empty object `{}` (as there are no parameters in the pattern). If the URL is different, it will return `null`. ## Route Loaders for State Management [Section titled “Route Loaders for State Management”](#route-loaders-for-state-management) A powerful feature is the `loader` option in a route definition. This function executes when the route becomes active and can be used to load data, it uses async data extension, which we was introduced in the [Extensions guide](/start/extensions). The more more cool feature is that you can create state that should only exist while the route is active. We call it a “computed factory” pattern. This is perfect for managing forms, for example. By creating a form inside a route’s loader, you ensure it’s fresh every time the user visits the route and is automatically cleaned up when they navigate away, preventing issues like old data appearing after logout. But still, the state is global, so you can access them from any component. Let’s adapt our `loginForm` example from the Forms guide: src/routes.ts ```ts import { reatomRoute, reatomForm } from '@reatom/core' // import * as api from './api' // Assuming you have an API module export const loginRoute = reatomRoute({ path: '/login', async loader() { // This form is created ONLY when /login is active // and destroyed when navigating away. const loginForm = reatomForm( { username: '', password: '', passwordDouble: '', }, { validate({ password, passwordDouble }) { if (password !== passwordDouble) { return 'Passwords do not match' } }, onSubmit: async (values) => { // return await api.login(values) console.log('Submitting login form:', values) await new Promise((r) => setTimeout(r, 1000)) return { success: true } }, validateOnBlur: true, name: 'loginForm', // for debugging }, ) return { loginForm } }, }) ``` Now, `loginRoute.loader.data()` will contain `{ loginForm }` when the `/login` route is active and the loader has completed. ## Using the Route and Form in a Component [Section titled “Using the Route and Form in a Component”](#using-the-route-and-form-in-a-component) Your React component can then access the form through the route’s loader. src/components/LoginPage.tsx ```tsx import { reatomComponent, bindField } from '@reatom/react' import { Button, TextInput, PasswordInput, Stack, Alert } from '@mantine/core' import { loginRoute } from '../routes' // Assuming routes.ts export const LoginPage = reatomComponent(() => { if (!loginRoute.loader.ready()) return
Loading login page...
const { submit, fields } = loginRoute.loader.data().loginForm return // your form here }, 'LoginPage') ``` When the user navigates away from `/login`, the `loginForm` instance created by the loader is automatically garbage-collected. If they navigate back, a new, fresh instance is created. This elegant pattern is called “Computed Factory” and solves many state lifecycle problems. This approach ensures that your form state is always clean and tied to the relevant view, enhancing predictability and reducing bugs. For more advanced routing scenarios, including nested routes, parameter validation, and global loading states, refer to the [handbook routing section](/handbook/routing). # Tooling > The list of key tools for Reatom ## Logging [Section titled “Logging”](#logging) Reatom has incredible capabilities for debugging and tracing your code. We will publish our devtools soon, but now you can use `connectLogger` for simple (or not!) logging. main.tsx ```tsx import './setup' // import setup file before all other modules! import ReactDOM from 'react-dom/client' import { App } from './app' const root = ReactDOM.createRoot(document.getElementById('root')!) root.render() ``` For better logging, you can use built-in `log` function, it will forward all arguments to the native `console.log`. setup.ts ```ts import { connectLogger, log } from '@reatom/core' if (import.meta.env.MODE === 'development') { connectLogger() } declare global { var LOG: typeof log } globalThis.LOG = log ``` You can filter or highlight logs with the `match` option: setup.ts ```ts connectLogger({ match: (name, { state }) => { // filter unwanted logs if (name.includes('internal')) return false if (name.includes('error')) { // highlight important logs return state?.code === 403 ? 'orange' : 'red' } // pass other logs return true }, }) ``` ### Log action [Section titled “Log action”](#log-action) `log` may give you huge DX impact: * the name is short name and handy * it will trace the relative call stack and show each time * **you can put it everywhere** and commit to the source code, logs will not be visible in production * you can extend it! `log` is an action, which means you can extend it with `withCallHook` or other action extensions to add custom behavior (e.g., sending logs to a remote service, filtering specific log types, etc.). ```ts import { withCallHook } from '@reatom/core' LOG.extend( withCallHook((params) => { // Send logs to a remote service sendToAnalytics({ level: 'debug', args: params }) }), ) ``` #### `log.label` [Section titled “log.label”](#loglabel) Same as `log`, but the first argument is a required label used as the logger title instead of `"LOG"`: ```ts LOG.label('fetch payload', response) // group title: "fetch payload" // console.log: response ``` #### `log.state` [Section titled “log.state”](#logstate) Logs a value only when it changes for the given name (`Object.is`). Always returns the value, so it can be used inline: ```ts const data = LOG.state('user', useSomeData()) // logs only when `data` changes between calls with the same name ``` ## Vite [Section titled “Vite”](#vite) [`@reatom/vite`](/reference/vite) injects development HMR cleanup for `reatomRoute` / nested `.reatomRoute()` and for `@reatom/jsx` `mount()`: vite.config.ts ```ts import { defineConfig } from 'vite' import { reatom } from '@reatom/vite' export default defineConfig({ plugins: [reatom()], }) ``` See [routing HMR](/handbook/routing/#hot-module-replacement-vite) and [JSX HMR](/reference/jsx#hot-module-replacement-vite) for the underlying dispose pattern. ## Eslint [Section titled “Eslint”](#eslint) We recommend using ESLint to enforce best practices and coding standards in your Reatom projects. We will publish our own ESLint plugin for name autofix soon, but you can use this plugin right now to automate `action`, `computed`, `effect` naming: * ```json { "plugins": ["react-component-name"], "rules": { "prefer-arrow-callback": ["error", { "allowNamedFunctions": true }], "react-component-name/react-component-name": [ "error", { "targets": ["action", "computed", "effect", "reatomComponent"] } ] } } ``` Additionally to control the use of `wrap` inside `action`, `computed`, and `effect`, you can use this rule. It does not require installing any additional packages and ensures that all promises whose values are retrieved via await are wrapped in wrap.: ```json { "rules": { "no-restricted-syntax": [ "error", { "selector": "CallExpression:matches([callee.name=\"action\"], [callee.name=\"computed\"], [callee.name=\"effect\"]) ArrowFunctionExpression AwaitExpression > :not(CallExpression[callee.name=\"wrap\"])", "message": "Any awaited Promise inside \"action\", \"effect\", or \"computed\" must be wrapped with wrap()" } ] } } ``` ## Global Extensions [Section titled “Global Extensions”](#global-extensions) You can automatically track all Reatom entities (atoms and actions) in your application using global extensions. This is particularly useful for analytics, monitoring, debugging, or logging. Track user interactions by monitoring action calls: setup.ts ```ts import { addGlobalExtension, isAction, withCallHook } from '@reatom/core' addGlobalExtension((target) => { if (isAction(target)) { target.extend( withCallHook((payload, params) => { analytics.track('action_called', { action: target.name, timestamp: Date.now(), params: JSON.stringify(params), }) }), ) } return target }) ``` Call `addGlobalExtension` early in your application initialization before creating any atoms or actions, as in `connectLogger` example,. Extensions are applied only to entities created after registration. You can learn more about extensions development in the [Extensions](../handbook/extensions.md) chapter. # SSR guide > SSR with Reatom Reatom has all features to give simple and powerful SSR experience with isomorphic code. **The docs under development**. # Async Operations > Handle async operations with predictable state management Async operations are everywhere in modern applications - API calls, file uploads, data processing, and more. Reatom provides powerful extensions to handle async operations with automatic state tracking, error handling, and concurrency management. > **💡 Deep Dive**: For a comprehensive understanding of how Reatom’s async context system works under the hood and why automatic cancellation is crucial, check out our [Async Context](/handbook/async-context) guide. ## Overview [Section titled “Overview”](#overview) Reatom offers two main approaches for async operations: | Use Case | Extension | Best For | | ------------- | --------------- | -------------------------------------------------------- | | **Mutations** | `withAsync` | POST/PUT/DELETE requests, form submissions, side effects | | **Queries** | `withAsyncData` | GET requests, data fetching, computed resources | Both extensions provide automatic tracking of loading states, errors, and lifecycle hooks, with built-in support for request cancellation and race condition prevention through Reatom’s async context system. ## wrap [Section titled “wrap”](#wrap) The `wrap` function is essential for preserving Reatom’s async context across asynchronous boundaries. JavaScript’s async operations (like `await`, `.then()`, `setTimeout`) can break the chain of causation that Reatom uses for tracking dependencies and managing effects. ### Why `wrap` is Needed [Section titled “Why wrap is Needed”](#why-wrap-is-needed) When you use `await` or `.then()` in an async function, JavaScript creates a new execution context. This breaks Reatom’s ability to track which atoms and actions are being used, potentially causing “context lost” errors or preventing automatic cancellation from working properly. Also, `wrap` allows Reatom to trace all your dataflow and show it in the logger and in the devtools! ```ts import { action, atom, wrap } from '@reatom/core' const dataAtom = atom(null, 'dataAtom') const fetchData = action(async () => { // ✅ GOOD: Wrap preserves context const response = await wrap(fetch('/api/data')) const data = await wrap(response.json()) dataAtom.set(data) // Context preserved, this works // ❌ BAD: Context lost after unwrapped await // const response = await fetch('/api/data') // const data = await response.json() // dataAtom.set(data) // May throw "context lost" error }, 'fetchData') ``` ### Basic Usage [Section titled “Basic Usage”](#basic-usage) Wrap any promise or callback that needs to maintain Reatom’s async context: ```ts // Wrap promises const response = await wrap(fetch('/api/data')) const data = await wrap(response.json()) // Wrap promise chains fetch('/api/data') .then((res) => res.json()) .then( wrap((data) => { // Context preserved in this callback dataAtom.set(data) }), ) // Wrap other async operations await wrap(new Promise((resolve) => setTimeout(resolve, 1000))) ``` ### Rule of Thumb [Section titled “Rule of Thumb”](#rule-of-thumb) Wrap any function callback or promise that interacts with Reatom atoms, actions, or effects *after* an `await` or within a `.then()` block. This ensures the reactive context is preserved throughout your async operations. **Important**: Don’t chain methods after `wrap()` as this breaks the context: ```ts // ❌ BAD: Chaining breaks context const data = await wrap(fetch('/api/data')).then((res) => res.json()) // ✅ GOOD: Wrap each step const response = await wrap(fetch('/api/data')) const data = await wrap(response.json()) ``` ## Basic Async Actions [Section titled “Basic Async Actions”](#basic-async-actions) Use `withAsync` for operations that don’t need to store the result data, such as form submissions or data mutations: ```ts import { action, wrap } from '@reatom/core' import { withAsync } from '@reatom/core' const submitForm = action(async (formData: FormData) => { const response = await wrap( fetch('/api/submit', { method: 'POST', body: formData, }), ) if (!response.ok) { throw new Error(`Failed to submit: ${response.statusText}`) } return await wrap(response.json()) }, 'submitForm').extend(withAsync()) // Now you have access to: submitForm.ready() // → true when not loading submitForm.error() // → latest error or undefined submitForm.retry() // → retry with the same parameters ``` ## Async Data Fetching [Section titled “Async Data Fetching”](#async-data-fetching) Use `withAsyncData` when you need to store and access the fetched data. This extension includes all `withAsync` features plus data storage and automatic request cancellation. While it can be applied to actions, it’s most powerful when used with `computed` atoms: ```ts import { computed, atom, wrap } from '@reatom/core' import { withAsyncData } from '@reatom/core' const searchQuery = atom('', 'searchQuery') const searchResults = computed(async () => { const query = searchQuery() if (!query.trim()) return [] const response = await wrap( fetch(`/api/search?q=${encodeURIComponent(query)}`), ) if (!response.ok) { throw new Error(`Search failed: ${response.statusText}`) } return await wrap(response.json()) }, 'searchResults').extend(withAsyncData({ initState: [] })) // Access the data and states: searchResults.data() // → the search results array searchResults.ready() // → false while loading, true when complete searchResults.error() // → error if search failed ``` ## Data Transformation [Section titled “Data Transformation”](#data-transformation) Transform fetched data before storing it to match your application’s data structure: ```ts interface User { id: string name: string email: string } interface UserListResponse { users: User[] total: number } const userList = computed(async () => { const response = await wrap(fetch('/api/users')) return (await wrap(response.json())) as UserListResponse }, 'userList').extend( withAsyncData({ initState: [] as User[], mapPayload: (response, params, currentUsers) => { // Transform the API response into the format you need return response.users }, }), ) // userList.data() now returns User[] instead of UserListResponse ``` ## Debouncing [Section titled “Debouncing”](#debouncing) When dealing with user input that triggers async operations (like search-as-you-type), you might want to debounce the requests to avoid overwhelming your API. Reatom offers elegant solutions for this common pattern. > **💡 Advanced Patterns**: For a deep dive into handling rapid user input and comparing traditional debounce patterns with Reatom’s modern concurrency model, check out our [Sampling](/handbook/sampling) guide. Here’s how you can add debouncing to our search example: ```ts import { sleep } from '@reatom/utils' const searchResults = computed(async () => { const query = searchQuery() if (!query.trim()) return [] // Debounce: wait 300ms before making the request // The wrap will throw abort error if user will trigger new search query during the delay await wrap(sleep(300)) const response = await wrap( fetch(`/api/search?q=${encodeURIComponent(query)}`), ) if (!response.ok) { throw new Error(`Search failed: ${response.statusText}`) } return await wrap(response.json()) }, 'searchResults').extend(withAsyncData({ initState: [] })) ``` The beauty of this approach is that Reatom’s automatic cancellation handles race conditions for you. When the user types quickly, outdated requests are cancelled automatically, ensuring only the latest search results are displayed. ## Error Handling [Section titled “Error Handling”](#error-handling) Customize error handling with parsing and reset strategies to create consistent error experiences: ```ts const searchResults = computed(async () => { const query = searchQuery() if (!query.trim()) return [] await wrap(sleep(300)) // Debounce const response = await wrap( fetch(`/api/search?q=${encodeURIComponent(query)}`), ) if (!response.ok) throw response return await wrap(response.json()) }, 'searchResults').extend( withAsyncData({ initState: [], // Transform errors into a consistent format parseError: (error) => { if (error instanceof Response) { return new Error(`Search failed: HTTP ${error.status}`) } return error instanceof Error ? error : new Error(String(error)) }, // Reset errors when starting a new search resetError: 'onCall', }), ) ``` ## Advanced Patterns [Section titled “Advanced Patterns”](#advanced-patterns) ### Dependent Resources [Section titled “Dependent Resources”](#dependent-resources) Chain async resources where one depends on another. Reatom automatically handles cancellation across the entire dependency chain: ```ts const searchQuery = atom('', 'searchQuery') const selectedCategory = atom('all', 'selectedCategory') const searchResults = computed(async () => { const query = searchQuery() if (!query.trim()) return [] await wrap(sleep(300)) // Debounce const response = await wrap( fetch(`/api/search?q=${encodeURIComponent(query)}`), ) return await wrap(response.json()) }, 'searchResults').extend(withAsyncData({ initState: [] })) const filteredResults = computed(async () => { // Wait for search results to load first const results = await wrap(searchResults()) const category = selectedCategory() if (category === 'all') return results // Apply additional filtering based on category const response = await wrap( fetch(`/api/filter?category=${category}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ items: results }), }), ) return await wrap(response.json()) }, 'filteredResults').extend(withAsyncData({ initState: [] })) ``` ### Optimistic Updates [Section titled “Optimistic Updates”](#optimistic-updates) For optimistic updates you can use `withTransaction` and `withRollback` extensions! Marked atoms will automatically rollback the changes inside a transaction action if it fails. ```ts import { action, atom, withAsync, withRollback, withTransaction, wrap, } from '@reatom/core' const user = atom(null, 'user').extend(withRollback()) const updateUser = action(async (update: Partial) => { user.set((state) => ({ ...state, ...update })) const response = await wrap( fetch(`/api/users/${user().id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(update), }), ) if (!response.ok) throw new Error('Update failed') return await wrap(response.json()) }, 'updateUser').extend(withAsync(), withTransaction()) ``` ### Manual Abort Control [Section titled “Manual Abort Control”](#manual-abort-control) `withAsyncData` includes automatic request cancellation through Reatom’s async context system. For `withAsync`, you need to add `withAbort` explicitly: ```ts import { withAbort, abortVar } from '@reatom/core' // withAsync alone doesn't include abort const basicTask = action(async (data: any) => { const response = await wrap( fetch('/api/process', { method: 'POST', body: JSON.stringify(data), }), ) return await wrap(response.json()) }, 'basicTask').extend(withAsync()) // basicTask.abort() // ❌ Not available // Add withAbort for manual cancellation control const abortableTask = action(async (data: any) => { const controller = abortVar.require() const response = await wrap( fetch('/api/process', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), signal: controller?.signal, // Use the abort signal from abortVar }), ) return await wrap(response.json()) }, 'abortableTask').extend(withAsync(), withAbort()) // Now you can manually abort abortableTask.abort() // ✅ Available // withAsyncData includes withAbort automatically const dataResource = computed(async () => { const response = await wrap(fetch('/api/data')) return await wrap(response.json()) }, 'dataResource').extend(withAsyncData()) dataResource.abort() // ✅ Available automatically ``` ### Lifecycle Hooks [Section titled “Lifecycle Hooks”](#lifecycle-hooks) Both extensions provide hooks for handling different phases of async operations, enabling fine-grained control over your async workflows: ```ts const api = action(async (data: any) => { // Your async operation return await wrap( fetch('/api/data', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }), ) }, 'api').extend(withAsync()) // Handle successful completion api.onFulfill.extend( withCallHook(({ payload, params }) => { console.log('API call succeeded:', payload) // payload: the resolved value // params: the original parameters passed to the action }), ) // Handle errors api.onReject.extend( withCallHook(({ error, params }) => { console.error('API call failed:', error) // error: the thrown error // params: the original parameters passed to the action }), ) // Handle completion (success or failure) api.onSettle.extend( withCallHook((result) => { console.log('API call completed') // result: either { payload, params } or { error, params } }), ) ``` ## Status Tracking [Section titled “Status Tracking”](#status-tracking) For more fine-grained control over async operation states, use the `status` option. Unlike the simple `ready()` and `error()` helpers, the `status` atom provides detailed information about the lifecycle of your async operations, including first-time loading detection and historical tracking. ### Enabling Status [Section titled “Enabling Status”](#enabling-status) Enable status tracking by passing `status: true` to `withAsync`: ```ts const fetchUser = action(async (id: string) => { const response = await wrap(fetch(`/api/users/${id}`)) return await wrap(response.json()) }, 'fetchUser').extend(withAsync({ status: true })) // Access the status atom fetchUser.status() // { isPending: false, isFirstPending: false, ... } ``` ### Status Properties [Section titled “Status Properties”](#status-properties) The status object provides several boolean flags organized into two categories: **Current State Flags** (mutually exclusive when settled): | Property | Description | | ------------- | ---------------------------------------------------- | | `isPending` | An async operation is currently in progress | | `isFulfilled` | The last completed operation succeeded | | `isRejected` | The last completed operation failed (non-abort only) | | `isSettled` | The operation has completed (fulfilled or rejected) | **Historical Tracking Flags**: | Property | Description | | ---------------- | ---------------------------------------------------------- | | `isFirstPending` | This is the first-ever pending state (great for skeletons) | | `isEverPending` | At least one async operation has been started | | `isEverSettled` | At least one async operation has completed | ### First Load vs Subsequent Loads [Section titled “First Load vs Subsequent Loads”](#first-load-vs-subsequent-loads) The `isFirstPending` flag is particularly useful for differentiating between initial loading states and subsequent refreshes: ```tsx const UserProfile = reatomComponent(() => { const status = fetchUser.status() if (status.isFirstPending) { // Show skeleton only on first load return } if (status.isPending) { // Show subtle spinner on subsequent loads return ( <> ) } if (status.isRejected) { return } return }) ``` ### Abort Handling [Section titled “Abort Handling”](#abort-handling) Aborted operations are treated specially - they don’t set `isRejected` to true. After an abort, the status returns to the last settled state (fulfilled/rejected) if one exists: ```ts const fetchData = action(async () => { const controller = abortVar.require() const response = await wrap( fetch('/api/data', { signal: controller?.signal }), ) return await wrap(response.json()) }, 'fetchData').extend(withAsync({ status: true }), withAbort()) await wrap(fetchData()) // status: { isFulfilled: true, isSettled: true, ... } fetchData.abort() // status remains: { isFulfilled: true, isSettled: true, ... } // (restored to last settled state, not marked as rejected) ``` ### Resetting Status [Section titled “Resetting Status”](#resetting-status) You can reset the status to its initial state using the `reset` action. This is useful when you want to treat the next async call as a “first” call again: ```ts const fetchUser = action(async (id: string) => { return await wrap(api.getUser(id)) }, 'fetchUser').extend(withAsync({ status: true })) // After some operations... fetchUser.status().isEverPending // true fetchUser.status().isEverSettled // true // Reset to initial state fetchUser.status.reset() fetchUser.status().isEverPending // false fetchUser.status().isEverSettled // false // Next call will have isFirstPending: true fetchUser('123') fetchUser.status().isFirstPending // true ``` ### Status with Data [Section titled “Status with Data”](#status-with-data) When using `withAsyncData` with `status: true`, the status object also includes a `data` property that mirrors the current data state: ```ts const searchResults = computed(async () => { const query = searchQuery() if (!query.trim()) return [] const response = await wrap( fetch(`/api/search?q=${encodeURIComponent(query)}`), ) return await wrap(response.json()) }, 'searchResults').extend(withAsyncData({ initState: [], status: true })) const status = searchResults.status() // status.data contains the current search results // status.isPending, status.isFirstPending, etc. are also available ``` ## Best Practices [Section titled “Best Practices”](#best-practices) ### 1. Choose the Right Extension [Section titled “1. Choose the Right Extension”](#1-choose-the-right-extension) ```ts // ✅ Use withAsync for mutations that don't need to store data const saveUser = action(async (user) => { await wrap(api.saveUser(user)) }, 'saveUser').extend(withAsync()) // ✅ Use withAsyncData for queries that need to store and access data const getUser = computed(async () => { return await wrap(api.getUser()) }, 'getUser').extend(withAsyncData()) ``` ### 2. Provide Meaningful Names [Section titled “2. Provide Meaningful Names”](#2-provide-meaningful-names) ```ts // ✅ Good: descriptive names help with debugging and developer experience const fetchUserProfile = computed(async () => { return await wrap(api.getUserProfile()) }, 'fetchUserProfile').extend(withAsyncData()) // ❌ Avoid: generic names make debugging and maintenance harder const data = computed(async () => { return await wrap(api.getUserProfile()) }).extend(withAsyncData()) ``` ### 3. Always Handle Loading and Error States [Section titled “3. Always Handle Loading and Error States”](#3-always-handle-loading-and-error-states) ```tsx // ✅ Good: handle all possible states for better UX const Component = reatomComponent(() => { if (!resource.ready()) return if (resource.error()) return return }) // ❌ Avoid: ignoring loading/error states leads to poor UX const Component = reatomComponent(() => { return }) ``` ### 4. Always Use `wrap()` for Async Operations [Section titled “4. Always Use wrap() for Async Operations”](#4-always-use-wrap-for-async-operations) ```ts // ✅ Good: wrap ensures proper error handling and cancellation const fetchData = computed(async () => { const response = await wrap(fetch('/api/data')) return await wrap(response.json()) }, 'fetchData').extend(withAsyncData()) // ❌ Avoid: unwrapped async calls bypass Reatom's async context system const fetchData = computed(async () => { const response = await fetch('/api/data') // Missing wrap() return await response.json() // Missing wrap() }, 'fetchData').extend(withAsyncData()) ``` ## Related Resources [Section titled “Related Resources”](#related-resources) * **[Async Context](/handbook/async-context)** - Deep dive into Reatom’s async context system and automatic cancellation * **[Sampling](/handbook/sampling)** - Advanced patterns for handling user input and debouncing strategies * **[Actions](/start/actions)** - Learn more about Reatom actions and their capabilities * **[Computed Values](/start/base)** - Understanding reactive computations in Reatom # Async Context > Documentation on async context in Reatom This article describes the main killer feature of redux-saga and rxjs and how you can now get it more simply, as well as about upcoming changes in the ECMAScript standard and Reatom. We will talk about automatic cancellation of concurrent asynchronous chains - an essential property when working with any REST API and other more general asynchronous sequential operations. ## Basic Example [Section titled “Basic Example”](#basic-example) ```javascript const getA = async () => { const a = await api.getA() return a } const getB = async (params) => { const b = await api.getB(params) return b } export const event = async () => { const a = await getA() const b = await getB(a) setState(b) } ``` The example is as basic as possible, most people have written such code: you need to first request some data from the backend, then based on that, request the final data from another endpoint. The situation is complicated if the first data depends on user input, most often these are some filters or sorting in a table. The user changes something, we make a request, the user changes something else, and we have already received a response from the previous request - this is a problem. Until the new request is completed, a “weird state” is displayed. But this is still nonsense. The overwhelming majority of backend servers do not monitor the order of requests and can respond to the second request first, and then to the first - for the user, this will be reflected in data for old filters, and the new data will never appear - “WAT state”. ```mermaid sequenceDiagram participant User participant App participant Backend User->>App: Input 1 App->>Backend: Request 1A (Chain 1) User->>App: Input 2 App->>Backend: Request 2A (Chain 2) Backend-->>App: Response 1A (Chain 1) App->>User: Update UI with data from Chain 1 (Weird state) Backend-->>App: Response 2A (Chain 2) App->>User: Update UI with data from Chain 2 (Final state) ``` How to avoid the WAT state from the example in the picture? It seems simple, cancel the last request. ```mermaid sequenceDiagram participant User participant App participant Backend User->>App: Input 1 App->>Backend: Request 1A (Chain 1) User->>App: Input 2 App->>Backend: Request 2A (Chain 2) Backend-->>App: Response 2A (Chain 2) App->>User: Update UI with data from Chain 2 Backend-->>App: Response 1A (Chain 1 - Outdated) App->>User: Update UI with data from Chain 1 (WAT state) ``` It’s not that difficult to cancel each specific request, although this code still needs to be written, not everyone has ready-made tools at hand. Axios itself doesn’t provide such a feature out of the box; it has the ability to pass a cancellation signal, but you have to manage it yourself. No automation. How could you do this yourself? The easiest way to add cancellation is through request versioning. ```javascript let aVersion = 0 const getA = async () => { const version = ++aVersion const a = await api.getA() if (version !== aVersion) throw new Error('aborted') return a } let bVersion = 0 const getB = async (params) => { const version = ++bVersion const b = await api.getB(params) if (version !== bVersion) throw new Error('aborted') return b } export const event = async () => { const a = await getA() const b = await getB(a) setState(b) } ``` Boilerplate? But that’s not all. We only fixed the “WAT state”, but what about the “weird state”? ```mermaid sequenceDiagram participant User participant App participant Backend User->>App: Input 1 (Chain 1) App->>Backend: Request 1A (Chain 1) User->>App: Input 2 (Chain 2) note over App,Backend: Cancel individual requests App->>Backend: Cancel Request 1A Backend--xApp: Response 1A (Canceled) App->>Backend: Request 2A (Chain 2) Backend-->>App: Response 2A (Chain 2) App->>Backend: Request 2B (Chain 2) Backend-->>App: Response 2B (Chain 2) App->>User: Update UI with data from Chain 2 (Expected state) ``` Our attempts to cancel the previous request lead to nothing! Requests go one after another and do not overtake each other, so the final result will be correct, but what will flicker on the screen may still be unclear to the user. How to fix this? It is important to understand that an asynchronous process is not only a request to the backend, but also the entire logical chain that we describe - it is what needs to be canceled! It is very easy to imagine and visualize this - there should not be two parallel operations, only one at any given time. To do this, we will introduce a version for the entire chain. ```javascript const getA = async (getVersion) => { const version = getVersion() const a = await api.getA() if (version !== getVersion()) throw new Error('aborted') return a } const getB = async (getVersion, params) => { const version = getVersion() const b = await api.getB(params) if (version !== getVersion()) throw new Error('aborted') return b } let version = 0 const getVersion = () => version export const event = async () => { version++ const a = await getA(getVersion) const b = await getB(getVersion, a) setState(b) } ``` Here we do not use `getVersion` from the closure in each request, because in real code these functions can be scattered across different files, and we have to declare a common contract - passing the version function as the first argument. But the problem is solved! Chain cancellation prevents “weird state”. ```mermaid sequenceDiagram participant User participant App participant Backend User->>App: Input 1 (Chain 1) App->>Backend: Request 1A (Chain 1) User->>App: Input 2 (Chain 2) note over App,Backend: Individual cancels don't prevent weird state App->>Backend: Request 2A (Chain 2) Backend-->>App: Response 1A (Chain 1) App->>User: Update UI with data from Chain 1 (Weird state) Backend-->>App: Response 2A (Chain 2) App->>Backend: Request 2B (Chain 2) Backend-->>App: Response 2B (Chain 2) App->>User: Update UI with data from Chain 2 (Final state) ``` “WAT state” - can no longer appear either. ```mermaid sequenceDiagram participant User participant App participant Backend User->>App: Input 1 (Chain 1) App->>Backend: Request 1A (Chain 1) User->>App: Input 2 (Chain 2) note over App,Backend: Cancel entire chain App->>App: Cancel Chain 1 completely App->>Backend: Request 2A (Chain 2) Backend--xApp: Response 1A (Chain 1 - Canceled) Backend-->>App: Response 2A (Chain 2) App->>Backend: Request 2B (Chain 2) Backend-->>App: Response 2B (Chain 2) App->>User: Update UI with data from Chain 2 (Expected state) ``` But the code looks very verbose. We can simplify it a bit using the native AbortController, which is already well supported in browsers and node.js. ```javascript const getA = async (controller) => { const a = await api.getA() controller.throwIfAborted() return a } const getB = async (controller, params) => { const b = await api.getB(params) controller.throwIfAborted() return b } let controller = new AbortController() export const event = async () => { controller.abort('concurrent') controller = new AbortController() const a = await getA(controller) const b = await getB(controller, a) setState(b) } ``` It got better and, I hope, clearer, but it still looks inconvenient and verbose, the controller has to be passed manually, is it worth it? In my practice, no one did this, because no one will rewrite all functions so that they interact normally with each other and the code is more consistent. Just as no one makes all functions async at all, you can read more about this in [How do you color your functions?](https://elizarov.medium.com/how-do-you-color-your-functions-a6bb423d936d). It is important to understand that the described example is as simplified as possible, and in real tasks, the data flow and the corresponding problem can be much more complex and serious. What are the alternatives? rxjs and redux-saga allow you to describe code in their specific API, which under the hood automatically tracks concurrent calls of asynchronous chains and can cancel outdated ones. The problem with this is precisely in the API - it is very specific, both in appearance and behavior - the entry threshold is quite large. Although less than in $mol - yes, it also knows how to do automatic cancellation. Here is an example with rxjs. ```javascript import { from, Subject } from 'rxjs' import { switchMap } from 'rxjs/operators' const getA = async () => { const a = await api.getA() return a } const getB = async (params) => { const b = await api.getB(params) return b } export const event$ = new Subject() event$ .pipe( switchMap(() => from(getA())), switchMap((a) => from(getB(a))), ) .subscribe((b) => setState(b)) ``` In `@reduxjs/toolkit`, there is `createListenerMiddleware`, whose API has some features from redux-saga that allow solving primitive cases of this problem. But chain tracking is more local and not as well integrated into the entire toolkit API. Also Effect has some features to solve this, but all *monad* and *generator* approaches have the same problem - excessive function coloring, which leads to abstraction leaking. What other options do we have? ## Context [Section titled “Context”](#context) In this article, we’ve primarily discussed automatic cancellation of asynchronous chains, but this is actually a specific application of a more fundamental concept: **asynchronous context**. Async context is essentially the ability to access shared data across asynchronous boundaries, similar to how you can access variables in lexical scope, but preserved through asynchronous operations via call stack. ### Why Async Context Matters [Section titled “Why Async Context Matters”](#why-async-context-matters) On backend platforms, asynchronous context has been a crucial tool for building reliable systems for years. Node.js provides [AsyncLocalStorage](https://nodejs.org/api/async_context.html) which allows developers to store and retrieve data across async operations without explicitly passing it through every function call. This enables important functionality like request-scoped logging, distributed tracing, and—as we’ve seen—automatic cancellation of outdated operations. The importance of async context is so well recognized that there’s currently an active TC39 proposal to include it in the ECMAScript standard: [tc39/proposal-async-context](https://github.com/tc39/proposal-async-context). This proposal would bring native async context to all JavaScript environments, including browsers. ### From Manual Passing to Automatic Context [Section titled “From Manual Passing to Automatic Context”](#from-manual-passing-to-automatic-context) Let’s see how our example would look using the proposed AsyncContext API: ```javascript // https://github.com/tc39/proposal-async-context#proposed-solution let prevAbort = new AbortController() const abortVar = new AsyncContext.Variable() const getA = async () => { const a = await api.getA() const controller = abortVar.get() controller.throwIfAborted() return a } const getB = async (params) => { const b = await api.getB(params) abortVar.get().throwIfAborted() return b } export const event = async () => { prevAbort.abort('concurrent') prevAbort = new AbortController() await abortVar.run(prevAbort, async () => { const a = await getA() const b = await getB(a) setState(b) }) } ``` The code is significantly cleaner than our previous manual implementations. The standard AbortController is stored in an AsyncContext.Variable, and each asynchronous function retrieves it automatically from the context rather than receiving it as an explicit parameter. But is it possible to use this approach today? Unfortunately, not quite. The TC39 proposal is still in the early stages, and existing polyfills like zone.js (used by Angular) don’t comprehensively cover all edge cases. ### Reatom’s Implementation of Async Context [Section titled “Reatom’s Implementation of Async Context”](#reatoms-implementation-of-async-context) Reatom offers a pragmatic solution by implementing its own async context system that works today. At its core, Reatom provides: 1. A `variable()` function that emulates AsyncContext.Variable 2. A `wrap()` function that preserves context across async boundaries 3. A `withAbort()` extension that automatically handles cancellation (will study it later) Here’s how the same example looks using Reatom’s API. ```javascript import { variable, wrap } from '@reatom/core' let prevAbort = new AbortController() const abortVar = variable() const getA = async () => { const a = await wrap(api.getA()) const controller = abortVar.get() controller.throwIfAborted() return a } const getB = async (params) => { const b = await wrap(api.getB(params)) abortVar.get().throwIfAborted() return b } export const event = async () => { prevAbort.abort('concurrent') prevAbort = new AbortController() await abortVar.run(prevAbort, async () => { const a = await wrap(getA()) const b = await wrap(getB(a)) setState(b) }) } ``` This code is remarkably close to our original synchronous-looking example, with just a few added calls to `wrap()` to maintain context across async operations. And even more! Reatom has build in `abortVar` which automatically tracked by all `wrap` calls, so you don’t need to check abort controller manually. ```javascript import { abortVar, wrap } from '@reatom/core' let prevAbort = new AbortController() const getA = async () => { const a = await wrap(api.getA()) return a } const getB = async (params) => { const b = await wrap(api.getB(params)) return b } export const event = async () => { prevAbort.abort('concurrent') prevAbort = new AbortController() await abortVar.run(prevAbort, async () => { const a = await wrap(getA()) const b = await wrap(getB(a)) setState(b) }) } ``` Under the hood Reatom operates its own async context by wrapping the run callback into “action”, a special function decorator, which creates a manageable frames for async stack emulation. You can use actions by yourself to simplify codestyle. Also, Reatom has built-in actions extension to manage previous controller handling! Check the end example with idempotent reatom code. ```javascript import { action, wrap, withAbort } from '@reatom/core' const getA = async () => { const a = await wrap(api.getA()) return a } const getB = async (params) => { const b = await wrap(api.getB(params)) return b } export const event = action(async () => { const a = await wrap(getA()) const b = await wrap(getB(a)) setState(b) }).extend(withAbort()) ``` This final example shows the most elegant way to handle async request cancellation with Reatom. The `action` wrapper creates a special function that creates an async context frame for each call, while `withAbort()` extension automatically handles aborting previous executions when a new one starts. This approach completely eliminates the need for manual abort controller management - you don’t need to create, store, or pass AbortController instances anymore. The cancellation happens automatically when concurrent calls are made to the same action. What’s particularly powerful is that this cancellation propagates through the entire chain of wrapped calls. When `event` is called concurrently, the entire previous execution chain (including both `getA` and `getB` calls) will be properly aborted, preventing any stale updates or race conditions. All of this is achieved with minimal additions to the original code structure, making it much more maintainable than alternatives. ### Advantages of Reatom’s Approach [Section titled “Advantages of Reatom’s Approach”](#advantages-of-reatoms-approach) Reatom’s implementation offers several key advantages over other solutions: 1. **Minimal API Surface**: Reatom’s approach requires minimal additions to your code—just wrap async operations and apply the withAbort extension—unlike rxjs or redux-saga which require learning entirely new paradigms. 2. **Native AbortController Integration**: Reatom uses the standard AbortController that’s already widely supported in browsers and node.js, as well as many libraries. This means you can easily connect Reatom’s cancellation system to native APIs like fetch. 3. **Lightweight**: The bundle size overhead is significantly smaller than alternatives like rxjs. 4. **Developer Experience**: The code remains highly readable and close to standard async/await patterns, lowering the learning curve. 5. **Debuggability**: Reatom provides complete traceability for all actions and includes a built-in logging system that works out of the box. This makes tracking async workflows and identifying issues much easier than with traditional approaches, where you’d need to manually add logging throughout your code. P.S. we have more features on top of async context, including transactions with automatic rollbacks! Check the rest of the docs 🙌 # Atomization > How to do factories in a correct way with Reatom You could store your backend data in atoms without any mappings, but it’s a good practice to wrap parts of your model in atoms for better control and access to more reactive features. The rule is simple: **mutable properties should be atoms, readonly properties should stay as primitives**. > [DTO](https://en.wikipedia.org/wiki/Data_transfer_object) is data from the backend, but the application model can differ slightly. For example, if you have a user model with an editable name property: \~/features/user/model.ts ```ts import { atom, action, type Atom } from '@reatom/core' type UserDto = { id: string name: string } type User = { id: string name: Atom } export const user = atom(null, 'user') export const fetchUser = action(async () => { const userDto = await api.getUser() const userModel = { id: userDto.id, name: atom(userDto.name, 'user.name') } user.set(userModel) }, 'fetchUser') export const syncUserName = action(async () => { const name = user()?.name() if (name) { return await api.updateUser({ name }) } }, 'syncUserName') ``` \~/features/user/index.tsx ```tsx import { reatomComponent } from '@reatom/react' // user component const User = reatomComponent(() => { const currentUser = user() if (!currentUser) return null const name = currentUser.name() const handleChange = (e: React.ChangeEvent) => currentUser.name.set(e.currentTarget.value) const handleSubmit = () => syncUserName() return (
) }) ``` If you have a list of users and need to perform CRUD operations (paging, sorting, adding) on it, you should wrap it in an atom too: > Check out our simple primitives for working with arrays: [reatomArray](/reference/primitives#reatomarray) ```ts // DTO type Users = Array<{ id: string name: string }> // App type Users = Atom< Array<{ id: string name: Atom }> > ``` ## Reducing computational complexity [Section titled “Reducing computational complexity”](#reducing-computational-complexity) Continuing from the example above, wrapping editable properties of a list element in atoms helps prevent excessive immutable work, like array recreation. In classic immutable state managers, it’s common to recreate the entire array with a new element reference for each property update, but this could be more optimal. Reatom offers a solution by allowing you to replace changeable properties with stable atom references, separating data structure definition and mutation. This approach is generally called the **ref pattern**. In Reatom, we call it **atomization**, and it’s much more useful than other solutions. ```ts // redux way: O(n) export const updateUserName = (state, idx, name) => { const newList = [...state.users] newList[idx] = { ...newList[idx], name } return { ...state, list: newList } } // reatom way: O(1) export const updateUserName = action((idx, name) => { const nameAtom = list()[idx].name nameAtom.set(name) }) ``` Note that an atom is both a getter and a setter for its state, so you usually don’t need to write an `updateUserName` action. You can directly modify the name atom in the relevant component. In most libraries, this is an anti-pattern because it’s challenging to debug what and where changes were made. However, in Reatom, you have `cause` tracking, allowing you to inspect the reason for any atom change. This provides an even better debugging experience than working with plain JSON data structures. Another cool feature and significant benefit of this pattern is seen when you have a computed list derived from another list. For example, mapping a list of JSX elements will re-render each property update. This issue can only be fixed with normalization, which is more complex and less powerful than atomization. ## Deatomization [Section titled “Deatomization”](#deatomization) Atomization wraps reactive parts in atoms. Sometimes you need the opposite: a plain snapshot without reactive references — for API payloads, logging, tests, or storage. Use [`deatomize`](/reference/methods#deatomize) for that. It recursively walks a value and replaces atoms with their current state: ```ts import { atom, deatomize, reatomEnum } from '@reatom/core' const user = { id: 42, name: atom('John', 'user.name'), tags: reatomEnum(['admin', 'member'], 'user.tags'), } deatomize(user) // { id: 42, name: 'John', tags: 'admin' } ``` `deatomize` also works with nested objects, arrays, `Map`, and `Set`. Actions are returned as-is. ### Linked lists [Section titled “Linked lists”](#linked-lists) [`reatomLinkedList`](/reference/primitives#reatomlinkedlist) stores ordered data in a linked structure, not a plain array. The atom state is a `LinkedList` object with `head`, `tail`, `size`, and internal links — so `list()` is not what you send to an API or validate with a schema. For ordered nodes in UI code, use `list.array()`. For a plain serializable snapshot, use `deatomize(list)`: ```ts import { atom, deatomize, reatomLinkedList } from '@reatom/core' const uploads = reatomLinkedList( (fileName: string) => ({ fileName, progress: atom(0, `uploads#${fileName}.progress`), }), 'uploads', ) uploads.create('cover.png') uploads.create('hero.png') uploads().size // linked list metadata uploads.array() // ordered nodes with reactive fields deatomize(uploads) // [ // { fileName: 'cover.png', progress: 0 }, // { fileName: 'hero.png', progress: 0 }, // ] ``` When list nodes are atoms themselves, `JSON.stringify(list)` also works because each node serializes through its own `toJSON`. For object nodes with nested atoms, prefer `deatomize(list)` to unwrap everything. Linked lists also define `fromJSON` for restoring from an array snapshot. That is used automatically by [`withPersist`](/handbook/persist#json-protocol-tojson--fromjson). ## Reasonability [Section titled “Reasonability”](#reasonability) “Mutable properties could be an atom, readonly properties should stay a primitive” is a general rule, but exceptions exist. For example, if you have a huge list (>10,000) of entities with many editable properties (>10), it may not be optimal to create an atom for each property. In such cases, wrapping an entity in an atom with primitive properties and updating it by recreating the entity object is more reasonable. This is where explicit atom declarations shine. In state managers with proxy-based APIs, you often can’t control atom/store/signal creation, and using a dot creates an observer. While implicit reactivity is convenient for simple cases, it’s not flexible enough for complex ones. Reatom aims to be simple and brief, but its main design goal is to be the best tool for large applications, ensuring developers retain control. # Computed Factory Pattern > Build scoped state from a single dependency chain—start with a tiny factory, add forms and concurrency, then see why route loaders are where this pattern shines The **computed factory** is one of the most distinctive patterns Reatom enables. It answers a question every state library eventually faces: how do you keep the **cleanup story** of local state and the **reach** of global state without choosing one and faking the other? The story goes like this: start with a **minimal** factory, add **forms** and a **stricter contract**, then **concurrency** and a **whole scoped model**, then **routing**—where the pattern is often at its most efficient, because the URL already names the scope. We close with **memoization** when volatile inputs would otherwise churn the factory too often. ## The tension [Section titled “The tension”](#the-tension) * **Local state** (`useState`, component-scoped signals, etc.) disappears with the owner, which is good for cleanup—but sharing it means props, context, or lifting, and the logic stays glued to the tree. * **Global state** (singleton atoms, stores, slices) is easy to reach from anywhere, but it lives as long as the app. Scoping it to a selection, session, or screen, resetting it, and tearing down effects tied to an old scope is **manual**—and manual steps accumulate bugs. What you usually want is **globally reachable state whose lifetime is tied to a meaningful unit of work**: a selected row, an edit session, a matched route, a tab, a modal. ## What a computed factory is [Section titled “What a computed factory is”](#what-a-computed-factory-is) A `computed` atom recomputes when its dependencies change. When the computed **returns another atom** (or an object of atoms and actions), that inner graph is **produced** by the computed—each time inputs change, a **new** inner instance becomes the return value and replaces the previous one. That replacement is the point: * Callers who read through the factory (`myFactory()`) always see the instance for the **current** inputs—no stale handles, no forgotten resets. * Work started under the old instance can be **aborted** so an old closure does not race with a new one ([Concurrency](#concurrency)). So the outer `computed` acts as a **factory**: it mints scoped state. The emphasis is not “save memory” but **freshness** and **isolation**. ### A minimal factory [Section titled “A minimal factory”](#a-minimal-factory) Start with the smallest useful shape: a draft keyed off a selection. When the selection changes, the draft is replaced wholesale. ```typescript import { atom, computed } from '@reatom/core' const selectedUserId = atom(null, 'selectedUserId') export const selectedUserDraft = computed(() => { const id = selectedUserId() if (id === null) return null return { id, name: atom('', `selectedUserDraft#${id}.name`), bio: atom('', `selectedUserDraft#${id}.bio`), } }, 'selectedUserDraft') ``` Anything in the app can read `selectedUserDraft()` and get the draft for the **current** user id. Change `selectedUserId` and the old draft vanishes from the contract—no `onCleanup`, no `WeakMap` of id → draft. Here the factory is **permissive**: it uses `null` when nothing is selected, so ambient reads stay safe. That is a good default. The next step is to tighten the contract when “no scope” is not a normal case but a **mistake**—and to pair that with richer pieces like forms. That combination is where factories start to feel indispensable. ## Forms and a stricter contract [Section titled “Forms and a stricter contract”](#forms-and-a-stricter-contract) Picture a list with an “edit” action per user. Without a factory you often maintain a `Map` by hand, or one “current” form and fight stale values when the selection changes. With a factory, the form for the **active** edit is one read away: ```typescript import { atom, computed, reatomForm, wrap } from '@reatom/core' const users = atom([], 'users') const editedUserId = atom(null, 'editedUserId') export const editedUserForm = computed(() => { const id = editedUserId() const user = users().find((u) => u.id === id) if (!id || !user) { throw new Error(`editedUserForm: nothing to edit`) } return reatomForm( { name: user.name, bio: user.bio }, { onSubmit: (values) => wrap( fetch(`/api/users/${user.id}`, { method: 'PUT', body: JSON.stringify(values), }), ), name: `editedUserForm#${user.id}`, }, ) }, 'editedUserForm') ``` ### The factory’s logical scope [Section titled “The factory’s logical scope”](#the-factorys-logical-scope) A factory is not “a global blob”—it is meaningful **only while a scope holds**: *while someone is being edited*, *while a route matches*, *while a modal is open*. Reading it outside that scope is meaningless. Once you name the scope, “nothing selected” stops being a branch every consumer repeats and becomes an **invariant**: reading the factory when the scope is inactive is a bug you want to surface. That is why the `throw` is acceptable: every real reader of `editedUserForm` lives under the “editing is on” umbrella—a view that only mounts when a user is chosen, an action fired from Edit, a parent that already checked `editedUserId()`. You choose the contract on purpose: * **Return `null`** — “no active scope” is ordinary state; every reader handles both branches. Use when reads are scattered (sidebars, dashboards). * **Throw** — “reading outside the scope is wrong”; readers can assume a valid value. Use when you control call sites and want fewer guard clauses. Prefer the stricter style whenever you can **name** the scope. Named scopes are where factories pay off—and [route loaders](#route-loaders) are often the cleanest way to name a scope across an entire app. ### Reading a factory that throws [Section titled “Reading a factory that throws”](#reading-a-factory-that-throws) Call sites split into a **gate** (is the scope active?) and a **view** (assume it is). The panel that uses the form stays simple: ```tsx import { reatomComponent, bindField } from '@reatom/react' const UserEditor = reatomComponent(() => { if (editedUserId() === null) return null return }, 'UserEditor') const EditPanel = reatomComponent(() => { const form = editedUserForm() return (
(e.preventDefault(), form.submit())}>