What: Framework-agnostic signal-based state manager with effect management
Version: v1000+ (@reatom/core@alpha)
Installation: pnpm add @reatom/core@alpha
Quick Reference:
- Source:
/tmp/reatom-v1000/packages/core/llms.md - Key advantage: Implicit context tracking, granular atomization, auto-cleanup
Table of Contents:
- Core Primitives
- Critical Pattern: wrap()
- Atomization Pattern
- Async State Management
- Server-Side Rendering (SSR)
- Client/Server Component Organization
- Creating Molecules (Reusable Services)
- Action Molecules Pattern (includes Jotai migration steps)
- Extensions
- React Integration
- Migration from Jotai
- API Quick Reference
Single piece of mutable state. Always provide a name.
const taskCount = atom(0, 'taskCount')
// Read
const value = taskCount() // -> 0
// Update using .set() method
taskCount.set(5) // Set to 5
taskCount.set(prev => prev + 1) // Increment to 6Lazy-evaluated derived value. Recalculates only when dependencies change and result is read.
const completedTasks = computed(() => taskCount() * 2, 'completedTasks')
const value = completedTasks() // -> 12 (if taskCount is 6)Encapsulates complex operations. Use for:
- Multiple state updates
- Side effects (API calls, localStorage)
- Complex business logic
const fetchUser = action(async (userId: string) => {
const response = await wrap(fetch(`/api/users/${userId}`))
const data = await wrap(response.json())
userName.set(data.name)
userEmail.set(data.email)
return data
}, 'fetchUser')
// Call it
fetchUser('123')When NOT to use actions:
// ❌ BAD: Wrapping simple updates
const setTaskCount = action((value: number) => {
taskCount.set(value)
}, 'setTaskCount')
// ✅ GOOD: Update directly
taskCount.set(10)Reactive side effects with automatic cleanup on abort context (unmount, signal cancellation).
const pollingEffect = effect(async () => {
console.log('Effect started')
try {
while (true) {
const data = await wrap(fetchProjects())
projectsAtom.set(data)
await wrap(sleep(5000))
}
} catch (error) {
if (isAbort(error)) {
console.log('Effect cleaned up')
}
}
}, 'pollingEffect')Use effect for:
- Reactive side effects (like computed, but with side effects)
- Automatic cleanup (WebSockets, intervals, timers)
- Component lifecycle-tied operations
- Background polling that auto-stops on unmount
Effect lifecycle:
// Effect runs when first accessed/subscribed
effect(async () => {
console.log('Started')
// Cleanup when component unmounts or effect is aborted
return () => console.log('Cleaned up')
}, 'myEffect')Effect patterns:
// Timer that auto-cleans
const tickerEffect = effect(async () => {
while (true) {
await wrap(sleep(1000))
nowAtom.set(Date.now())
}
}, 'tickerEffect')
// WebSocket with cleanup
const wsEffect = effect(async () => {
const ws = new WebSocket('wss://api.example.com')
await onEvent(ws, 'open')
console.log('Connected')
onEvent(ws, 'message', (event) => {
tasksAtom.set(JSON.parse(event.data))
})
// Cleanup on unmount
return () => {
ws.close()
console.log('Disconnected')
}
}, 'wsEffect')Listen to atom/computed changes. Callback runs immediately with current value.
const unsubscribe = taskCount.subscribe((value) => {
console.log('Task count:', value)
})
// Stop listening
unsubscribe()Reatom v1000 does NOT have a ctx.get() or similar API to read atoms outside their implicit context.
Atoms can only be read in two ways:
- Direct call inside Reatom context:
const value = myAtom() - Subscribe from outside:
myAtom.subscribe(value => { ... })
// ❌ BAD: No ctx.get() API exists
const ctx = reatomContext.get()
const value = ctx.get(myAtom) // Does not exist!
// ✅ GOOD: Call atom directly in Reatom context
const myComputed = computed(() => {
const value = myAtom() // Works inside computed/action/effect
return value * 2
}, 'myComputed')
// ✅ GOOD: Subscribe from outside
myAtom.subscribe((value) => {
console.log('Atom value:', value)
})You cannot read Reatom atoms from inside Jotai's get() function.
// ❌ BAD: Cannot read Reatom atom in Jotai context
const { projectsFilter } = mol(ReatomMolecule) // Reatom computed atom
const jotaiAtom = atom((get) => {
return get(projectsFilter as any) // Does not work!
})
// ✅ GOOD: Use Jotai molecule for Jotai TanStack Query integration
const { projectsFilterAtom } = mol(JotaiMolecule) // Jotai atom
const queryAtom = atomWithSuspenseQuery((get) => ({
queryKey: ['data', get(projectsFilterAtom)],
queryFn: async () => { /* ... */ }
}))Migration Rule: When migrating molecules that use atomWithQuery or atomWithSuspenseQuery, keep them using Jotai atoms for their dependencies. Don't try to bridge Reatom atoms into Jotai's query atoms.
ALWAYS use wrap() to preserve Reatom's implicit context across async boundaries.
Context is lost across:
awaitpromises.then()callbackssetTimeout/setInterval- Event handlers
// ❌ BAD: Context lost
action(async () => {
const response = await fetch('/api/tasks')
const data = await response.json()
tasks.set(data) // Throws: "Missed context"
}, 'fetchBad')()
// ✅ GOOD: Wrap promises
action(async () => {
const response = await wrap(fetch('/api/tasks'))
const data = await wrap(response.json())
tasks.set(data) // Works
}, 'fetchGood')()
// ✅ GOOD: Wrap entire promise chain
action(async () => {
const data = await wrap(fetch('/api/tasks').then(r => r.json()))
tasks.set(data) // Works
}, 'fetchGood2')()
// ✅ GOOD: Wrap callbacks
action(() => {
fetch('/api/tasks')
.then(r => r.json())
.then(wrap(data => {
tasks.set(data) // Works
}))
}, 'fetchGood3')()
// ✅ GOOD: Wrap event handlers (React)
<button onClick={wrap(myAction)}>Click</button>
<input onChange={wrap(e => myAtom.set(e.target.value))} />Rule: Wrap the final step that interacts with Reatom OR the callback function itself.
Break complex objects into granular atoms for efficient updates.
// ❌ BAD: Monolithic object
const user = atom({ id: '1', name: 'Alice', email: 'alice@example.com' })
// Update requires: user.set(prev => ({ ...prev, email: 'new@example.com' }))
// ✅ GOOD: Separate atoms
const userName = atom('Alice', 'userName')
const userEmail = atom('alice@example.com', 'userEmail')
// Compose if needed (read-only)
const user = { id: '1', name: userName, email: userEmail }
// Direct updates
userName.set('Bob')
userEmail.set('bob@example.com')For side effects (POST, PUT, DELETE). Tracks pending/error state.
const createTask = action(async (taskData) => {
await wrap(api.createTask(taskData))
}, 'createTask').extend(withAsync())
createTask.ready() // Atom<boolean>: true while running
createTask.error() // Atom<undefined | Error>: stores error
// createTask.onFulfill() / onReject() / onSettle() availableFor computed atoms fetching data (GET). Includes auto-abort on dependency change.
const projectId = atom('1', 'projectId')
const projectData = computed(async () => {
const id = projectId()
// Auto-cancelled if projectId changes
const response = await wrap(fetch(`/api/projects/${id}`))
if (!response.ok) throw new Error('Fetch failed')
return await wrap(response.json())
}, 'projectData').extend(withAsyncData())
projectData.data() // Atom<Data | undefined>: fetched data
projectData.ready() // Atom<boolean>: true while fetching
projectData.error() // Atom<undefined | Error>: fetch errorAuto-cancellation prevents race conditions and stale data.
Use withInit() to populate atoms with server-side data on first access.
// Server provides initial data
let serverUserData: User | null = null
export function initializeUserData(user: User) {
serverUserData = user
}
// Atom initializes from server data
const userData = atom<User | null>(null, 'userData').extend(
withInit(() => serverUserData)
)
// First access returns server data without fetching
const user = userData() // Uses serverUserDataWARNING!!! This pattern only for common data that should be available for everyone. Proper pattern WILL BE AVAILABLE LATER Pattern for page-level data:
// page-project.reatom.ts
let serverProjectData: Project = {} as Project
let serverTaskCount: number | undefined = undefined
export function initializeProjectPage(project: Project, taskCount?: number) {
serverProjectData = project
serverTaskCount = taskCount
}
export const pageProjectAtom = atom<Project>({} as Project, "pageProjectAtom").extend(
withInit(() => serverProjectData)
)
export const pageTaskCountAtom = atom<number | undefined>(undefined, "pageTaskCountAtom").extend(
withInit(() => serverTaskCount)
)Flow:
- Server fetches data
- Client-side hydrator calls
initializeProjectPage(serverData) - Atoms initialize with server data on first access
- No loading state needed for initial render
Pattern: Show data immediately, poll in background without loading indicators.
const projectTasksDataAtom = computed(async () => {
const project = projectAtom()
if (!project) return null
// Read refetch trigger to make this reactive
refetchTriggerAtom()
const response = await wrap(
fetch(`/api/projects/${project.id}/tasks`)
)
if (!response.ok) throw new Error("Failed to fetch")
return await wrap(response.json())
}, `projectTasksDataAtom`).extend(
withAsyncData(),
// Initialize with server data - no loading state!
withInit(() => {
const serverTaskCount = pageTaskCountAtom()
if (serverTaskCount !== undefined) {
return { count: serverTaskCount, tasks: [] }
}
return undefined
})
)
// Polling effect (runs in background)
const refetchTriggerAtom = atom(0, `refetchTrigger`)
const tasksRefetchEffect = effect(async () => {
while (true) {
await wrap(sleep(5000))
// Trigger refetch by incrementing counter
refetchTriggerAtom.set(refetchTriggerAtom() + 1)
}
}, `tasksRefetchEffect`)
// Smart loading indicator - only on initial fetch
const isLoadingTasksAtom = computed(() => {
const ready = projectTasksDataAtom.ready()
const data = projectTasksDataAtom.data()
const serverTaskCount = pageTaskCountAtom()
// If we have server data, never show loading
if (serverTaskCount !== undefined) return false
// Only loading on initial fetch (data is null)
return !ready && data === null
}, `isLoadingTasksAtom`)Key points:
withInit()provides immediate data from server- Effect polls every 5 seconds by incrementing trigger
- Loading indicator only shows when no data exists
- Refetches don't show loading (stale data stays visible)
Extract polling pattern into reusable extension:
import type { Ext } from '@reatom/core'
import { effect, sleep, wrap, atom } from '@reatom/core'
export const withPolling = <T extends AtomLike>(
intervalMs: number
): Ext<T> => {
return (target) => {
const refetchTrigger = atom(0, `${target.name}.refetchTrigger`)
const pollingEffect = effect(async () => {
while (true) {
await wrap(sleep(intervalMs))
refetchTrigger.set(refetchTrigger() + 1)
}
}, `${target.name}.pollingEffect`)
return {
refetchTrigger,
pollingEffect,
}
}
}
// Usage
const userData = computed(async () => {
// This computed will re-run every 5 seconds
const response = await wrap(fetch('/api/user'))
return await wrap(response.json())
}, 'userData').extend(
withAsyncData(),
withPolling(5000) // Poll every 5s
)React Query -> Reatom equivalents:
// React Query
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
refetchInterval: 5000,
})
// Reatom equivalent
const userId = atom('1', 'userId')
const userData = computed(async () => {
const id = userId()
const response = await wrap(fetch(`/api/users/${id}`))
return await wrap(response.json())
}, 'userData').extend(
withAsyncData(),
withPolling(5000), // Refetch every 5s
withInit(() => serverUserData) // SSR data
)
// In component
const data = userData.data()
const isLoading = userData.ready()
const error = userData.error()Benefits over React Query:
- Smaller bundle size
- Framework-agnostic
- Better TypeScript inference
- Granular subscriptions (only re-render on actual data changes)
- Simpler API, no query keys
React Query features -> Reatom:
| React Query | Reatom |
|---|---|
useQuery |
computed(...).extend(withAsyncData()) |
useMutation |
action(...).extend(withAsync()) |
queryKey dependency |
Computed dependencies (automatic) |
refetchInterval |
.extend(withPolling(ms)) |
initialData |
.extend(withInit(() => data)) |
enabled |
Conditional computed |
onSuccess |
.extend(withAsync()).onFulfill() |
onError |
.extend(withAsync()).onReject() |
WARNING!!! This pattern only for common data that should be available for everyone. Proper pattern WILL BE AVAILABLE LATER
Reatom works seamlessly with SSR through the page atoms pattern:
- Server: Fetches data in
asynclayout/page components - Hydrator: Client component initializes atoms with server data
- Atoms: Use
withInit()to consume server data - Components: Render immediately with no loading states
File: page-project.reatom.ts (Page-specific atoms)
import { atom, withInit } from "@reatom/core"
import type { Project } from "@/types"
// Module-level storage for server data
let serverProjectData: Project = {} as Project
let serverTaskCount: number | undefined = undefined
/**
* Initialize page atoms with server data
* Called from hydrator before atoms are accessed
*/
export function initializeProjectPage(project: Project, taskCount?: number) {
serverProjectData = project
serverTaskCount = taskCount
}
/**
* Page project atom - auto-initializes from server data
* Non-nullable since always populated before use
*/
export const pageProjectAtom = atom<Project>({} as Project, "pageProjectAtom").extend(
withInit(() => serverProjectData)
)
/**
* Page task count atom - initializes from server, updates on client
*/
export const pageTaskCountAtom = atom<number | undefined>(undefined, "pageTaskCountAtom").extend(
withInit(() => serverTaskCount)
)File: layout.tsx (Server Component)
import { cache } from "react"
import { getApiClient } from "@/app/api-client"
import { PageProjectHydrator } from "./page-project-hydrator"
// Cached fetcher - deduplicates requests
const getProjectData = cache(async (projectId: string) => {
const apiClient = getApiClient()
const response = await apiClient.api.projects[":id"].$get({
param: { id: projectId },
})
if (!response.ok) return null
return response.json()
})
const getTaskCount = cache(async (projectId: string) => {
const apiClient = getApiClient()
const response = await apiClient.api.projects[":id"].tasks.count.$get({
param: { id: projectId },
})
if (!response.ok) return undefined
const data = await response.json()
return data.count
})
export default async function ProjectLayout(props: Props) {
const projectId = await extractProjectIdFromParams(props.params)
// Fetch data in parallel on server
const [project, taskCount] = await Promise.all([
getProjectData(projectId),
getTaskCount(projectId),
])
if (!project) throw new Error("Project not found")
return (
<PageProjectHydrator project={project} taskCount={taskCount}>
{props.children}
</PageProjectHydrator>
)
}File: page-project-hydrator.tsx (Client Component)
"use client"
import { type ReactNode, useMemo } from "react"
import { initializeProjectPage } from "./page-project.reatom"
import type { Project } from "@/types"
interface Props {
project: Project
taskCount?: number
children: ReactNode
}
/**
* Hydrates page atoms with server data
* Must be client component to call initialization
*/
export function PageProjectHydrator({ project, taskCount, children }: Props) {
// Initialize once per project
useMemo(() => {
initializeProjectPage(project, taskCount)
}, [project.id, taskCount])
return <>{children}</>
}Client components can access page atoms directly:
"use client"
import { reatomComponent } from "@reatom/react"
import { pageProjectAtom, pageTaskCountAtom } from "./page-project.reatom"
export const ProjectInfo = reatomComponent(() => {
const project = pageProjectAtom()
const taskCount = pageTaskCountAtom()
// Data available immediately - no loading state!
return (
<div>
<h1>{project.title}</h1>
<p>Tasks: {taskCount ?? 0}</p>
</div>
)
})Page atoms (for SSR data):
- Store server-fetched data
- Initialize with
withInit() - Live at page/route level
- Non-nullable (always have data)
Regular atoms (for client state):
- Created in molecules/stores
- May fetch additional data
- Can reference page atoms
- May use page atoms as initial values
Example - Molecule using page atoms:
// project-store.reatom.ts
import { molecule, atom, computed } from '@reatom/core'
import { pageProjectAtom, pageTaskCountAtom } from './page-project.reatom'
export const ProjectMoleculeReatom = molecule((mol, scope) => {
// Get project from page atom (or scope for reusable components)
const scopeProject = scope(ProjectScope)
const project = scopeProject || pageProjectAtom()
if (!project) {
throw new Error('Project data not initialized')
}
// Create local atoms from page data
const projectAtom = atom({
id: atom(project.id),
title: atom(project.title),
// ... atomize the project object
}, `project-${project.id}`)
// Use page task count as initial value
const taskCountAtom = computed(() => {
const asyncData = projectTasksDataAtom.data()
const serverData = pageTaskCountAtom()
// Prefer fresh data, fall back to server data
return asyncData?.count ?? serverData ?? 0
}, 'taskCountAtom')
return { projectAtom, taskCountAtom }
})File: app/layout.tsx (Root layout)
import { reatomContext } from '@reatom/react'
import { context } from '@reatom/core'
export default function RootLayout({ children }: Props) {
return (
<html>
<body>
<reatomContext.Provider value={context.start()}>
{children}
</reatomContext.Provider>
</body>
</html>
)
}IMPORTANT: Put Reatom provider at app root, not in page layouts. This ensures:
- Single context for entire app
- Atoms persist across page navigations
- No context recreation on route changes
-
Server data -> Page atoms
// Good: Page-level atoms for server data export const pageProjectAtom = atom(...).extend(withInit(() => serverData))
-
Initialize before access
// Good: Hydrator calls init before children render <PageProjectHydrator project={serverProject}> <ProjectWidget /> {/* Can access pageProjectAtom */} </PageProjectHydrator>
-
Avoid loading states for SSR data
// Good: Check for server data first const isLoading = computed(() => { const serverData = pageProjectAtom() if (serverData) return false // Have server data! const asyncData = fetchedDataAtom.data() return !fetchedDataAtom.ready() && !asyncData })
-
Polling without loading flicker
// Good: withInit + polling effect const data = computed(async () => { refetchTrigger() // Reactive to polling return await wrap(fetch(...)) }).extend( withAsyncData(), withInit(() => serverData), // No initial loading! withPolling(5000) )
Server components: Static structure, data fetching Client components: Interactivity, context consumers
Before (all client):
"use client" // Everything is client-side
export function TaskListWidget() {
const project = pageProjectAtom()
const tasks = project.tasks
return (
<div>
{tasks.map(task => (
<Card key={task.id}>
<h3>{task.title}</h3>
<TaskStatus task={task} />
<CompleteButton task={task} />
</Card>
))}
</div>
)
}After (split server/client):
File: task-list-widget-server.tsx (Server Component)
// No "use client" - this is a server component!
import { TaskStatusClient } from './task-status-client'
import { CompleteButtonClient } from './complete-button-client'
interface Props {
project: Project
taskCount?: number
}
export function TaskListWidget({ project, taskCount }: Props) {
// Calculate on server (no context needed)
const hasTasks = project.tasks.length > 0
const now = Date.now()
const overdueTasks = project.tasks.filter(
t => new Date(t.dueDate).getTime() < now && !t.completed
)
return (
<div className="flex flex-col gap-4">
{project.tasks.map((task, index) => (
<Card key={task.id}>
<div className="flex justify-between">
<h3>{task.title}</h3>
{/* Client component for dynamic status */}
<TaskStatusClient
taskIndex={index}
task={task}
isOverdue={overdueTasks.includes(task)}
/>
</div>
<p>{task.description}</p>
{/* Client component for interactivity */}
<CompleteButtonClient
taskIndex={index}
taskId={task.id}
/>
</Card>
))}
</div>
)
}File: task-status-client.tsx (Client Component)
"use client"
import { reatomComponent } from '@reatom/react'
import { useTaskStatusReatom } from '../store/hooks.reatom'
import { Skeleton } from '@ui/components'
import { TaskStatus } from '@/types'
interface Props {
taskIndex: number
task: Task
isOverdue: boolean
}
export const TaskStatusClient = reatomComponent<Props>(({
taskIndex,
task,
isOverdue,
}) => {
const { lastActivityDataAtom } = useTaskStatusReatom()
const isLoading = !lastActivityDataAtom.ready()
const lastActivity = lastActivityDataAtom.data()
// Only show skeleton when actually needed
const needsActivity = task.status === TaskStatus.InProgress && isOverdue
if (isLoading && needsActivity) {
return <Skeleton className="h-5 w-24" />
}
return <StatusBadge task={task} lastActivity={lastActivity} />
})File: complete-button-client.tsx (Client Component)
"use client"
import { reatomComponent } from '@reatom/react'
import { useMolecule } from 'bunshi/react'
import { TaskMoleculeReatom } from '../store/task-store.reatom'
import { Button } from '@ui/components'
interface Props {
taskIndex: number
taskId: string
}
export const CompleteButtonClient = reatomComponent<Props>(({
taskIndex,
taskId,
}) => {
const { completeAction, isCompleteDisabledAtom } = useMolecule(TaskMoleculeReatom)
const isDisabled = isCompleteDisabledAtom()
return (
<Button
disabled={isDisabled}
onClick={wrap(() => completeAction(taskIndex))}
>
Complete
</Button>
)
})Pattern 1: Server shell + Client slots
// server-component.tsx (no "use client")
export function FolderList({ folders }: Props) {
return (
<div>
<h2>Folders ({folders.length} items)</h2>
{folders.map(folder => (
<div key={folder.id}>
<span>{folder.name}</span>
{/* Client component for interactivity */}
<DeleteButtonClient folderId={folder.id} />
</div>
))}
<CreateFolderButtonClient />
</div>
)
}Pattern 2: Conditional client components
// server-component.tsx
export function TaskPhase({ task }: Props) {
const now = Date.now()
const isActive = new Date(task.startDate).getTime() <= now
return (
<Card>
<h3>{task.title}</h3>
{/* Server-rendered priority */}
<p>Priority: {task.priority}</p>
{/* Conditional client components */}
{isActive ? (
<CompleteButtonClient taskId={task.id} />
) : (
<ScheduleButtonClient startDate={task.startDate} />
)}
</Card>
)
}Pattern 3: Data prop drilling
// Pass server data as props to client components
// Avoids unnecessary atom access
// server-component.tsx
export async function UserProfile({ userId }: Props) {
const user = await fetchUser(userId) // Server fetch
return (
<div>
<h1>{user.name}</h1>
{/* Pass data as props instead of using atoms */}
<EditButtonClient
userId={user.id}
userName={user.name}
/>
</div>
)
}
// edit-button-client.tsx
"use client"
export const EditButtonClient = reatomComponent<Props>(({
userId,
userName,
}) => {
const { updateUserAction } = useMolecule(UserMolecule)
return (
<Button onClick={wrap(() => updateUserAction(userId, userName))}>
Edit
</Button>
)
})-
Maximize server components
- Static content, layout, structure
- Data fetching (use
asynccomponents) - SEO-critical content
-
Minimize client components
- Only for: interactivity, hooks, context
- Keep them small and focused
- Extract static parts to server
-
Data flow: Server -> Props -> Client
// Good: Server fetches, props to client export async function Page() { const data = await fetchData() return <ClientWidget data={data} /> } // Bad: Client fetches what server could have "use client" export function Page() { const data = useQuery(...) return <Widget data={data} /> }
-
Avoid premature "use client"
// Bad: Entire component is client "use client" export function TaskCard({ task }) { return ( <Card> <h3>{task.title}</h3> <p>{task.description}</p> <CompleteButton taskId={task.id} /> </Card> ) } // Good: Only button is client export function TaskCard({ task }) { return ( <Card> <h3>{task.title}</h3> <p>{task.description}</p> <CompleteButtonClient taskId={task.id} /> </Card> ) }
-
Use page atoms for SSR data
// Good: Server data -> page atoms -> client // layout.tsx (server) const data = await fetchData() return <Hydrator data={data}>{children}</Hydrator> // hydrator.tsx (client) "use client" useMemo(() => initPageAtoms(data), [data]) // component.tsx (client) const data = pageDataAtom() // No loading!
Molecules are reusable state containers that encapsulate related atoms, computed values, and actions. Use the factory pattern with create* prefix for new molecules.
A well-structured molecule follows this pattern:
import { molecule } from "bunshi"
import { atom, action, computed, wrap } from "@reatom/core"
// 1. Types - define interfaces at the top
interface TaskData {
id: string
title: string
status: "todo" | "in_progress" | "done"
assigneeId: string | null
}
// 2. Molecule definition
export const TaskEditorMolecule = molecule((mol) => {
// 3. Dependencies - inject other molecules
const { currentUserIdAtom } = mol(UserMolecule)
const { apiClient } = mol(ApiClientMolecule)
// 4. Core state atoms (granular, atomized)
const taskIdAtom = atom<string | null>(null, "taskEditor.taskId")
const titleAtom = atom("", "taskEditor.title")
const statusAtom = atom<TaskData["status"]>("todo", "taskEditor.status")
const assigneeIdAtom = atom<string | null>(null, "taskEditor.assigneeId")
// 5. UI state atoms
const isLoadingAtom = atom(false, "taskEditor.isLoading")
const isSavingAtom = atom(false, "taskEditor.isSaving")
const errorAtom = atom<string | null>(null, "taskEditor.error")
// 6. Computed values (derived state)
const isOwnTaskAtom = computed(() => {
const currentUserId = currentUserIdAtom()
const assigneeId = assigneeIdAtom()
return currentUserId === assigneeId
}, "taskEditor.isOwnTask")
const canEditAtom = computed(() => {
const isOwn = isOwnTaskAtom()
const status = statusAtom()
return isOwn && status !== "done"
}, "taskEditor.canEdit")
// 7. Initialize action - hydrate from server data
const initialize = action((data: TaskData) => {
taskIdAtom.set(data.id)
titleAtom.set(data.title)
statusAtom.set(data.status)
assigneeIdAtom.set(data.assigneeId)
errorAtom.set(null)
}, "taskEditor.initialize")
// 8. Mutation actions
const updateTitle = action((title: string) => {
titleAtom.set(title)
}, "taskEditor.updateTitle")
const save = action(async () => {
const taskId = taskIdAtom()
if (!taskId) return
isSavingAtom.set(true)
errorAtom.set(null)
try {
await wrap(apiClient.tasks.update(taskId, {
title: titleAtom(),
status: statusAtom(),
assigneeId: assigneeIdAtom(),
}))
} catch (err) {
errorAtom.set(err instanceof Error ? err.message : "Failed to save")
throw err
} finally {
isSavingAtom.set(false)
}
}, "taskEditor.save")
// 9. Cleanup action (optional)
const cleanup = action(() => {
taskIdAtom.set(null)
titleAtom.set("")
statusAtom.set("todo")
assigneeIdAtom.set(null)
errorAtom.set(null)
}, "taskEditor.cleanup")
// 10. Return organized exports
return {
// State
taskIdAtom,
titleAtom,
statusAtom,
assigneeIdAtom,
// UI State
isLoadingAtom,
isSavingAtom,
errorAtom,
// Computed
isOwnTaskAtom,
canEditAtom,
// Actions
initialize,
updateTitle,
save,
cleanup,
}
})
// 11. Export types if needed externally
export type { TaskData }1. Entity Molecules - Core data for a domain entity
// stream-info.molecule.ts
export const StreamInfoMolecule = molecule(() => {
// Atomized entity fields (granular updates)
const idAtom = atom<string | null>(null, "streamInfo.id")
const titleAtom = atom("", "streamInfo.title")
const descriptionAtom = atom<string | null>(null, "streamInfo.description")
const categoryAtom = atom<Category | null>(null, "streamInfo.category")
// Initialize from server data
const initialize = action((data: StreamEntityData) => {
idAtom.set(data.id)
titleAtom.set(data.title)
descriptionAtom.set(data.description)
categoryAtom.set(data.category)
}, "streamInfo.initialize")
return {
idAtom,
titleAtom,
descriptionAtom,
categoryAtom,
initialize,
}
})2. Feature Molecules - Specific feature logic (likes, follows, etc.)
// stream-actions.molecule.ts
export const StreamActionsMolecule = molecule((mol) => {
const { addressAtom } = mol(WalletMolecule)
// Feature state
const likeCountAtom = atom(0, "actions.likeCount")
const isLikedAtom = atom(false, "actions.isLiked")
const isShareModalOpenAtom = atom(false, "actions.isShareModalOpen")
// Optimistic update pattern
const toggleLike = action(async () => {
const streamId = streamIdAtom()
const userAddress = addressAtom()
if (!streamId || !userAddress) return
const wasLiked = isLikedAtom()
// Optimistic update
isLikedAtom.set(!wasLiked)
likeCountAtom.set(prev => wasLiked ? prev - 1 : prev + 1)
try {
const response = await wrap(
fetch(`/api/streams/${streamId}/likes`, {
method: wasLiked ? "DELETE" : "POST",
body: JSON.stringify({ userAddress }),
})
)
if (!response.ok) {
// Revert on error
isLikedAtom.set(wasLiked)
likeCountAtom.set(prev => wasLiked ? prev + 1 : prev - 1)
}
} catch {
// Revert on error
isLikedAtom.set(wasLiked)
likeCountAtom.set(prev => wasLiked ? prev + 1 : prev - 1)
}
}, "actions.toggleLike")
return { likeCountAtom, isLikedAtom, toggleLike, ... }
})3. Service Molecules - Infrastructure services (wallet, API client, etc.)
// wallet-connect.molecule.ts
export const WalletConnectMolecule = molecule(() => {
// Core state
const addressAtom = atom<Address | null>(null, "wallet.address")
const chainIdAtom = atom<number | null>(null, "wallet.chainId")
const walletClientAtom = atom<WalletClient | null>(null, "wallet.client")
// Actions
const updateAddress = action((address: Address | null) => {
addressAtom.set(address)
}, "wallet.updateAddress")
const clearWallet = action(() => {
addressAtom.set(null)
chainIdAtom.set(null)
walletClientAtom.set(null)
}, "wallet.clear")
return {
addressAtom,
chainIdAtom,
walletClientAtom,
updateAddress,
clearWallet,
}
})4. Collection Molecules - Lists with atomized items
// products.molecule.ts
export const ProductsMolecule = molecule((mol) => {
const { addressAtom } = mol(WalletConnectMolecule)
// Source data (URLs from server)
const productUrlsAtom = atom<string[]>([], "products.urls")
// Atomized products map (each product has its own atoms)
const productsMapAtom = atom<Map<string, AtomizedProduct>>(
new Map(),
"products.map"
)
// Computed: ordered list of products
const productsAtom = computed(() => {
const urls = productUrlsAtom()
const map = productsMapAtom()
// Return products in URL order
return urls
.map(url => {
const id = parseProductId(url)
return id ? map.get(id) : null
})
.filter(Boolean) as AtomizedProduct[]
}, "products.list")
// Fetch with batch API
const fetchProducts = action(async () => {
const urls = productUrlsAtom()
const itemIds = urls.map(parseProductId).filter(Boolean)
const items = await wrap(fetchItemsBatch(itemIds))
// Update or create atomized products
const map = new Map(productsMapAtom())
for (const [id, item] of items) {
const existing = map.get(id)
if (existing) {
updateAtomizedProduct(existing, item)
} else {
map.set(id, createAtomizedProduct(item))
}
}
productsMapAtom.set(map)
}, "products.fetch")
return { productUrlsAtom, productsAtom, fetchProducts }
})When a collection item needs individual reactivity, atomize each field:
interface AtomizedProduct {
// Readonly identifiers
id: string
url: string
// Mutable atoms (can change)
nameAtom: ReturnType<typeof atom<string>>
priceAtom: ReturnType<typeof atom<number | null>>
imageAtom: ReturnType<typeof atom<string | undefined>>
// Computed (derived from other atoms)
isOwnedAtom: ReturnType<typeof computed<boolean>>
}
function createAtomizedProduct(
id: string,
data: ProductData,
ownerAddressAtom: ReturnType<typeof atom<string | null>>
): AtomizedProduct {
const nameAtom = atom(data.name, `product.${id}.name`)
const priceAtom = atom<number | null>(data.price, `product.${id}.price`)
const imageAtom = atom<string | undefined>(data.image, `product.${id}.image`)
const ownerAtom = atom<string | null>(data.owner, `product.${id}.owner`)
// Computed depends on external atom
const isOwnedAtom = computed(() => {
const owner = ownerAtom()
const currentAddress = ownerAddressAtom()
if (!owner || !currentAddress) return false
return owner.toLowerCase() === currentAddress.toLowerCase()
}, `product.${id}.isOwned`)
return {
id,
url: data.url,
nameAtom,
priceAtom,
imageAtom,
isOwnedAtom,
}
}
// Update existing atomized product (reuses atoms)
function updateAtomizedProduct(
existing: AtomizedProduct,
data: ProductData
): void {
existing.nameAtom.set(data.name)
existing.priceAtom.set(data.price)
existing.imageAtom.set(data.image)
}Client page with multiple molecules:
"use client"
import { useEffect, useRef } from "react"
import { reatomComponent } from "@reatom/react"
import { useMolecule } from "bunshi/react"
export const TaskPageClient = reatomComponent(({ task }: { task: TaskData }) => {
const initializedRef = useRef(false)
// Get molecules
const taskEditor = useMolecule(TaskEditorMolecule)
const taskActions = useMolecule(TaskActionsMolecule)
const taskComments = useMolecule(TaskCommentsMolecule)
// Initialize all molecules once
useEffect(() => {
if (initializedRef.current) return
initializedRef.current = true
// Initialize with server data
taskEditor.initialize(task)
taskActions.initialize(task.id, task.assigneeId)
taskComments.initialize(task.id)
// Cleanup on unmount
return () => {
taskEditor.cleanup()
taskComments.cleanup()
}
}, [task, taskEditor, taskActions, taskComments])
// Read atoms
const title = taskEditor.titleAtom()
const canEdit = taskEditor.canEditAtom()
return (
<div>
<h1>{title}</h1>
{canEdit && <EditButton onClick={() => taskEditor.updateTitle("New Title")} />}
<TaskActionsPanel />
<TaskCommentsPanel />
</div>
)
}, "TaskPageClient")Child component consuming molecule:
"use client"
import { reatomComponent } from "@reatom/react"
import { useMolecule } from "bunshi/react"
export const TaskActionsPanel = reatomComponent(() => {
const { likeCountAtom, isLikedAtom, toggleLike } = useMolecule(TaskActionsMolecule)
const likeCount = likeCountAtom()
const isLiked = isLikedAtom()
return (
<Button onClick={toggleLike} variant={isLiked ? "default" : "outline"}>
<Heart className={isLiked ? "fill-current" : ""} />
{likeCount}
</Button>
)
}, "TaskActionsPanel")1. Naming Conventions
// Molecule names: *Molecule suffix
export const TaskEditorMolecule = molecule(...)
// Factory functions: create* prefix (preferred for new code)
export const createProductsEditor = (name: string) => molecule(...)
// Note: reatom* prefix was used during migration, prefer create* for new factories
// Atom names: dotted namespace
const titleAtom = atom("", "taskEditor.title")
const isLoadingAtom = atom(false, "taskEditor.isLoading")
// Action names: same namespace
const save = action(async () => {...}, "taskEditor.save")2. Granular Atomization
// BAD: Monolithic object atom
const taskAtom = atom({ id: "", title: "", status: "todo" }, "task")
// Update requires: taskAtom.set(prev => ({ ...prev, title: "new" }))
// GOOD: Granular atoms
const taskIdAtom = atom("", "task.id")
const titleAtom = atom("", "task.title")
const statusAtom = atom<Status>("todo", "task.status")
// Direct updates: titleAtom.set("new")3. Dependency Injection via mol()
export const TaskActionsMolecule = molecule((mol) => {
// Inject dependencies
const { currentUserIdAtom } = mol(UserMolecule)
const { apiClient } = mol(ApiClientMolecule)
// Use injected dependencies
const isOwnerAtom = computed(() => {
return currentUserIdAtom() === assigneeIdAtom()
}, "isOwner")
})4. Optimistic Updates with Rollback
const toggleLike = action(async () => {
const wasLiked = isLikedAtom()
// 1. Optimistic update
isLikedAtom.set(!wasLiked)
likeCountAtom.set(prev => wasLiked ? prev - 1 : prev + 1)
try {
// 2. API call
const response = await wrap(fetch(...))
if (!response.ok) {
// 3a. Revert on API error
isLikedAtom.set(wasLiked)
likeCountAtom.set(prev => wasLiked ? prev + 1 : prev - 1)
}
} catch {
// 3b. Revert on network error
isLikedAtom.set(wasLiked)
likeCountAtom.set(prev => wasLiked ? prev + 1 : prev - 1)
}
}, "toggleLike")5. Polling with Guard
const isPollingAtom = atom(false, "isPolling")
const startPolling = action(async () => {
// Guard against multiple polling loops
if (isPollingAtom()) return
isPollingAtom.set(true)
while (true) {
await wrap(sleep(5000))
await fetchData()
}
}, "startPolling")6. Initialize Once Pattern
export const PageClient = reatomComponent(({ data }) => {
const initializedRef = useRef(false)
const molecule = useMolecule(MyMolecule)
useEffect(() => {
if (initializedRef.current) return
initializedRef.current = true
molecule.initialize(data)
return () => molecule.cleanup()
}, [data, molecule])
})7. Return Organized Exports
return {
// Group 1: Core state atoms
idAtom,
titleAtom,
statusAtom,
// Group 2: UI state
isLoadingAtom,
isErrorAtom,
// Group 3: Computed values
canEditAtom,
isOwnerAtom,
// Group 4: Actions
initialize,
save,
cleanup,
}Issue 1: Duplicate atoms on re-render
// BAD: Creates new atoms every render
const MyComponent = reatomComponent(() => {
const countAtom = atom(0, "count") // New atom each render!
return <div>{countAtom()}</div>
})
// GOOD: Atoms in molecule (created once)
const CounterMolecule = molecule(() => {
const countAtom = atom(0, "count")
return { countAtom }
})
const MyComponent = reatomComponent(() => {
const { countAtom } = useMolecule(CounterMolecule)
return <div>{countAtom()}</div>
})Issue 2: Missing wrap() in async
// BAD: Context lost
const fetchData = action(async () => {
const response = await fetch("/api/data")
dataAtom.set(await response.json()) // Error: Missed context
}, "fetchData")
// GOOD: Wrap async operations
const fetchData = action(async () => {
const response = await wrap(fetch("/api/data"))
dataAtom.set(await wrap(response.json()))
}, "fetchData")Issue 3: Molecules not initialized
// BAD: Using molecule without initialization
const MyComponent = reatomComponent(() => {
const { titleAtom } = useMolecule(TaskMolecule)
return <div>{titleAtom()}</div> // Empty - never initialized!
})
// GOOD: Initialize in parent/page component
const PageComponent = reatomComponent(({ data }) => {
const task = useMolecule(TaskMolecule)
useEffect(() => {
task.initialize(data) // Initialize with server data
}, [])
return <ChildComponent />
})Issue 4: Multiple polling loops
// BAD: No guard
const startPolling = action(async () => {
while (true) { // Multiple calls = multiple loops!
await wrap(sleep(5000))
await fetch()
}
}, "startPolling")
// GOOD: Guard with flag
const isPollingAtom = atom(false, "isPolling")
const startPolling = action(async () => {
if (isPollingAtom()) return // Guard
isPollingAtom.set(true)
while (true) {
await wrap(sleep(5000))
await fetch()
}
}, "startPolling")Context: Creating action molecules with action() + withAsync() extensions for complex operations.
// Simple single-layer pattern with direct action + extensions
export const TaskActionsMoleculeReatom = molecule((mol) => {
const { apiClient } = mol(ApiClientMolecule)
const createTaskAction = action(
async ({ projectId, title, description, assigneeId }) => {
const result = await wrap(
apiClient.tasks.create({ projectId, title, description, assigneeId })
)
return { ...result, projectId, title }
},
"createTaskAction",
).extend(
withAsync(), // Adds: .pending, .fulfilled, .rejected, .settle, onFulfill, onReject
// onCall: Runs when action is called (before execution)
withCallHook((values, [params]) => {
const { projectId, title } = params
analytics.track('TaskCreationStarted', { projectId, title })
toast.loading('Creating task...', { duration: Infinity })
}),
)
// onFulfill: Runs when action succeeds
createTaskAction.onFulfill.extend(
withCallHook((result) => {
const { id, title } = result.payload
analytics.track('TaskCreated', { taskId: id, title })
toast.dismiss()
toast.success(`Task "${title}" created successfully`)
}),
)
// onReject: Runs when action fails
createTaskAction.onReject.extend(
withCallHook(({ error, params: [paramsValue] }) => {
const { title } = paramsValue
analytics.track('TaskCreationFailed', { title, error: error.message })
toast.dismiss()
toast.error(`Failed to create task: ${error.message}`)
}),
)
return { createTaskAction }
})
// Usage in component (via reatomComponent)
const { createTaskAction } = useMolecule(TaskActionsMoleculeReatom)
await createTaskAction({ projectId, title, description, assigneeId })| Hook Name | Reatom Extension | Signature |
|---|---|---|
onMutate / onCall |
withCallHook() after action |
(values, [params]) => void |
onSuccess / onFulfill |
action.onFulfill.extend(withCallHook()) |
(result, [params]) => void |
onError / onReject |
action.onReject.extend(withCallHook()) |
({ error, params: [paramsValue] }) => void |
In onCall (withCallHook after action):
withCallHook((values, [params]) => {
const { projectId, title, description } = params // Action parameters
})In onFulfill:
createTaskAction.onFulfill.extend(
withCallHook((result, [params]) => {
const { id, title, createdAt } = result.payload // Return value from action
const originalParams = params // Original parameters
}),
)In onReject:
createTaskAction.onReject.extend(
withCallHook(({ error, params: [paramsValue] }) => {
const err = error // Error object
const { projectId, title } = paramsValue // Original parameters
}),
)// Use molecule directly in reatomComponent
export const CreateTaskButtonReatom = reatomComponent<{ projectId: string }>(
({ projectId }) => {
const { createTaskAction } = useMolecule(TaskActionsMoleculeReatom)
// Access async state
const isPending = createTaskAction.pending()
const handleCreate = async () => {
await createTaskAction({
projectId,
title: 'New Task',
description: 'Task description',
assigneeId: getCurrentUserId(),
})
}
return (
<button onClick={handleCreate} disabled={isPending}>
{isPending ? "Creating..." : "Create Task"}
</button>
)
},
"CreateTaskButtonReatom",
)withAsync() extension provides:
const createTaskAction = action(async (params) => { ... }, "createTaskAction")
.extend(withAsync())
// State atoms (read in reatomComponent)
createTaskAction.pending() // boolean: Is action running?
createTaskAction.fulfilled() // boolean: Did action succeed?
createTaskAction.rejected() // boolean: Did action fail?
createTaskAction.settled() // boolean: Is action done (success or failure)?
// Lifecycle hooks
createTaskAction.onFulfill.extend(withCallHook((result) => { ... }))
createTaskAction.onReject.extend(withCallHook(({ error, params }) => { ... }))Example: Show loading state
const CreateTaskButtonReatom = reatomComponent(({ projectId }) => {
const { createTaskAction } = useMolecule(TaskActionsMoleculeReatom)
const isPending = createTaskAction.pending()
const isFulfilled = createTaskAction.fulfilled()
const isRejected = createTaskAction.rejected()
return (
<div>
<button disabled={isPending}>
{isPending && "Creating..."}
{isFulfilled && "Created!"}
{isRejected && "Failed"}
{!isPending && !isFulfilled && !isRejected && "Create Task"}
</button>
</div>
)
}, "CreateTaskButtonReatom")import { molecule } from "bunshi"
import { action, withAsync, wrap } from "@reatom/core"
import { withCallHook } from "@reatom/core"
import { toast } from "sonner"
import { ApiClientMolecule } from "@/api/client-molecule"
export interface CreateTaskParams {
projectId: string
title: string
description?: string
assigneeId?: string
dueDate?: Date
}
export interface UpdateTaskParams {
taskId: string
title?: string
description?: string
status?: 'todo' | 'in_progress' | 'done'
assigneeId?: string
}
export const TaskActionsMoleculeReatom = molecule((mol) => {
const { apiClient } = mol(ApiClientMolecule)
// CREATE TASK ACTION
const createTaskAction = action(async (params: CreateTaskParams) => {
const result = await wrap(apiClient.tasks.create(params))
return { ...result, ...params }
}, "createTaskAction").extend(
withAsync(),
withCallHook(async (values, [params]) => {
const { title } = params
toast.loading(`Creating task "${title}"...`, { duration: Infinity })
}),
)
createTaskAction.onFulfill.extend(
withCallHook((result) => {
const { title } = result.payload
toast.dismiss()
toast.success(`Task "${title}" created`)
}),
)
createTaskAction.onReject.extend(
withCallHook(({ error, params: [paramsValue] }) => {
const { title } = paramsValue
toast.dismiss()
toast.error(`Failed to create "${title}": ${error.message}`)
}),
)
// UPDATE TASK ACTION
const updateTaskAction = action(async (params: UpdateTaskParams) => {
const result = await wrap(apiClient.tasks.update(params.taskId, params))
return { ...result, ...params }
}, "updateTaskAction").extend(
withAsync(),
withCallHook((values, [params]) => {
toast.loading('Updating task...', { duration: Infinity })
}),
)
updateTaskAction.onFulfill.extend(
withCallHook(() => {
toast.dismiss()
toast.success('Task updated')
}),
)
updateTaskAction.onReject.extend(
withCallHook(({ error }) => {
toast.dismiss()
toast.error(`Update failed: ${error.message}`)
}),
)
// DELETE TASK ACTION
const deleteTaskAction = action(async (taskId: string) => {
await wrap(apiClient.tasks.delete(taskId))
return { taskId }
}, "deleteTaskAction").extend(
withAsync(),
withCallHook(() => {
toast.loading('Deleting task...', { duration: Infinity })
}),
)
deleteTaskAction.onFulfill.extend(
withCallHook(() => {
toast.dismiss()
toast.success('Task deleted')
}),
)
deleteTaskAction.onReject.extend(
withCallHook(({ error }) => {
toast.dismiss()
toast.error(`Delete failed: ${error.message}`)
}),
)
return { createTaskAction, updateTaskAction, deleteTaskAction }
})If you need to share state between onCall, onFulfill, and onReject:
export const TaskActionsMoleculeReatom = molecule((mol) => {
// Store state outside action (closure scope)
let originalTaskTitle: string | undefined
let calculatedContext: EventContext | undefined
const updateTaskAction = action(async (params) => {
// Action execution
}, "updateTaskAction").extend(
withAsync(),
withCallHook(async (values, [params]) => {
// Calculate and store
originalTaskTitle = params.title
calculatedContext = buildEventContext(params)
}),
)
updateTaskAction.onFulfill.extend(
withCallHook((result) => {
// Access stored state
showSuccessToast(result.payload.title, originalTaskTitle)
}),
)
updateTaskAction.onReject.extend(
withCallHook(({ error, params: [paramsValue] }) => {
// Access stored state
showErrorToast(paramsValue.title, error, originalTaskTitle)
}),
)
return { updateTaskAction }
})export const ProjectActionsMoleculeReatom = molecule((mol) => {
const { apiClient } = mol(ApiClientMolecule)
const createProjectAction = action(async (params) => { ... }, "createProjectAction")
.extend(withAsync(), withCallHook(...))
const archiveProjectAction = action(async (params) => { ... }, "archiveProjectAction")
.extend(withAsync(), withCallHook(...))
const inviteMemberAction = action(async (params) => { ... }, "inviteMemberAction")
.extend(withAsync(), withCallHook(...))
return { createProjectAction, archiveProjectAction, inviteMemberAction }
})const createTaskAction = action(async ({ projectId, title, assigneeId }) => {
// Validate before execution
if (!projectId) {
throw new Error("Project ID is required")
}
if (!title || title.trim().length === 0) {
throw new Error("Task title is required")
}
if (title.length > 200) {
throw new Error("Task title must be 200 characters or less")
}
// Execute
const result = await wrap(apiClient.tasks.create({ projectId, title, assigneeId }))
return result
}, "createTaskAction").extend(withAsync(), withCallHook(...))Before (Jotai):
export const TaskTransactionMolecule = molecule((mol, scope) => {
const apiClientAtom = scope(ApiClientScope)
const { userIdAtom } = mol(UserMolecule)
function createTransaction({ taskData }) {
const mutationAtom = atomWithMutation((get) => ({ ... }))
return { mutationAtom }
}
return createTransaction
})After (Reatom):
export const TaskTransactionMoleculeReatom = molecule((mol) => {
const { apiClient } = mol(ApiClientMoleculeReatom)
const createTaskAction = action(async (params) => { ... }, "createTaskAction")
.extend(withAsync(), withCallHook(...))
return { createTaskAction }
})Key Changes:
- Remove
scope(ApiClientScope)-> UseApiClientMoleculeReatom - Remove nested
createTransaction()function -> Direct action creation - Remove
atomWithMutation-> Useaction()+withAsync() - Remove transaction storage logic -> Actions are stateless (state tracked externally if needed)
Before (Jotai atom):
// API factory returns Jotai atom
const createTaskAtom = createTask(apiClientAtom) // atom factory from API
// Later in mutationFn
const result = await get(createTaskAtom({ title, projectId, ... }))After (Reatom factory):
// API factory returns async function
const apiClient = apiClientAtom() // Get client
const createTask = createTaskFactory(apiClient) // Get factory function
// Execute with wrap
const result = await wrap(createTask(taskRequest))Critical: Always use wrap() for API calls to preserve Reatom context and enable proper cancellation.
| Jotai Mutation Hook | Reatom Extension | Signature |
|---|---|---|
onMutate |
withCallHook() after action |
(values, [params]) => void |
onSuccess |
action.onFulfill.extend(withCallHook()) |
(result, [params]) => void |
onError |
action.onReject.extend(withCallHook()) |
({ error, params: [paramsValue] }) => void |
Before (atomWithMutation):
const mutationAtom = atomWithMutation((get) => ({
onMutate: (variables: MutationVariables) => {
toast.loading('Creating task...')
analytics.track('TaskCreationStarted', context)
},
onSuccess: (result, variables) => {
toast.dismiss()
toast.success('Task created!')
analytics.track('TaskCreated', context)
},
onError: (error, variables) => {
toast.dismiss()
toast.error(`Failed: ${error.message}`)
analytics.track('TaskCreationFailed', context)
},
mutationFn: async (variables) => { ... },
}))After (Reatom action + extensions):
const createTaskAction = action(
async (params) => { /* mutationFn logic */ },
"createTaskAction"
).extend(
withAsync(),
// onMutate -> withCallHook after action
withCallHook((values, [params]) => {
toast.loading('Creating task...')
analytics.track('TaskCreationStarted', context)
}),
)
// onSuccess -> onFulfill
createTaskAction.onFulfill.extend(
withCallHook((result, [params]) => {
toast.dismiss()
toast.success('Task created!')
analytics.track('TaskCreated', context)
}),
)
// onError -> onReject
createTaskAction.onReject.extend(
withCallHook(({ error, params: [paramsValue] }) => {
toast.dismiss()
toast.error(`Failed: ${error.message}`)
analytics.track('TaskCreationFailed', context)
}),
)In onCall (withCallHook after action):
withCallHook((values, [params]) => {
const { projectId, title, assigneeId } = params // Action parameters
})In onFulfill:
createTaskAction.onFulfill.extend(
withCallHook((result, [params]) => {
const { id, title, createdAt } = result.payload // Return value from action
const originalParams = params // Original parameters
}),
)In onReject:
createTaskAction.onReject.extend(
withCallHook(({ error, params: [paramsValue] }) => {
const err = error // Error object
const { projectId, title } = paramsValue // Original parameters
}),
)Before (Jotai + hook):
export function useCreateTask(projectId: string) {
const { transactionAtom } = useMolecule(TaskTransactionMolecule, {
withScope: [ProjectScope, projectId],
})
const { mutationAtom } = useAtomValue(transactionAtom)
const { mutate, isPending } = useAtomValue(mutationAtom)
return { mutate, isPending }
}
// In component
const { mutate, isPending } = useCreateTask(projectId)
await mutate({ title, description, assigneeId })After (Reatom + reatomComponent):
// No custom hook needed - use molecule directly
export const CreateTaskButtonReatom = reatomComponent<{ projectId: string }>(
({ projectId }) => {
const { createTaskAction } = useMolecule(TaskTransactionMoleculeReatom)
// Access async state
const isPending = createTaskAction.pending()
const handleCreate = async () => {
await createTaskAction({
projectId,
title: 'New Task',
description: 'Task description',
assigneeId: getCurrentUserId(),
})
}
return (
<button onClick={handleCreate} disabled={isPending}>
{isPending ? "Creating..." : "Create Task"}
</button>
)
},
"CreateTaskButtonReatom",
)withAsync() extension provides:
const createTaskAction = action(async (params) => { ... }, "createTaskAction")
.extend(withAsync())
// State atoms (read in reatomComponent)
createTaskAction.pending() // boolean: Is action running?
createTaskAction.fulfilled() // boolean: Did action succeed?
createTaskAction.rejected() // boolean: Did action fail?
createTaskAction.settled() // boolean: Is action done (success or failure)?
// Lifecycle hooks
createTaskAction.onFulfill.extend(withCallHook((result) => { ... }))
createTaskAction.onReject.extend(withCallHook(({ error, params }) => { ... }))Old Pattern (Jotai atom factory):
// api/tasks/create.ts
export function createTask(apiClientAtom: Atom<ApiClient | null>) {
return atomFamily((request: CreateTaskRequest) =>
atom(async (get) => {
const client = get(apiClientAtom)
// ... create task logic
return result
})
)
}New Pattern (Reatom factory function):
// api/tasks/create.reatom.ts
export function createTaskFactory(
apiClient: ApiClient | null | undefined,
) {
return async (request: CreateTaskRequest): Promise<CreateTaskResult> => {
if (!apiClient) {
throw new Error("API Client is not initialized")
}
const response = await apiClient.tasks.create(request)
return response
}
}Key Differences:
- Jotai: Returns
atomFamily-> atom that returns async function - Reatom: Returns async function directly (no atom wrapping)
- Jotai: Uses
get(apiClientAtom)to access client - Reatom: Receives client directly as parameter
For each action molecule:
- Create new
*-molecule.reatom.tsxfile - Change molecule name:
TaskTransactionMolecule->TaskTransactionMoleculeReatom - Replace
scope(ApiClientScope)withmol(ApiClientMoleculeReatom) - Convert API call:
get(createTaskAtom(...))->wrap(createTaskFactory(apiClient)(...)) - Replace
atomWithMutationwithaction().extend(withAsync(), withCallHook(...)) - Convert lifecycle hooks:
-
onMutate->withCallHook()after action -
onSuccess->action.onFulfill.extend(withCallHook()) -
onError->action.onReject.extend(withCallHook())
-
- Update parameter access:
- onCall:
[params]from second argument - onFulfill:
result.payloadand[params] - onReject:
{ error, params: [paramsValue] }
- onCall:
- Remove transaction storage logic (atomEffect, etc.)
- Return
{ actionName }from molecule - Add header comment documenting migration from Jotai
// In component test
const { createTaskAction } = useMolecule(TaskTransactionMoleculeReatom)
// Call action
await createTaskAction({ projectId: '123', title: 'Test Task', ... })
// Check state
expect(createTaskAction.pending()).toBe(false)
expect(createTaskAction.fulfilled()).toBe(true)
expect(createTaskAction.rejected()).toBe(false)const taskCount = atom(0, 'taskCount').actions((target) => ({
increment: (amount = 1) => target.set(prev => prev + amount),
decrement: (amount = 1) => target.set(prev => prev - amount),
reset: () => target.set(0),
}))
taskCount.increment(5)
taskCount.reset()const withLogger = <T extends AtomLike>(prefix: string): Ext<T, T> => {
return withMiddleware((target) => {
return (next, ...params) => {
console.log(`${prefix} [${target.name}] Before:`, params)
const result = next(...params)
console.log(`${prefix} [${target.name}] After:`, result)
return result
}
})
}
const withReset = <T extends AtomLike>(
defaultValue: AtomState<T>,
): Ext<T> & { reset: Action } =>
(target) => ({
reset: action(() => target.set(defaultValue), `${target.name}.reset`),
})
const taskCount = atom(0, 'taskCount').extend(
withReset(0),
withLogger('TASK_COUNT'),
)- Always name primitives: Use second argument (
atom(0, 'taskCount')) - Descriptive names: Regular variable names (e.g.,
taskCount,fetchTasks) - NO suffixes: Don't use "Atom" or "Action" in names
- Factory functions: Prefix with
reatom*(e.g.,reatomTimer)
// GOOD
const taskCount = atom(0, 'taskCount')
const fetchUser = action(async () => {}, 'fetchUser')
// BAD
const taskCountAtom = atom(0, 'taskCountAtom')
const fetchUserAction = action(async () => {}, 'fetchUserAction')
// Factory pattern
const reatomTimer = (name: string) => {
const count = atom(0, `${name}.count`)
return { count }
}
const myTimer = reatomTimer('myTimer')const UserProfile = reatomComponent<{ className?: string }>(({ className }) => {
const [t] = useTranslation()
return (
<div className={className}>
<p>{t('name')}: {userName()}</p>
<p>{t('email')}: {userEmail()}</p>
</div>
)
})IMPORTANT: Reatom v1000 does NOT have useAtom() or useAtomValue() hooks.
There is NO @reatom/npm-react package. Do not try to import useAtom from it.
Pattern: Call atoms directly as functions inside reatomComponent or custom hooks:
// GOOD: Call atoms directly inside reatomComponent
const UserProfile = reatomComponent<{ className?: string }>(({ className }) => {
const name = userName()
const email = userEmail()
return (
<div className={className}>
<p>Name: {name}</p>
<p>Email: {email}</p>
</div>
)
})
// BAD: No useAtom() or useAtomValue() in v1000
const UserProfile = reatomComponent(() => {
const name = useAtom(userName) // Does not exist in v1000
const email = useAtomValue(userEmail) // Does not exist in v1000
return <div>{name}</div>
})
// BAD: Calling atoms without reatomComponent wrapper
const UserProfile = () => {
const name = userName() // Missing context, will throw error
return <div>{name}</div>
}Rule: Any component or hook that calls atoms MUST be wrapped with reatomComponent.
Recommended pattern for components with local state. Factory creates stable atoms.
const Timer = reatomFactoryComponent((props: { intervalMs: number }) => {
// Factory: create local state and effects
const count = atom(0, 'localTimerCount')
effect(async () => {
while(true) {
await wrap(sleep(props.intervalMs))
count.set(c => c + 1)
}
}, 'timerEffect') // Auto-cleans on unmount
// Return render function
return () => (
<div>Timer ({props.intervalMs}ms): {count()}</div>
)
}, 'Timer')NEVER create atoms inside render: Only in factory or outside component.
Await next update of atom/action within async context. Must use wrap().
const formData = atom({ value: '', error: null }, 'formData')
const submitWhenValid = action(async () => {
while (true) {
const data = formData()
const error = validate(data)
if (!error) break
formData.set({ ...data, error })
await wrap(take(formData)) // Wait for next change
}
console.log('Submitting:', formData())
}, 'submitWhenValid')Handle events safely with abort context support.
const reatomTaskUpdates = (projectId) =>
atom(null, `${projectId}TaskUpdatesAtom`).extend(
withConnectHook(async (target) => {
if (socket.readyState !== WebSocket.OPEN) {
await onEvent(socket, 'open')
}
socket.send(JSON.stringify({ projectId, type: 'subscribe' }))
onEvent(socket, 'message', (event) => {
if (event.data.projectId === projectId) {
target.set(JSON.parse(event.data))
}
})
onEvent(socket, 'close', () => abortVar.abort('close'))
onEvent(socket, 'error', () => abortVar.abort('error'))
abortVar.subscribeAbort(() =>
socket.send(JSON.stringify({ projectId, type: 'unsubscribe' }))
)
}),
)Checkpoint pattern for race conditions:
// BAD: Event might be missed
const animation = element.animate(keyframes)
const content = await wrap(api.fetchTasks())
await onEvent(animation, 'finish') // Might wait forever
// GOOD: Start listening before slow operation
const animation = element.animate(keyframes)
const animationFinished = onEvent(animation, 'finish') // Checkpoint
const content = await wrap(api.fetchTasks())
await animationFinished // Catches event even if finished during fetch// setup.ts
import { clearStack, connectLogger } from '@reatom/core'
clearStack() // Force explicit wrap() usage (recommended)
if (import.meta.env.DEV) {
connectLogger() // Enable debug logging
}// main.tsx
import { context } from '@reatom/core'
import { reatomContext } from '@reatom/react'
import ReactDOM from 'react-dom/client'
import './setup' // BEFORE app code
import { App } from './App'
const root = ReactDOM.createRoot(document.getElementById('root')!)
root.render(
<reatomContext.Provider value={context.start()}>
<App />
</reatomContext.Provider>,
)| Jotai | Reatom |
|---|---|
const countAtom = atom(0) |
const count = atom(0, 'count') |
const doubled = atom(get => get(count) * 2) |
const doubled = computed(() => count() * 2, 'doubled') |
const [count, setCount] = useAtom(countAtom) |
count() to read, count.set(5) to set |
useSetAtom(countAtom) |
Direct updates: count.set(5) |
atomWithStorage |
atom(...).extend(withInit(...), withChangeHook(...)) |
atomFamily |
Factory pattern: reatomFoo(id) |
useAtomValue(atom) |
atom() inside reatomComponent |
Key differences:
- Always name atoms: Required for debugging
- Call atoms directly: No hooks for read/write (inside
reatomComponent) - Use
.set()for updates: Not function calls - Use
wrap(): Required for async operations - Atomization over objects: Break complex state into granular atoms
Built-in helpers for common patterns:
reatomArray- Array operationsreatomBoolean- Boolean with togglereatomEnum- Enum values with settersreatomMap- Map operationsreatomNumber- Number with increment/decrementreatomRecord- Record operationsreatomSet- Set operationsreatomString- String operationsreatomLinkedList- Linked list
Core:
atom(initState, name?)- Mutable statecomputed(computeFn, name?)- Derived stateaction(effectFn, name?)- Logic/side effectseffect(effectFn, name?)- Auto-cleanup side effectswrap(fn | promise)- Preserve context (ESSENTIAL)
Methods:
.subscribe(callback)- Listen to changes.extend(extension)- Apply extensions.actions(builderFn)- Add related actions.set(value | updater)- Update atom
Extensions:
withAsync()- Track action states (ready, error)withAsyncData()- Track data fetching (data, ready, error, auto-cancel)withInit()- Initialize from source (e.g., SSR data, localStorage)withChangeHook()- React to changes (e.g., save to localStorage)withConnectHook()- Run on first subscriptionwithAbort()- Auto-cancellation supportwithMemo()- Memoization
Utilities:
take(target, name?)- Await next update (usewrap(take(...)))onEvent(target, eventName, callback?)- Handle events safelyconnectLogger()- Enable debug loggingclearStack()- Force explicitwrap()usagecontext.start(fn)- Create isolated contextsleep(ms)- Async delay (use withwrap())isAbort(error)- Check if error is abort
React:
reatomComponent- Reactive componentreatomFactoryComponent- Component with local state/effectsreatomContext.Provider- Context provider
All information sourced from: /tmp/reatom-v1000/packages/core/llms.md
Core concepts:
- Lines 9-120: Core primitives (atom, computed, action, effect, subscribe)
- Lines 128-227: Context preservation with wrap()
- Lines 229-262: Async state management
- Lines 264-308: Extensions
- Lines 310-377: Advanced utilities (take, onEvent)
- Lines 379-604: React integration and examples
- Lines 606-625: API reference
Document verified: 2025