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 (
)
}, '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`
`
},
})
// 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``
},
})
// 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:
[](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(() => (
))
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 (
)
})
```
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 (
)
}, 'EditPanel')
```
Pick another user and the previous form—fields, validation, submit—disappears and a new one takes its place. Pick the same user again and you still get a **new** form instance (unless you add sharing logic yourself).
## Concurrency
[Section titled “Concurrency”](#concurrency)
A fresh model answers “which scope am I in?”. You also need an answer to “what happens to **in-flight** work from the **previous** scope?”
If the factory polls, or `onSubmit` is still running when the user switches selection, the old closure can finish **after** the factory has moved on—e.g. a `wrap(fetch(...))` for user `123` resolving while the UI already shows user `456`, and writing into the wrong world.
Add [`withAbort`](/reference/extensions#withabort) to the factory. On each recomputation it aborts pending `wrap()` work from the prior generation—inside `effect()`, `onEvent()`, actions, etc.:
```typescript
import { withAbort } from '@reatom/core'
export const editedUserForm = computed(() => {
// ...
}, 'editedUserForm').extend(withAbort())
```
`withAsyncData` already includes `withAbort`, so computeds that return a `Promise` get this behavior without extra wiring:
```typescript
const resource = computed(async () => {
// synchronous factory body, plus async work
}, 'resource').extend(withAsyncData())
```
For factories that return a **synchronous** model object, add `withAbort()` yourself. That single extension is what turns “replace the instance on input change” into “replace the instance **and** cancel leftover async work from the last one.” For factory-style computeds, it is usually the most important extension.
## A whole scoped model
[Section titled “A whole scoped model”](#a-whole-scoped-model)
Nothing stops the factory from returning a **small subsystem**: atoms, actions, effects, nested computeds—everything that belongs to one scope. They share one lifetime: when the factory recomputes, the old bundle is replaced and `withAbort` cancels work tied to it.
```typescript
import {
action,
atom,
computed,
effect,
reatomForm,
sleep,
withAbort,
withAsyncData,
wrap,
} from '@reatom/core'
const currentUserId = atom(null, 'currentUserId')
export const userSession = computed(() => {
const id = currentUserId()
if (id === null) return null
const profileForm = reatomForm(
{ name: '', email: '' },
{
onSubmit: (values) =>
wrap(
fetch(`/api/users/${id}/profile`, {
method: 'PUT',
body: JSON.stringify(values),
}),
),
name: `userSession.profileForm#${id}`,
},
)
const fetchStats = action(async () => {
return wrap(fetch(`/api/users/${id}/stats`).then((r) => r.json()))
}, `userSession.fetchStats#${id}`).extend(withAsyncData())
effect(async () => {
while (true) {
await wrap(sleep(30_000))
fetchStats()
}
}, `userSession.pollStats#${id}`)
const summary = computed(() => {
const stats = fetchStats.data()
return {
isProfileComplete: !!(
profileForm.fields.name() && profileForm.fields.email()
),
hasUnsaved: profileForm.focus().dirty,
hasNotifications: stats ? stats.notifications > 0 : null,
}
}, `userSession.summary#${id}`)
return { profileForm, fetchStats, summary }
}, 'userSession').extend(withAbort())
```
`withAbort()` on the outer computed is what makes the bundle safe. When `currentUserId` changes—logout, switch account, clear—the polling loop’s `wrap(sleep(...))` aborts, in-flight `fetchStats` cancels, and a stale `profileForm.submit()` does not land in the next user’s session. The next user gets a clean graph, not a race.
## Route loaders
[Section titled “Route loaders”](#route-loaders)
Routing is the **quintessence** of this pattern for many apps: it is where factories often show **maximum leverage**. A route already **is** a named, navigable scope: the URL says which layout, which screens, which data load. Anything rendered in the route’s outlet only exists **while the route matches**, so “read the loader data” naturally lines up with “you are inside the scope.”
Under the hood a route’s `loader` is a `computed`. That makes it an ideal factory host: state you create in the loader is **scoped to that route’s activation**, refreshed when params change, and a good fit for the **stricter** style (invalid reads are hard to reach because the outlet is the gate).
```typescript
import { reatomRoute, reatomForm, wrap } from '@reatom/core'
import { z } from 'zod'
const userRoute = reatomRoute({
path: 'users/:userId',
params: z.object({
userId: z.string().transform(Number),
}),
async loader(params) {
const user = await wrap(
fetch(`/api/users/${params.userId}`).then((r) => r.json()),
)
return user
},
})
export const userEditRoute = userRoute.reatomRoute({
path: 'edit',
async loader() {
const user = userRoute.loader.data()
const editForm = reatomForm(
{ name: user.name, bio: user.bio },
{
onSubmit: (values) =>
wrap(
fetch(`/api/users/${user.id}`, {
method: 'PUT',
body: JSON.stringify(values),
}),
),
name: `userEditForm#${user.id}`,
},
)
return { user, editForm }
},
})
```
Consumers use `userEditRoute.loader.data()?.editForm` from anywhere, but **meaningful** UI lives under the route. Navigating from `/users/123/edit` to `/users/456/edit` swaps the form; leaving the route stops depending on that loader. You did not hand-write a dispose callback—the **route is the scope**, and the loader is the factory. For nested loaders, params, and abort behavior, see the [Routing handbook](/handbook/routing).
## When the factory recomputes too often
[Section titled “When the factory recomputes too often”](#when-the-factory-recomputes-too-often)
Factories recompute when **any** dependency changes. If the factory reads something that changes often (search text, a tab, a filter), the whole inner model would be recreated and you would lose internal state.
Route loaders make this especially visible: **path and search** can both trigger the loader, so a tab in the query string might refetch and rebuild everything:
```typescript
const todosRoute = reatomRoute({
path: 'todos',
search: z.object({
tab: z.enum(['all', 'open', 'closed']).optional(),
}),
async loader(params) {
// ❌ This fetch runs every time `tab` changes.
const todos = await wrap(fetch('/api/todos').then((r) => r.json()))
// ❌ This computed is recreated on every tab change too.
const filteredList = computed(() => {
const tab = params.tab || 'all'
if (tab === 'all') return todos
if (tab === 'open') return todos.filter((t) => !t.completed)
return todos.filter((t) => t.completed)
}, 'filteredList')
return { todos, filteredList }
},
})
```
Three common fixes:
### Solution 1: Separate volatile inputs
[Section titled “Solution 1: Separate volatile inputs”](#solution-1-separate-volatile-inputs)
If the fast input is UI state, not a “new scope” signal, hold it in its own atom (e.g. `withSearchParams` for URL-backed UI) so the loader does not depend on it:
```typescript
import { atom, computed, withSearchParams } from '@reatom/core'
const todosRoute = reatomRoute({
path: 'todos',
async loader() {
const todos = await wrap(fetch('/api/todos').then((r) => r.json()))
return { todos }
},
})
const todosTab = atom<'all' | 'open' | 'closed'>('all', 'todosTab').extend(
withSearchParams('tab', (value) =>
value === 'all' || value === 'open' || value === 'closed' ? value : 'all',
),
)
const filteredTodos = computed(() => {
const todos = todosRoute.loader.data()?.todos ?? []
const tab = todosTab()
if (tab === 'all') return todos
if (tab === 'open') return todos.filter((t) => !t.completed)
return todos.filter((t) => t.completed)
}, 'filteredTodos')
```
The loader now tracks route entry/exit; the tab is a separate reactive input.
### Solution 2: Move the model out of the loader
[Section titled “Solution 2: Move the model out of the loader”](#solution-2-move-the-model-out-of-the-loader)
Put child computeds on dependencies that should **actually** recreate them. Route `.extend` is a good place—you get the route atom and can split “fetch once per match” from “derive from search”:
```typescript
import { computed, withAsyncData, wrap } from '@reatom/core'
const todosRoute = reatomRoute({
path: 'todos',
search: z.object({
tab: z.enum(['all', 'open', 'closed']).optional(),
}),
}).extend((target) => {
const todosResource = computed(async () => {
if (!target.match()) return []
return wrap(fetch('/api/todos').then((r) => r.json()))
}, `${target.name}.todosResource`).extend(withAsyncData({ initState: [] }))
const filteredList = computed(() => {
const todos = todosResource.data()
const tab = target()?.tab ?? 'all'
if (tab === 'all') return todos
if (tab === 'open') return todos.filter((t) => !t.completed)
return todos.filter((t) => t.completed)
}, `${target.name}.filteredList`)
return { todosResource, filteredList }
})
```
`todosResource` tracks match changes; `filteredList` reacts to the tab without refetching.
### Solution 3: Memoize inside the factory
[Section titled “Solution 3: Memoize inside the factory”](#solution-3-memoize-inside-the-factory)
Keep the structure inside the loader but wrap construction in [`memo`](/reference/methods#memo) so it only rebuilds on the dependencies **you** list:
```typescript
import { atom, computed, memo, withAsyncData, wrap } from '@reatom/core'
const todosRoute = reatomRoute({
path: 'todos',
search: z.object({
tab: z.enum(['all', 'open', 'closed']).optional(),
}),
async loader(params) {
const model = memo(() => {
todosRoute.match()
const todosResource = computed(async () => {
if (!todosRoute.match()) return []
return wrap(fetch('/api/todos').then((r) => r.json()))
}, `${todosRoute.name}.todosResource`).extend(
withAsyncData({ initState: [] }),
)
const search = atom('', `${todosRoute.name}.search`)
const filteredList = computed(() => {
let todos = todosResource.data()
const tab = params.tab || 'all'
if (tab === 'open') todos = todos.filter((t) => !t.completed)
else if (tab === 'closed') todos = todos.filter((t) => t.completed)
const searchState = search().toLowerCase()
return todos.filter((t) => t.title.toLowerCase().includes(searchState))
}, `${todosRoute.name}.filteredList`)
return { search, todosResource, filteredList }
})
return model
},
})
```
`memo` rebuilds the inner model only when atoms it reads (here `todosRoute.match()`) change; the outer loader may still run on search changes, but the memoized model can survive across those runs.
### Choosing a fix
[Section titled “Choosing a fix”](#choosing-a-fix)
* **Solution 1** — volatile input is independent UI (filters, sort, mode) and should not own the factory’s lifetime.
* **Solution 2** — you want a dedicated computed whose dependencies express “when to refetch” vs “when to re-derive.”
* **Solution 3** — you need fine control: some inputs rebuild the memoized block, others only drive inner reactions.
## Resetting on disconnect (optional)
[Section titled “Resetting on disconnect (optional)”](#resetting-on-disconnect-optional)
Factories are about **invalidating** when inputs change, not necessarily **freeing** memory when the last subscriber leaves. Factories are read from components, actions, `wrap()`, and other factories, so “no observers” is rarely the lifecycle you want.
If you do need it—for example clearing an in-memory cache when nothing subscribes—use [`withDisconnectHook`](/reference/extensions#withdisconnecthook) on specific atoms:
```typescript
import { atom, withDisconnectHook } from '@reatom/core'
const cache = atom(new Map(), 'cache').extend(
withDisconnectHook((target) => target.set(new Map())),
)
```
Use sparingly; [concurrency](#concurrency) is usually what matters.
## Next steps
[Section titled “Next steps”](#next-steps)
* [Routing handbook](/handbook/routing) — loaders, nesting, and how navigation interacts with factories.
* [Atomization](/handbook/atomization) — why fields that change independently should be atoms.
* [Forms handbook](/handbook/forms/introduction) — forms inside factories.
* [Async Context](/handbook/async-context) — `wrap()` and scoped async work.
* [`memo`](/reference/methods#memo) — controlling rebuilds inside atoms and actions.
# Events
> Documentation on events in Reatom
**Sampling states and events with atoms and actions: The reactive event pattern that will change how you think about data flow!**
I am the author of the Reatom state manager, and today I want to introduce you to one of the most powerful yet underappreciated patterns in reactive programming: sampling states and events using atoms and actions.
While most state managers force you to choose between imperative events or reactive state, Reatom bridges this gap with an elegant unification of both paradigms. This approach provides the clarity of event-driven programming with the consistency of reactive state management.
## The Problem with Traditional Approaches
[Section titled “The Problem with Traditional Approaches”](#the-problem-with-traditional-approaches)
Traditional state management typically falls into one of two categories:
1. **Event-driven approaches** where events trigger reactive streams (RxJS)
2. **State-driven approaches** where derived values automatically update (MobX, signals)
Each has its strengths, but also critical weaknesses. Event-driven systems need a lot of additional methods to handle complex state properly. Reactive systems with “excel” design fails with event tracking and proper async logic handling.
What if we could have the best of both worlds?
## Actions as Reactive Events: A Core Insight
[Section titled “Actions as Reactive Events: A Core Insight”](#actions-as-reactive-events-a-core-insight)
The key insight of Reatom is treating actions as first-class reactive events that can be both triggered and observed. Let’s see a simple example:
```javascript
import { atom, action } from '@reatom/core'
// Create an atom - a state container
const counter = atom(0, 'counter')
// Create an action - a callable function that also works as an event emitter
const increment = action((amount = 1) => {
counter.set(counter() + amount)
return counter()
}, 'increment')
// Subscribe to state changes
counter.subscribe((count) => {
console.log(`Counter is now: ${count}`)
})
// Call the action like a normal function
increment(10) // Counter is now: 10
```
So far, this looks like a typical action pattern. But here’s where Reatom’s unique perspective shines: **actions themselves are observable reactive entities**. This means you can subscribe to action calls just like you subscribe to atom changes:
```javascript
// Subscribe to action calls
increment.subscribe((calls) => {
console.log('Counter calls:', ...calls)
})
// Call the action like a normal function
increment()
increment(5)
// To the next tick:
// Counter calls: { params: [], payload: 11 }, { params: [5], payload: 16 }
```
This dual nature of actions as both callable functions and observable events creates a foundation for powerful patterns that are difficult to implement in other libraries.
## The `take` Operator: Awaiting Events Procedurally
[Section titled “The take Operator: Awaiting Events Procedurally”](#the-take-operator-awaiting-events-procedurally)
One of the most powerful capabilities this enables is the `take` operator. It allows you to wait for specific events or state changes in an asynchronous context, similar to redux-saga’s API but with native async/await syntax:
```javascript
import { take, wrap } from '@reatom/core'
const saveUser = action(async () => {
// Wait for specific user input
const userData = await wrap(take(submitUserForm))
// Submit to server
const response = await wrap(api.saveUser(userData))
if (response.success) {
// Wait for form confirmation
await wrap(take(confirmSave))
successAtom.set(true)
}
}, 'saveUser')
```
This allows you to describe complex workflows procedurally while still maintaining a reactive connection to your application’s events.
## The Problem with Traditional Event Sampling
[Section titled “The Problem with Traditional Event Sampling”](#the-problem-with-traditional-event-sampling)
In traditional state management, coordinating between events and state often requires complex state machines or tangled subscriptions. Consider this common scenario: you need to listen for an event but access the latest state at the moment the event occurs.
Here’s how it might look with traditional approaches:
```javascript
// Redux/traditional approach
const mapStateToProps = (state) => ({
filters: state.filters,
})
const mapDispatchToProps = (dispatch) => ({
onSearch: () => {
// Need to somehow get the current filters here...
const currentFilters = ???
dispatch(searchWithFilters(currentFilters))
}
})
```
Getting access to the current state when an event happens requires convoluted patterns like:
* Storing duplicate state in component
* Creating closure-based references
* Using refs in React
* Complex selector patterns
## Reatom’s Solution: Direct State Access
[Section titled “Reatom’s Solution: Direct State Access”](#reatoms-solution-direct-state-access)
Reatom provides a remarkably clean solution to this problem with direct state access. Within any action or reactive function, you can simply call any atom as a function to get its current value:
```javascript
import { atom, action, computed, wrap } from '@reatom/core'
const filtersAtom = atom({ text: '', category: 'all' }, 'filters')
const resultsAtom = atom([], 'results')
const search = action(async () => {
// Access any atom's current state directly
const filters = filtersAtom()
// Use that state in an API call
const results = await wrap(searchApi(filters))
// Update results
resultsAtom.set(results)
}, 'search')
// Attach to a button in UI
searchButton.addEventListener('click', wrap(search))
```
This eliminates an entire category of state management problems by making any state accessible at the point where it’s needed.
## Combining Actions and Atoms with `take`
[Section titled “Combining Actions and Atoms with take”](#combining-actions-and-atoms-with-take)
Now let’s see how these concepts come together to create truly elegant solutions to complex problems.
Imagine we’re building a form with real-time validation and a submit button that’s only enabled when validation passes. Here’s how we might approach this with `take` and the action-as-event pattern:
```javascript
import { atom, action, computed, take, wrap } from '@reatom/core'
// Form state atoms
const usernameAtom = atom('', 'username')
const passwordAtom = atom('', 'password')
const isValidAtom = computed(() => {
const username = usernameAtom()
const password = passwordAtom()
return username.length >= 3 && password.length >= 6
}, 'isValid')
// Form actions
const setUsername = action((value) => {
usernameAtom.set(value)
}, 'setUsername')
const setPassword = action((value) => {
passwordAtom.set(value)
}, 'setPassword')
const submit = action(async () => {
// Only proceed if form is valid
if (!isValidAtom()) {
return
}
const username = usernameAtom()
const password = passwordAtom()
// Submit form
await wrap(api.register({ username, password }))
}, 'submit')
// Now here's where the magic happens - a procedure that coordinates the whole form flow
const formFlowController = action(async () => {
// Wait until the form becomes valid
if (!isValidAtom()) {
await wrap(take(isValidAtom, (isValid, skip) => (isValid ? isValid : skip)))
console.log('Form is now valid')
}
// Wait for the submit button to be clicked
await wrap(take(submit))
console.log('Form submitted')
// Show success message and wait for user acknowledgment
const showSuccessMessageAtom = atom(false, 'showSuccessMessage')
showSuccessMessageAtom.set(true)
// Wait for a specific button click to close the success message
const closeButton = document.getElementById('closeSuccess')
await wrap(onEvent(closeButton, 'click'))
console.log('Flow complete')
}, 'formFlowController')
// Start managing the form
formFlowController()
```
See what happened here? We described a complex form flow with validation, submission, and success handling in a procedural way that’s easy to follow, yet it’s completely reactive. The code executes in response to events as they occur, without complex nested callbacks or state machine definitions.
## Advanced Pattern: Condition-Based Event Sampling
[Section titled “Advanced Pattern: Condition-Based Event Sampling”](#advanced-pattern-condition-based-event-sampling)
The `take` function allows for sophisticated filtering with its third parameter. This enables you to wait not just for events, but for events that meet specific criteria:
```javascript
import { wrap, take } from '@reatom/core'
// Wait for a specific navigation event
const routerAtom = atom('/home', 'router')
const destination = await wrap(
take(routerAtom, (path, skip) => (path === '/dashboard' ? path : skip)),
)
// Wait for a specific form submission
const formDataAtom = atom(null, 'formData')
const validFormData = await wrap(
take(formDataAtom, (data, skip) => (data?.isValid ? data : skip)),
)
```
This filtering capability eliminates the need for many conditional statements and allows for very declarative descriptions of complex flows.
## Combining Multiple Sources: Racing and Parallel Sampling
[Section titled “Combining Multiple Sources: Racing and Parallel Sampling”](#combining-multiple-sources-racing-and-parallel-sampling)
Sometimes you need to wait for any one of multiple possible events. Reatom makes this easy with techniques for racing between different events:
```javascript
import { race, take, wrap, sleep } from '@reatom/core'
// Form submission atoms
const formSubmitSuccessAtom = atom(false, 'formSubmitSuccess')
const cancelRequestedAtom = atom(false, 'cancelRequested')
const result = await wrap(
race({
success: take(formSubmitSuccessAtom, (value) => value === true),
cancel: take(cancelRequestedAtom, (value) => value === true),
timeout: sleep(5000),
}),
)
if (result.success) {
// Handle successful submission
} else if (result.cancel) {
// Handle cancellation
} else if (result.timeout) {
// Handle timeout
}
```
You can also wait for multiple events to occur in any order:
```javascript
import { all, take, wrap } from '@reatom/core'
// Data loading atoms
const profileLoadedAtom = atom(null, 'profileLoaded')
const preferencesLoadedAtom = atom(null, 'preferencesLoaded')
const [userProfile, userPreferences] = await wrap(
all([
take(profileLoadedAtom, (profile) => profile !== null),
take(preferencesLoadedAtom, (prefs) => prefs !== null),
]),
)
// Both have loaded, proceed
```
## Real-world Example: User Auth Flow
[Section titled “Real-world Example: User Auth Flow”](#real-world-example-user-auth-flow)
Let’s bring everything together with a real-world example - an authentication flow that includes login, verification, and redirection:
```javascript
import { atom, action, take, wrap, race, sleep, onEvent } from '@reatom/core'
// Auth state atoms
const userAtom = atom(null, 'user')
const loadingAtom = atom(false, 'loading')
const errorAtom = atom(null, 'error')
const twofaRequiredAtom = atom(false, '2faRequired')
// Login action
const login = action(async (credentials) => {
return await wrap(api.login(credentials))
}, 'login')
// 2FA verification action
const verify2FA = action(async (userId, code) => {
return await wrap(api.verify2FA(userId, code))
}, 'verify2FA')
// Authentication flow controller
const authFlow = action(async () => {
// Create a login form and get reference to its submit button
const loginForm = document.getElementById('loginForm')
const submitButton = loginForm.querySelector('button[type="submit"]')
// Wait for login attempt (when submit button is clicked)
await wrap(onEvent(submitButton, 'click'))
// Get form data
const formData = new FormData(loginForm)
const credentials = {
username: formData.get('username'),
password: formData.get('password'),
}
loadingAtom.set(true)
try {
// Attempt login
const user = await wrap(login(credentials))
userAtom.set(user)
// If 2FA is required, wait for verification code
if (user.requires2FA) {
twofaRequiredAtom.set(true)
// Get reference to verification form
const verificationForm = document.getElementById('2faForm')
const verifyButton = verificationForm.querySelector(
'button[type="submit"]',
)
// Wait for verification submission
await wrap(onEvent(verifyButton, 'click'))
// Get verification code
const verificationCode = document.getElementById('verificationCode').value
await wrap(verify2FA(user.id, verificationCode))
}
// Success! Wait for navigation or timeout
const result = await wrap(
race({
// Wait for a click on any navigation link
navigation: onEvent(document.querySelector('nav'), 'click'),
// Or timeout after 3 seconds
timeout: sleep(3000),
}),
)
// Auto-redirect if user hasn't navigated manually
if (result.timeout) {
window.location.href = '/dashboard'
}
} catch (error) {
errorAtom.set(error)
// Reset after error is acknowledged
const dismissButton = document.getElementById('dismissError')
await wrap(onEvent(dismissButton, 'click'))
errorAtom.set(null)
} finally {
loadingAtom.set(false)
}
}, 'authFlow')
// Start the auth flow controller when the app initializes
document.addEventListener(
'DOMContentLoaded',
wrap(() => {
authFlow()
}),
)
```
This example shows how Reatom allows you to describe complex, multi-step processes with branching logic in a way that’s readable, maintainable, and reactive.
## Benefits Over Traditional Approaches
[Section titled “Benefits Over Traditional Approaches”](#benefits-over-traditional-approaches)
Compared to other approaches, Reatom’s sampling pattern offers significant advantages:
1. **Readability**: Describe complex flows in a procedural style that’s easy to follow
2. **Maintainability**: No deeply nested callbacks or complex state machines
3. **Flexibility**: Combine reactive and imperative patterns seamlessly
4. **Type Safety**: Full TypeScript support with excellent inference
5. **Testing**: Easily isolate and test individual steps or entire flows
6. **Concurrency Control**: Built-in handling of race conditions
## Conclusion
[Section titled “Conclusion”](#conclusion)
The unification of events and state through Reatom’s action and atom primitives enables a uniquely powerful approach to managing application state and behavior. By treating actions as reactive events and providing tools like `take` for procedural event sampling, Reatom creates a programming model that’s both more expressive and simpler than traditional approaches.
This pattern is especially valuable for:
* Complex user flows and multi-step processes
* Form validation and submission
* Authentication and authorization
* API request coordination
* Animation sequences
Next time you find yourself building complex state logic with multiple steps and conditions, consider how Reatom’s event sampling approach might help you create code that’s more maintainable and easier to reason about.
The power of reactive events awaits!
# Extensions
> Documentation on the extension system in Reatom
## The Extension System
[Section titled “The Extension System”](#the-extension-system)
Reatom features a powerful **Extension System** using the `.extend()` method. Extensions are reusable functions (often named `with...`) that add capabilities (like async handling, persistence, validation, etc.) or derived state to atoms and actions.
## Build-in Extensions
[Section titled “Build-in Extensions”](#build-in-extensions)
> TODO link to references
`withInit`, `withComputed`, `withAbort` and many others.
## How Extensions Work
[Section titled “How Extensions Work”](#how-extensions-work)
1. **Extension Factory (`with...`)**: A function (e.g., `withReset(initialValue)`) that might take options and returns the actual *Extension Function*.
2. **Extension Function**: This function receives the target atom/action (`target`) and returns either:
* **An Assigner Object**: An object whose properties are merged onto the `target`. Function properties automatically become named, traceable actions (e.g., `withReset` adds `{ reset: /* action */ }`). The extension function should return this object directly.
* **The original target**: If you make some other transformations, like linking with other atoms or so on.
## Rules Of Extension
[Section titled “Rules Of Extension”](#rules-of-extension)
* For extension type interface name use `NameExt` pattern, to have an ability to combine a few extensions interface to a meta extension.
* To match an atom in the generic, extend `AtomLike` interface, (for only actions you can use `Action`).
* Always use the `withMiddleware` helper for middleware extensions. For assigner extensions, simply return the object to assign.
* Use `Ext` generic to match specific atom type, use `GenericExt` to match any atom (it isn’t require to write generic by yourself).
* Use `target.name` to compute relative names of additional atoms and actions.
## Assigner Extension
[Section titled “Assigner Extension”](#assigner-extension)
To assign some properties to the atom, just return an object from the extension.
```ts
import { atom, action, AtomLike, Action, Ext, AtomState } from '@reatom/core'
// Define the shape of the added properties
export interface ResetExt {
reset: Action<[], State>
}
// Extension Factory returning the assigner function directly
export const withReset =
(
// Get initial value type from Atom
initialValue: AtomState,
// Define extension input/output types
): Ext>> =>
// Return the extension function
(target) =>
// target is the atom being extended
// Return the object to assign
({
reset: action(
() => target.set(initialValue), // Action logic uses target and initialValue
`${target.name}.reset`, // Auto-naming based on target
),
})
// Usage:
const counter = atom(0, 'counter').extend(withReset(0))
counter.set(10)
counter.reset() // Works! State is 0.
```
Another example with additional state:
```ts
import {
atom,
AtomLike,
Computed,
Ext,
AtomState,
computed,
} from '@reatom/core'
export interface HistoryExt {
history: Computed<[current: State, ...past: Array]>
}
export const withHistory =
(
length = 2,
): Ext>> =>
(target) => {
type State = AtomState
type History = [current: State, ...past: Array]
return {
history: computed(
(state?: History) =>
[target(), ...(state || []).slice(0, length)] as History,
`${target.name}.history`,
),
}
}
```
**2. Middleware Extension (`withMiddleware`)**: Intercepts/modifies behavior.
```ts
import { isAction, withMiddleware, GenericExt } from '@reatom/core'
// Simple logging middleware
const withLogger = (): GenericExt =>
withMiddleware((target) => (next, ...params) => {
// just a state reading, do nothing
if (!isAction(target) && !params.length) return next()
console.log(`[${target.name}] Calling with:`, params)
const result = next(...params)
console.log(`[${target.name}] Result:`, result)
return result
})
// Usage:
const message = atom('', 'message').extend(withLogger())
message.set('Hello') // Logs call and new state
const greet = action((name: string) => `Hi, ${name}!`, 'greet').extend(
withLogger(),
)
greet('Reatom') // Logs call and return value
```
**Composition:** Apply multiple extensions easily:
```ts
const persistentCounter = atom(0, 'persistentCounter').extend(
withReset(0),
withLogger(),
// withPersist('counterKey') // Example using another hypothetical extension
)
```
### Middleware Order
[Section titled “Middleware Order”](#middleware-order)
When composing multiple middlewares, keep in mind that they wrap each other. The last extension passed to `extend` will be the outer-most wrapper, meaning it executes *first*.
```ts
const counter = atom(0).extend(
withMiddleware(() => (next, ...args) => {
console.log('inner')
return next(...args)
}),
withMiddleware(() => (next, ...args) => {
console.log('outer')
return next(...args)
}),
)
counter()
// Logs: "outer", then "inner"
```
***
Reatom provides many built-in extensions, explore the ecosystem and create your own to build powerful, reusable abstractions!
# Comparison
> Comparison with other form libraries
Legend:
* 🟢 - fully supported
* 🟡 - partial support
* 🔴 - not supported
Bundle size is calculated based on the core API and any other dependencies required for the library to function. The core API selection is approximate and may not be fully accurate.
| Feature | Reatom Form | [TanStack Form](https://tanstack.com/form/latest) | [React Hook Form](https://react-hook-form.com/) | [Formisch (ex Modular Forms)](https://formisch.dev/) |
| ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Github |  |  |  |  |
| Supported rendering frameworks | React, Preact, Vue, Solid, Lit, Reatom JSX | React, Vue, Angular, Solid, Lit | React | React, Solid, Vue, Svelte, Qwik, Preact |
| Bundle size | [](https://bundlejs.com?q=@reatom/core,@reatom/react\&treeshake=\[{+reatomField,reatomFieldSet,reatomForm,reatomFieldArray+}],\[{+reatomComponent,useAtom,bindField+}]\&config={%22esbuild%22:{%22external%22:\[%22react%22]}}) | [](https://bundlejs.com?q=@tanstack/react-form\&treeshake=\[{+useStore,useForm,useField,useFieldGroup+}]\&config={%22esbuild%22:{%22external%22:\[%22react%22]}}) | [](https://bundlejs.com?q=react-hook-form\&treeshake=\[{+useForm,useFormState,useFormContext,useWatch,watch,useFieldArray,useController,Controller,createFormControl+}]\&config={%22esbuild%22:{%22external%22:\[%22react%22]}}) | [](https://bundlejs.com?q=valibot,@formisch/react\&treeshake=\[{+object,string,number,pipe,email,minLength,maxLength+}],\[*]\&config={%22esbuild%22:{%22external%22:\[%22react%22]}}) |
| Decoupled form and field models **\*1** | 🟢 | 🔴 | 🔴 | 🔴 |
| Granular reactivity **\*2** | 🟢 | 🟢 \*2 | 🟢 \*2 | 🟢 \*2 |
| Standard Schema support | 🟢 | 🟢 | 🟢 | 🔴 |
| SSR support | 🟢 | 🟢 | 🔴 | 🔴 |
| Devtools | 🟡 \*3 | 🟢 | 🟢 | 🔴 |
| Field groups support | 🟢 | 🟢 | 🔴 | 🔴 |
| Highly optimized array fields | 🟢 | 🟡 \*4 | 🟡 \*4 | 🟡 \*4 |
| Built-in async validation and debounce | 🟢 | 🟢 | 🟡 \*5 | 🔴 |
| First-class support for dependent fields and reactive validation rules | 🟢 | 🟡 \*6 | 🔴 | 🔴 |
| Built-in fields input/output transformers | 🟢 | 🔴 | 🔴 | 🟢 |
| First-class support for abstract field components | 🟢 | 🟢 | 🟢 \*7 | 🔴 |
| First-class support for state persistence and cross-tab sync | 🟢 | 🔴 | 🔴 | 🔴 |
| Built-in element reference and focus management | 🟢 | 🔴 | 🟢 | 🟡 \*8 |
1. *Decoupled form and field models* - the form or field logic (state, validation, and field dependencies) is fully defined as a standalone entity outside of the UI framework’s lifecycle. It indicates whether the entire form/fields/group of fields model can be tested, reused, or binded to another framework without modifying the business logic, leaving the UI layer responsible only for data binding.
2. *Reactivity granularity* there is limited to static dependency lists, while ideal behavior would involve automatic tracking like in signal-based architectures
3. Only debug logger is available at this moment
4. List implementation may have performance issues when rendering a large number of elements and may be poorly optimized for virtualization
5. No built-in debounce and validation concurrency solution
6. Validators are currently limited to subscribing to the state of other fields or form submission events. However, validation rule reactivity implies the ability to subscribe to any data source to dynamically update the rules.
7. Available only as a separate package `@hookform/lenses`
8. There is a way to programmatically trigger focus on a field, but there is no access to the element reference itself
# Field array
In Reatom, a dynamic field list shares most traits with `reatomField` and is based on the `reatomLinkedList` primitive, which provides extremely high rendering performance and editing operations for dynamic field lists.
However, since fields and forms in Reatom are initialized outside the UI layer, and the initialized fields can receive various settings for establishing external reactive connections/persistence/etc, dynamic field initialization differs significantly from other libraries, and here we will examine this in detail.
## Initialization
[Section titled “Initialization”](#initialization)
There are several ways to initialize array fields:
### Array Literals
[Section titled “Array Literals”](#array-literals)
The simplest way is to specify array literals, which must have type information for correct type inference:
```ts
const emailsFieldArray = reatomFieldArray(['mail@example.com'], 'emails')
```
In this case, `reatomFieldArray` will know that you passed an array with a default `string` value, and this will be enough to build a dynamic field list with a single default item:
```ts
emailsFieldArray.array() // -> [FieldAtom]
```
But a default value is not always available. In that case, you can explicitly specify generics in `reatomFieldArray` or use typed arrays—it all depends on the use case:
```ts
const emailsFieldArray = reatomFieldArray([], 'emails')
// OR
const emailsFieldArray = reatomFieldArray(new Array(), 'emails')
```
#### Behavior when specifying object literals
[Section titled “Behavior when specifying object literals”](#behavior-when-specifying-object-literals)
Everything works quite obviously when specifying an array of primitive values. But what if we specify an array of objects containing primitives, or even other nested objects?
```ts
const contactsFieldArray = reatomFieldArray(
[{ name: 'John Doe', phone: '+14151234567' }],
'contacts',
)
contactsFieldArray.array() // -> [{ name: FieldAtom, phone: FieldAtom }]
```
The object fields simply… [got atomized](/handbook/atomization/)! This means that object keys at any nesting level will be automatically wrapped in `reatomField`.
Let’s say that’s the case, but what if you need to configure a field during its creation: specify an individual validation function, configure value transformation, add persistence? The following initialization method is suitable for this.
### Item Factory
[Section titled “Item Factory”](#item-factory)
When it comes to configuring a dynamically created field, the item factory, or the `create` parameter of `reatomFieldArray`, comes to the rescue:
```ts
const contactsFieldArray = reatomFieldArray(
(params: { name: string; phone: string; hidden: boolean }, elementName) => ({
name: params.name,
phone: reatomField(params.phone, {
name: `${elementName}.phone`,
validate: z.e164(), // zod built-in E.164 phone number validator
validateOnBlur: true,
}),
hidden: reatomBoolean(params.hidden, `${elementName}.hidden`).extend(
withField(),
),
}),
'contacts',
)
const newItem = contactsFieldArray.create({
name: 'John Doe',
phone: '+14151234567',
hidden: false,
})
newItem.name() // -> FieldAtom
newItem.phone() // -> FieldAtom
newItem.hidden() // -> BooleanAtom & FieldExt
```
Here we specified a function that describes how each element of the dynamic list will be created. Notable points here:
* We explicitly specified the `params` type to nominally indicate what the “parameters” for creating an element would look like during the element creation operation, or when initializing the original list elements. By the way, this could again be avoided in favor of type inference if at least one default list element was specified:
```diff
const contactsFieldArray = reatomFieldArray(
+ [{ name: '', phone: '', hidden: false }],
{
name: 'contacts',
+create: (params, name) => ({
name: params.name,
phone: reatomField(params.phone, {
name: `${name}.phone`,
validate: z.e164(), // zod built-in E.164 phone number validator
validateOnBlur: true
}),
hidden: reatomBoolean(params.hidden, `${name}.hidden`).extend(withField())
}),
}
)
```
* We left the `name` field with a primitive value from the `params.name` parameter. And this still means that for this part of the element **atomization will occur and a `reatomField` will be created in its place**. This is useful to avoid writing extra code for field initialization if no specific configuration is required.
* Technically, `FieldAtom` as a result of calling `reatomField` is also an object, but atomizing each property of this object would be a bug and unexpected behavior. Therefore, like primitive values, **`reatomField` is the atomization termination point**, and based on this, you can create fields with object values without automatic atomization of their keys.
```ts
const groupsFieldArray = reatomFieldArray(
[{ name: '', permissions: ['read'] }],
{
name: 'groups',
create: (params, name) => ({
name: params.name,
permissions: reatomField(params.permissions, `${name}.permissions`),
}),
},
)
const group = groupsFieldArray.create({
name: 'admin',
permissions: ['read', 'write', 'delete'],
})
group.name() // -> FieldAtom
group.permissions() // -> FieldAtom
```
## Validation behavior
[Section titled “Validation behavior”](#validation-behavior)
Although `reatomFieldArray` is a list of dynamic fields, this model is not aggregational like [`reatomFieldSet`](/handbook/forms/concepts/fieldset/#aggregate-atoms). This model contains its own separate `validation`, `focus` atoms and its own `initState` separately from the underlying fields or other dynamic field lists.
Therefore, there are some nuances in ensuring `reatomFieldArray` validation when working with validation schemas. Since `reatomFieldArray` is based on `reatomLinkedList`, the SoT state of field array is not an array but a `LinkedList` instance—a special structure that will always contain non-deatomized data, making schema validation not straightforward.
```ts
const emailsFieldArray = reatomFieldArray(['test@mail.com'], {
name: 'emails',
validate: z.array(z.any()).min(2, 'min'),
validateOnChange: true,
})
```
Schema validation works well here for validating the number of elements in the list, provided that we specify a `z.any()` contract for each element. It is also possible to describe a contract for a primitive field list using `z.transform`, but this won’t make sense because the schema won’t be able to react to state changes inside the dynamic list.
If the field list invariant depends on states inside the list, [reactive validation callback](/handbook/forms/concepts/reactive-validation/) will work:
```ts
const contactsFieldArray = reatomFieldArray(
[{ name: '', address: '', enabled: true }],
{
name: 'contacts',
validateOnChange: true,
validateOnConnect: true,
validate: ({ state }) => {
return state.every((group) => !group.enabled())
? 'At least one contact should be enabled'
: undefined
},
},
)
```
How this will work:
* `validateOnConnect` activates the first validation when the component that renders the field list is mounted
* The validation callback will be called to process validation, where a subscription to the `enabled` field will occur in a loop along with the first validation of the initial values
* Subsequent validations will occur when new elements are added and when the `enabled` field of each element changes
## Available Methods
[Section titled “Available Methods”](#available-methods)
Since field array or array literal in the fields definition are a syntactic sugar over `reatomLinkedList`, it provides several methods to manipulate the array of fields:
* `create(value)`: Adds a new field with the given value to the end of the array
* `remove(field)`: Removes a specific field from the array
* `clear()`: Removes all fields from the array
* `array()`: Returns an array of all fields, which you should use to iterate over the fields
* `swap(field1, field2)`: Swaps the positions of two fields in the array
* `move(field, targetField)`: Moves a field to a position after the target field (use null to move to the beginning)
* `find(predicate)`: Finds a field in the array that matches the predicate function
When rendering field arrays in UI components, you should always use the `.array()` method to iterate over the fields.
```ts
import { reatomForm } from '@reatom/core'
const emailsFieldArray = reatomFieldArray([], 'emails')
// Add a new email field
emailsFieldArray.create('')
// Access the array of email fields
const emailFields = emailsFieldArray.array()
// Iterate over the fields to render them
// In React, this would look like:
// {emailFields.map((emailField) => (
//
// ))}
// Remove a specific email field
emailsFieldArray.remove(emailFields[0])
// Clear all email fields
emailsFieldArray.clear()
```
Since we use the “field as model” approach and each field is an object, we can achieve maximum type safety by working directly with objects. But the cherry on top is atomization, a principle used by array fields that allows maintaining a high-quality type-safe experience at any level of nesting in your forms.
## Nested Array Fields
[Section titled “Nested Array Fields”](#nested-array-fields)
You can also create nested array structures:
```ts
const addressesFieldArray = reatomFieldArray([
{
street: '',
city: '',
tags: ['home'],
},
])
// Access nested fields
const addresses = addressesFieldArray.array()
const firstAddressTags = addresses[0]?.tags.array()
```
And you can use `FieldArrayItem` type helper to infer type of the field array item:
```tsx
import { reatomComponent, type FieldArrayItem } from '@reatom/core'
type AddressFieldType = FieldArrayItem
const AddressField = reatomComponent(
({ element }: { element: AddressFieldType }) => {
// ...
},
)
type AddressTagFieldType = FieldArrayItem
const AddressTagField = reatomComponent(
({ element }: { element: AddressTagFieldType }) => {
// ...
},
)
```
## Known limitations
[Section titled “Known limitations”](#known-limitations)
Currently, the `dirty` state calculation does not work quite accurately because `reatomLinkedList` does not yet support multiple lists with overlapping elements (when one element can be in two or more linked lists simultaneously). Currently, the dirty check is limited to only checking the number of elements between `initState` and the current field array state. This can lead to situations where, for example, when moving/swapping elements, the field will be considered untouched.
# Field atom
In many form validation libraries, fields exist only within forms and their lifecycle is tied to the form’s lifecycle, while access to fields is typically done through string dot notation, and field configuration has no single source of truth and can be spread across the entire application.
In Reatom, fields are independent entities with their own related states and methods that are fully open to configuration and composition. `reatomField` itself is a field state atom, to which other related states are assigned, such as `validation`, `focus` and others, as well as various methods like `change` and `reset`. Let’s examine each aspect of `reatomField` in more detail to build a complete picture and reactive model in your head.
```ts
import { reatomField } from '@reatom/core'
const fieldAtom = reatomField(0, 'fieldAtom')
fieldAtom() // -> number
fieldAtom.value() // -> number
fieldAtom.focus() // -> { active: boolean, dirty: boolean, touched: boolean }
fieldAtom.validation() // -> { error: string | undefined, triggered: boolean }
fieldAtom.change(123)
fieldAtom.reset()
```
## State and value
[Section titled “State and value”](#state-and-value)
The state is the key atom of the field, which `reatomField` returns as its value. Additionally, a `value` atom is attached to this atom, which is computed from the `state` atom (i.e., from the field’s state itself). The key difference between `state` and `value` is their purpose - `state` undergoes validation and should contain the pure field value, which is the definitive value from a business logic perspective, while `value` is a derived value primarily intended for UI display.
A couple of examples illustrating the dichotomy between these states for better understanding:
* In a select component, `state` will contain the actual value of the selected element, while `value` will contain the element’s text (i.e., its label)
* In a text field for selecting a birth date, the `state` will contain either a `Date` object or `null` if the value is invalid, while `value` will contain a string with arbitrary user input, which will be used for rendering in the text field. Thus, if the user enters a valid date, the `state` will receive a valid `Date`
To configure the `state -> value` transformation, you can define the `fromState` callback in the field creation options, and for the reverse transformation there is a `toState` callback.
```ts
const dateField = reatomField(null, {
name: 'dateField',
fromState: (state) => (state ? state.toString() : ''),
toState: (value) => {
if (!value) return null
const date = new Date(value)
return !isNaN(date.getTime()) ? date : null
},
})
```
### `toState` abort
[Section titled “toState abort”](#tostate-abort)
You can cancel state computation on `change` action call by throwing an abort error inside the callback. This is useful to make `state` and `value` independent of each other while preserving consistency when possible:
```ts
const numberField = reatomField(0, {
fromState: (state) => state.toString(),
toState: (value: string) => {
const parsed = Number(value)
return isNaN(parsed) ? throwAbort() : parsed
},
})
```
For this atom, any `value` string will be valid, but `state` will only be changed once the `value` becomes transformable to `state`.
### `fromState` reactivity
[Section titled “fromState reactivity”](#fromstate-reactivity)
Since the `fromState` transformer executes in the context of computing the `value` computed atom, it’s possible to reactively use atoms inside it, which allows maintaining the field state more consistently by adding new dependencies to `value`:
```ts
const dateMask = atom('MM.DD.YYYY', 'dateMask')
const dateField = reatomField(null {
name: 'dateField',
fromState: (state) => (state ? dayjs(state).format(dateMask()) : ''),
toState: (value) => {
if (!value) return null
const date = dayjs(value, dateMask())
return date.isValid() ? date.toDate() : null
},
})
dateField.change('08.20.2024')
dateField.value() // -> '08.20.2024'
dateMask.set('DD.MM.YYYY')
dateField.value() // -> '20.08.2024'
```
As you can see, both `dateField` and `dateField.value` are now bound to the `dateMask` atom. This means that whenever the mask format changes, the field value automatically transforms to match the new format, keeping both states synchronized without any manual intervention.
### `initState` atom
[Section titled “initState atom”](#initstate-atom)
This atom contains the initial field value that was passed as an argument to the `reatomField` constructor. This is a separate state against which the field’s dirty status is calculated, and which will be set as the current value for the field after calling the field’s `reset` method.
You can also modify this state using the `reset` method by passing an argument to it:
```ts
const usernameField = reatomField('later', 'usernameField')
const saveUsername = action(async (username: string) => {
await wrap(syncUsername(username))
usernameField.reset(username)
}, 'saveUsername').extend(withAsync())
```
## Focus atom
[Section titled “Focus atom”](#focus-atom)
All states related to field interaction are stored here.
```ts
export interface FieldFocus {
/** The field is focused. */
active: boolean
/** The field state is not equal to the initial state. */
dirty: boolean
/** The field has ever gained and lost focus. */
touched: boolean
}
```
By combining these statuses you can derive additional meta information:
* `!touched && active` - the field got focus for the first time
* `touched && active` - the field got focus again
```ts
export interface FocusAtom extends AtomLike {
/** Action for handling field focus. */
in: Action<[], FieldFocus>
/** Action for handling field blur. */
out: Action<[], FieldFocus>
}
```
Without these methods, we cannot maintain the `focus` atom in a consistent state. In rendering frameworks, these actions should be used as `focus` and `blur` events, otherwise, in addition to the inconsistency of the `focus` atom, we may lose the ability to validate the field when focus is lost.
## Validation atom
[Section titled “Validation atom”](#validation-atom)
This atom stores states related to field state validation. In addition to the validation error text of the field itself, it contains `triggered`, which always shows when validation was triggered and completed. But validation can also be asynchronous, so `validating` can contain a validation promise that will return a non-empty list of errors if validation fails.
```ts
export interface FieldValidation {
/** Message of the first validation error, computed from errors atom */
error: undefined | string
/** The validation actuality status. */
triggered: boolean
/** The field async validation status. */
validating: undefined | Promise<{ errors: FieldError[] }>
}
```
But these are read-only states and we cannot change them directly. Therefore, actions are provided that allow changing their state. By mutating the `errors` atom, we can influence the computation of the `validation` atom’s state, and this is especially pleasant because the `errors` atom is one of the basic `reatomArray` primitives, having convenient methods like `push`, `unshift` and others.
```ts
export interface ValidationAtom extends AtomLike {
/** Action to trigger field validation. */
trigger: Action<[], FieldValidation> & AbortExt
/** Full list of all errors related to the field */
errors: ArrayAtom
/** Action to clear all errors by passed sources. */
clearErrors: Action<[...sources: FieldErrorSource[]], FieldValidation>
}
```
The `trigger` action activates the field validation callback and returns the new state of the `validation` atom. It’s worth noting that despite field validation being potentially asynchronous, the `trigger` action itself does not return a promise, but returns the `.validating` prop which will provide a promise in case of asynchronous validation:
```ts
const result = await field.validation.trigger().validating
```
## Validation and concurrency
[Section titled “Validation and concurrency”](#validation-and-concurrency)
Like all form fields in the world, a field can have validation rules defined. For `reatomField`, validation rules consist of two parts: field validity checking through the `validate` callback in form creation options and defining validation trigger conditions. Speaking of the `validate` callback, it allows using both synchronous and asynchronous functions and even Standard Schema compatible validation schemas.
### Validation triggers
[Section titled “Validation triggers”](#validation-triggers)
By default, validation does not happen automatically and is only called programmatically through the `field.validation.trigger()` action, but it’s possible to configure validation triggers on certain events in the field creation options:
* `validateOnChange` - validation on value change
* `validateOnBlur` - validation on blur
* `validateOnConnect` - validation on connect (in other words, when “mounted”)
### Validation callback
[Section titled “Validation callback”](#validation-callback)
In any validation callback, you can either throw errors or return their message as string or a `FieldError` object, which allows setting arbitrary error sources and even metadata:
```ts
const usernameField = reatomField({
validate: ({ state }) => {
if (!state) return 'Username is required'
if (state.length < 3) {
return {
message: 'Username is too short',
source: 'validation',
meta: { minLength: 3 },
}
}
},
})
```
Also, since validation callback execution is an effect, the validation callback allows automatically tracking dependencies and re-calling itself when these dependencies change, just like all effects or computed values do. We call this [reactive validation](/handbook/forms/concepts/reactive-validation) and this pattern allows very elegant implementation of [dependent validation](/handbook/forms/recipes/dependent-validation)
### Async validation callback
[Section titled “Async validation callback”](#async-validation-callback)
The main feature of the async callback lies in concurrency handling. Each subsequent call of the async callback cancels the execution of the pending promise from the previous call. This opens up many possibilities, including implementing [debounce validation](/handbook/forms/recipes/async-validation-debounce)
### Combining both async and sync
[Section titled “Combining both async and sync”](#combining-both-async-and-sync)
Moreover, it’s possible to combine both synchronous and asynchronous validation in one field and this won’t color the validation function in case of synchronous validation:
```ts
const usernameField = reatomField({
validate: ({ state }) => {
if (!state) return 'Username is required' // `validation` atom will receive error synchronously
const checkUsernameIsFree = async () => {
const response = wrap(
await fetch(`/api/check-username?username=${state}`),
)
const data = await wrap(response.json())
return !!data
}
// `validation` atom won't receive error synchronously,
// but validation promise will be available in `.validating`
return checkUsernameIsFree()
},
})
```
When combining synchronous and asynchronous validations, pay attention to the function’s return type. The callback itself should never be asynchronous, but should return some promise in some of the validation code execution branches
### Standard schema
[Section titled “Standard schema”](#standard-schema)
Using such schemas is quite straightforward, you just need to pass a standard-compatible object there. The nicest thing here is that even in asynchronous validation schemas, Reatom will resolve concurrency by interrupting async function execution at `wrap` call sites.
Zod implementation caution
You cannot throw errors in `.refine` [according to documentation](https://zod.dev/api?id=refine) and the throwed error will not be propagated to the `reatomField` internals so we need a bunch of boilerplate to handle this.
```ts
const usernameSchema = z
.string()
.min(3)
.max(20)
.superRefine(async (value, { addIssue }) => {
try {
const response = await wrap(
fetch(`/api/check-username?username=${value}`),
)
const data = await wrap(response.json())
if (!data) {
addIssue({
code: 'custom',
message: 'Username is already taken',
})
}
} catch (error) {
if (!isAbort(error)) {
addIssue({
code: 'custom',
message: 'Error checking username',
})
}
}
})
const usernameField = reatomField({
validate: usernameSchema,
})
```
You can easily use standard schemas conditionally too:
```ts
const usernameField = reatomField({
validate: async ({ focus }) => (focus.touched ? usernameSchema : undefined),
})
```
### Error sources
[Section titled “Error sources”](#error-sources)
Any validation error in forms has a property `source`, which indicates what caused the validation error. By default, any errors that occurred during field validation through the `validate` option will receive the value `validation` as the `source`. Also, errors can appear in the field whose `source` value will be `schema`, in case the error occurred during validation by the schema from the [form](/handbook/forms/concepts/form/) that contains this field. Otherwise, nothing prevents you from using any other values as `source` if necessary
## Disabling fields
[Section titled “Disabling fields”](#disabling-fields)
When fields are disabled, their validation stops being triggered and they are [excluded from the validation and focus process of `reatomFieldSet`](/handbook/forms/concepts/fieldset/#fieldslist-and-fieldarrayslist). Additionally, when the field is properly bound to a DOM or other input (i.e., through the `bindField` method), the associated element also becomes disabled at the UI level.
Nothing prevents you from adding external reactive dependencies to the field’s disabled state if your form logic requires it:
```ts
const cartPrice = computed(
() => products().reduce((sum, p) => sum + p.price, 0),
'cartPrice',
)
const promoCodeField = reatomField(null, 'promoCodeField')
promoCodeField.disabled.extend(withComputed(() => cartPrice() < 100))
```
## Managing input element references
[Section titled “Managing input element references”](#managing-input-element-references)
Each field has its own associated `elementRef` atom, which contains a reference to the corresponding field element. This can be either a DOM element or any other element depending on the environment in which the field operates.
```ts
const usernameField = reatomField('', {
elementRef: document.querySelector('#username'),
})
```
However, the approach with a default `elementRef` value is only suitable when the DOM element is already created and known at the time of field creation. A much more common case is when the element is assigned upon the creation of the corresponding component:
```tsx
```
## `withField` extension
[Section titled “withField extension”](#withfield-extension)
This extension is a convenient way to make any atom as a form field without losing its original properties
```ts
const priorityField = reatomEnum(
['unset', 'low', 'high'],
'priorityField',
).extend(
withField({
validate: ({ state }) =>
state === 'unset' ? 'Priority is required' : undefined,
}),
)
// These actions are still available:
priorityField.setLow()
priorityField.setHigh()
priorityField.setUnset()
```
# Fieldset
Field sets allow you to group related fields together and manage them as a single unit. This is an 80% of `reatomForm` functionality since `reatomForm` is fully based on top of `reatomFieldSet`. This is also useful for organizing complex forms into logical sections such as [wizard (multi-step) forms](/handbook/forms/recipes/wizard-forms), [compound fields](/handbook/forms/recipes/compound-fields), or for tracking the combined state of multiple fields without creating a full form.
## Initialization
[Section titled “Initialization”](#initialization)
When creating a fieldset, you can initialize fields in several ways:
### Primitive values
[Section titled “Primitive values”](#primitive-values)
By passing a primitive value, you implicitly initialize a `reatomField` with that value as its default. This way is suitable when no individual options are needed for the field.
```ts
const fieldSet = reatomFieldSet(
{
username: '', // String field
age: 25, // Number field
isActive: true, // Boolean field
birthDate: new Date(), // Date field
},
'fieldSet',
)
```
### Existing `reatomField` instances
[Section titled “Existing reatomField instances”](#existing-reatomfield-instances)
You are free to attach existing fields to the field set. However, note that in this case, the fields will not receive naming scoped to the field set domain.
```ts
const usernameField = reatomField('', 'usernameField').extend(
withLocalStorage(),
)
const ageField = reatomField(25, 'ageField')
const fieldSet = reatomFieldSet(
{
username: usernameField,
age: ageField,
},
'fieldSet',
)
```
For better debugging experience, it is recommended to initialize fields directly within the field set’s initialization tree, initializing the name similar to how it is done in model factories. For this purpose, the field set’s `initState` accepts a callback with the `name` parameter:
```ts
const fieldSet = reatomFieldSet(
(name) => ({
username: reatomField('', `${name}.username`).extend(withLocalStorage()),
age: reatomField(25, `${name}.age`),
}),
'fieldSet',
)
```
Mind that you can also pass regular atoms extended by `withField` since they are full-fledged `reatomField` instances.
```ts
const form = reatomFieldSet(
(name) => ({
active: reatomBoolean(false, `${name}.active`).extend(withField()),
}),
'fieldSet',
)
```
### ~~Field options object~~
[Section titled “Field options object”](#field-options-object)
Caution
**Deprecated**. This legacy method of field initialization will be removed in the future major release due to type inference bugs and complexity. Use direct `reatomField` or `reatomFieldArray` definitions instead
By passing an object with `initState` property, you can initialize a `reatomField`/`reatomFieldArray` with a value as its default, and pass additional options for the field.
```ts
const form = reatomForm(
{
username: {
initState: '',
validateOnChange: true,
validate: ({ state }) => (state.length < 3 ? 'too short' : undefined),
},
age: {
initState: 25,
validateOnBlur: true,
},
},
'form',
)
form.username // <- FieldAtom
form.age // <- FieldAtom
```
## Aggregate Atoms
[Section titled “Aggregate Atoms”](#aggregate-atoms)
`reatomFieldSet` itself returns a computed atom that structurally contains all current values of the fields belonging to the field set.
```ts
form() // <- { username: string, age: number }
```
The fact that an atom is returned allows us to extend the field set with new behavior through the [extension system](/handbook/extensions/) by calling `.extend`
Field sets also create special `validation` and `focus` atoms, which are computed from all nested fields.
### `focus` atom
[Section titled “focus atom”](#focus-atom)
An aggregate of all `focus` atoms of all fields in the field set. If at least one field in the field set is `dirty`/`touched`/`active`, then the field set will also be considered `dirty`/`touched`/`active`.
### `validation` atom
[Section titled “validation atom”](#validation-atom)
```ts
export interface FieldSetFieldError extends FieldError {
field: FieldAtom
}
export interface FieldSetValidation {
errors: FieldSetFieldError[]
triggered: boolean
validating: undefined | Promise<{ errors: FieldSetFieldError[] }>
}
```
The behavior of the `validation` atom of a field set is slightly more complex due to validation mechanics.
1. **A field set is considered `triggered` if ALL fields in it have been `triggered`**. Therefore, you need to be careful if you tie the disabling of a form’s submit button to the `triggered` state of the field set: the button may be disabled even if all required fields in the form have already been filled, because some optional field may remain untouched, and therefore validation for such a field will not be `triggered`
> Some experts consider disabling submit buttons an [anti-pattern](https://gomakethings.com/dont-disable-buttons/)
2. In the `errors` state, as expected, all errors from all fields are accumulated, but with a reference to the field in which the error occurred
3. If at least one field of the field set is in the process of asynchronous validation, **then `validating` will store a `Promise`** that will wait for the completion of all asynchronous validations. The result will return a list of all errors as in the `errors` state
Just like with a regular field, this atom has a `trigger` action assigned to it, which sequentially calls `trigger` on all fields in the field set
### Behavior with disabled fields
[Section titled “Behavior with disabled fields”](#behavior-with-disabled-fields)
When fields are disabled, they no longer automatically trigger their own validation. In field sets, these disabled fields are excluded from the `validation` and `focus` computations, meaning they are not considered in the validation process according to the schema/etc. This ensures that disabled fields do not affect the validation status of the form or field set they belong to.
### `fieldsList` and `fieldArraysList`
[Section titled “fieldsList and fieldArraysList”](#fieldslist-and-fieldarrayslist)
These computed atoms contain a flat list of all `reatomField` and `reatomFieldArray` instances in the field set. Their size can change if dynamic field lists (`reatomFieldArray`) change their number of fields.
They serve as the foundation for the `validation` and `focus` atoms described above, and can be the basis for your own aggregates, or if you want to implement the [auto focus on error pattern](/handbook/forms/recipes/focus-management/)
### `init` action
[Section titled “init action”](#init-action)
Like in a regular `reatomField`, you can bulk update the `initState` of all fields in the field set by passing a nested structure of new initial field values.
```ts
registerForm.init({
username: 'newUsername',
email: 'newEmail',
})
```
This will not affect the visible state of the fields, but will affect what value they receive when the `reset` action is called
### `reset` action
[Section titled “reset action”](#reset-action)
This is the same as the `reset` method in `reatomField`, but for a field set. It will reset all fields in the field set to their initial values, and can also set initial values for them if you pass a structure of new initial field values like in the `init` action:
```ts
registerForm.reset({
username: 'newUsername',
email: 'newEmail',
})
```
## Field Sets as Lenses
[Section titled “Field Sets as Lenses”](#field-sets-as-lenses)
> This section is directly related to the main primitive [`reatomForm`](/handbook/forms/concepts/form). If you are not yet familiar with this API, it is recommended to familiarize yourself with it before studying this section.
One of the most useful properties, besides the fact that field sets allow you to create independent fragments of full-fledged forms consisting of groups of fields with their own separate isolated logic, is that **field sets can be used as lenses**.
Let’s assume we have the following form:
```ts
import { reatomForm, reatomFieldSet } from '@reatom/core'
import { z } from 'zod'
const checkoutForm = reatomForm(
{
personal: { firstName: '', lastName: '', email: '' },
shipping: { address: '', city: '', zipCode: '' },
},
{
name: 'checkoutForm',
validateOnBlur: true,
schema: z.object({
personal: z.object({
firstName: z.string(),
lastName: z.string(),
email: z.string(),
}),
shipping: z.object({
address: z.string(),
city: z.string(),
zipCode: z.string(),
}),
}),
},
)
```
When implementing wizard forms, we need to separate form filling into different steps, and the completeness/errors and other states of each step need to be tracked independently of each other. Field sets can help us with this: we will split the form so that each step is a separate field set, essentially creating lenses that focus on a separate group of form fields:
```ts
const personalInfoSet = reatomFieldSet(
checkoutForm.fields.personal,
'checkoutForm.personalInfoSet',
)
const shippingInfoSet = reatomFieldSet(
checkoutForm.fields.shipping,
'checkoutForm.shippingInfoSet',
)
```
But the most convenient way to implement this is through `.extend`:
```ts
const checkoutForm = reatomForm({
// ...
}).extend((target) => ({
personalInfoSet: reatomFieldSet(
target.fields.personal,
`${target.name}.personalInfoSet`,
),
shippingInfoSet: reatomFieldSet(
target.fields.shipping,
`${target.name}.shippingInfoSet`,
),
}))
checkoutForm.personalInfoSet.focus() // <- { active: false, dirty: false, touched: false }
checkoutForm.shippingInfoSet.validation() // <- { errors: [], triggered: false, validating: undefined }
```
Now we can use the `validation` atom to display the submit availability status, reset button for fields only for this step, and for validating the step according to the parent form’s validation schema
# Form
Are you ready for forms?
Before learning `reatomForm`, familiarize yourself with [the reatomFieldSet concept](/handbook/forms/concepts/fieldset/), since most of `reatomForm` functionality is based on it.
`reatomForm` is a small wrapper over `reatomFieldSet` that includes several important features that should be in any form:
1. The ability to submit a form
2. The ability to perform validation through validation schemas
3. The ability to add form-level validation rules
4. The ability to define some form options globally for all fields
Consequently, we can immediately determine that we are dealing with `reatomFieldSet` and therefore field initialization, aggregation atoms, and actions are already present inside.
## Submit
[Section titled “Submit”](#submit)
Forms have a built-in `submit` action, which itself is an [async action with the `withAsyncData` extension](/handbook/async/). When this action is triggered, the following happens:
1. First, validation of all fields is triggered (essentially, the field set’s `validation.trigger` method is called)
2. If the validation `schema` is defined in the form options, then schema validation is invoked
3. If the form options have a `validateBeforeSubmit` function, it is called next
4. If both validation stages pass successfully, the asynchronous `onSubmit` callback from the form options is called. The first argument receives the state of all field values in structure (essentially the state of the field set atom itself)
5. The `form.submitted` atom becomes `true` if no error occurred in `onSubmit`
```ts
const contactUsForm = reatomForm(
{
subject: reatomEnum(['support', 'complaint', 'other']).extend(withField()),
message: '',
},
{
name: 'contactUsForm',
onSubmit: async (state) => {
// ^ { subject: 'support' | 'complaint' | 'other', message: string }
},
},
)
await contactUsForm.submit()
contactUsForm.submitted() // <- true, because there were no errors
```
Handle `submit` call errors
The action is asynchronous and expects **error throwing**, and often when calling it, developers forget to handle errors by adding a `try catch` block or `.catch(noop)`.
Errors that occurred during form submission will be collected in the `submit.error` atom, but may also be reflected in the fields if they belong to them
### Custom params and return data
[Section titled “Custom params and return data”](#custom-params-and-return-data)
We leave the ability for the developer to define call parameters and return value for the `submit` action:
```ts
const blogPostForm = reatomForm(
{
title: '',
content: '',
},
{
name: 'blogPostForm',
onSubmit: async (state, action: 'draft' | 'publish') => {
const post =
action === 'draft'
? wrap(await api.saveDraft(state))
: wrap(await api.publishPost(state))
return post
},
},
)
const post = await blogPostForm.submit('draft')
```
### Concurrency
[Section titled “Concurrency”](#concurrency)
Since the `withAsyncData` extension also adds `withAbort` to async actions, the `submit` action supports concurrent execution. This means we can easily implement submit debouncing just as it can be implemented with [reatomField validation](/handbook/forms/recipes/async-validation-debounce/):
```ts
const exchangeRatesForm = reatomForm(
{
value: '',
currency: '',
},
{
name: 'exchangeRatesForm',
onSubmit: async (state) => {
await wrap(sleep(500))
return wrap(api.getExchangeRates(state))
},
},
)
```
Subsequent calls to `submit` will be cancelled if the previous call has not yet completed.
### `reset` side-effects
[Section titled “reset side-effects”](#reset-side-effects)
Calling the form’s `form.reset` action, in addition to calling the `reset` method of the form’s field set itself, resets the `form.submitted` state to `false`, and also cancels the execution of the `submit` action by throwing an abort error
## Standard Schema validation
[Section titled “Standard Schema validation”](#standard-schema-validation)
The [Standard Schema](https://standardschema.dev) contract is also supported by the `schema` parameter, so you can use many validation libraries that support it.
```ts
const registerForm = reatomForm(
{
email: '',
password: '',
dateOfBirth: '',
},
{
name: 'registerForm',
schema: z.object({
email: z.email(),
password: z.string().min(6),
dateOfBirth: z.coerce.number().int().positive(),
}),
onSubmit: async (state) => {
// ^ { email: string, password: string, age: number }
return wrap(api.register(state))
},
},
)
```
Now the first argument in `onSubmit` will be the result of parsing and validating the form data by the validation schema, considering all further transformations (in other words, the `Output` type).
Errors issued by the validation schema will be distributed across fields according to their `path`, that is, according to the keys in the schema
### Validation behavior
[Section titled “Validation behavior”](#validation-behavior)
Schema validation occurs automatically when validation of any form field is triggered, while validation occurs across the entire schema completely due to Standard Schema limitations, so editing one field can create a cascade of errors in all other fields that the user hasn’t even touched. How to manage this behavior is described in the [Errors UX recipe](/handbook/forms/recipes/errors-ux/)
You can also invoke form validation by schema programmatically through the `form.triggerSchemaValidation` action, which will validate all fields by schema and assign validation errors to each field
## Default options for fields
[Section titled “Default options for fields”](#default-options-for-fields)
All form fields acquire the options `validateOnChange`, `validateOnBlur`, `keepErrorOnChange`, `keepErrorDuringValidating` set on the form as default values, but each individual field can override these options. In other words, if a field has not defined one of these options, the form can define them itself, but not override already defined options
```ts
const applicationForm = reatomForm(
(name) => ({
name: '',
body: '',
images: reatomArray([], `${name}.images`).extend(
withField({
validateOnBlur: false,
validateOnChange: true,
validate: ({ state }) =>
!state.length ? 'Please add at least one image' : undefined,
}),
),
}),
{
name: 'applicationForm',
validateOnBlur: true,
},
)
```
In this example, all fields will be validated on blur, except for the `images` field: it has `validateOnBlur` disabled because for this type of input it doesn’t make sense since it’s not focusable, but `validateOnChange` is enabled
# Reactive validation
If you have reliable and flexible reactive primitives, why not use them?
Reactive validation is a feature of the `validation` callback that tracks changes in atoms used inside it under the hood, as if it were a true computed value (tl;dr, it is computed). This enables the magical ability to recalculate the field’s `validation` atom based on changes in the field’s external dependencies.
Overall, this process can be divided into three logical parts:
1. **first call of the `validate` callback**: for dependencies to start being tracked, the callback needs to be called once to collect them, which can be implemented by validation triggers such as `validateOnBlur`, for example;
2. **dependency tracking**: subsequent calls to `validate` occur due to dependency changes;
3. **applying changes**: the result of the callback invocation applies changes to the `validation` atom according to the callback’s behavior, as if `validation.trigger` were called.
> *Interesting fact: all of this is implemented through dynamic creation of an `effect` on each `validation.trigger` action call, which lives exactly until the next `validation.trigger` call and in which the `validate` callback is invoked. Dependencies inside this callback become the effect’s dependencies, then the effect applies changes to the field’s `validation` atom.*
Let’s implement a simple user registration form model:
```ts
import { reatomField, reatomForm } from '@reatom/core'
const form = reatomForm(
{
username: '',
password: '',
confirmPassword: reatomField('', {
validate: ({ state }): string | undefined =>
form.fields.password() != state ? 'Passwords do not match' : undefined,
}),
},
{
name: 'registerForm',
validateOnBlur: true,
},
)
```
In the `validate` callback of the `confirmPassword` field, we use the value of the `password` field: this is where the subscription to it happens. Now, on the first validation of `confirmPassword` (and for the form it’s defined that all fields are validated on blur `validateOnBlur: true`), the `confirmPassword` field will be revalidated on every change to the `password` field.
We can also choose not to subscribe to the `password` field immediately, but first check that the `confirmPassword` field is not empty:
```diff
import { reatomField, reatomForm } from '@reatom/core'
const form = reatomForm({
username: "",
password: "",
confirmPassword: reatomField("", {
validate: ({ state }): string | undefined => {
-form.fields.password() != state ? "Passwords do not match" : undefined
if (!state) return 'Confirm password is required';
return form.fields.password() != state ? "Passwords do not match" : undefined;
}
}),
}, {
name: 'registerForm',
validateOnBlur: true
});
```
This allowed us to avoid an unnecessary subscription to the `password` field while the `confirmPassword` field is empty.
# Introduction
> All about Reatom forms
This is a general form library with a simple focus and validation management.
The form API is designed for the best type-safety and flexibility. Instead of setting up the form state with a single object, each field is created separately, giving you the ability to fine-tune each field perfectly. As the field and its meta statuses are stored in atoms, you can easily combine them, define hooks, and effects to describe any logic you need.
The cherry on the cake is dynamic field management. You don’t need to use awkward string-based APIs like `form.${index}.property`. Instead, you work with actual objects that support straightforward interface definitions and seamless interaction at both the type-level and runtime.
The forms API is composed of the following primitives:
* [`reatomField`](/handbook/forms/concepts/field-atom): a simple yet powerful and flexible field model that encapsulates multiple related states and methods
* [`reatomFieldArray`](/handbook/forms/concepts/field-array): a primitive for dynamic field arrays that enables adding, removing, and reordering fields with a linked-list-like data structure
* [`reatomFieldSet`](/handbook/forms/concepts/fieldset): an aggregation primitive that combines multiple fields and manages them collectively
* [`reatomForm`](/handbook/forms/concepts/form): the form primitive itself - combining `reatomFieldSet`, schema-based validation, and submission functionality
By combining these primitives, you can construct form models of any complexity while maintaining framework agnosticism, simplifying testing, and achieving unprecedented levels of performance and flexibility.
In **React/Preact**, bind fields with `bindField` from `@reatom/react` or `@reatom/preact`. In **native JSX** (`@reatom/jsx`), use `