| name | jotai-state |
| description | Atomic state management with Jotai for React applications. Use when implementing global state, creating atom families for entity maps, deriving computed state, syncing with IPC/backend, or managing streaming updates. Triggers on Jotai, atoms, atomFamily, derived state, state management, React state, global state. |
Jotai Atomic State Management
Fine-grained state management with Jotai atoms, optimized for entity-based data and real-time updates.
When to Use This Skill
- Setting up global application state
- Creating entity maps with atomFamily
- Deriving computed state from base atoms
- Syncing state with IPC/backend
- Managing streaming updates from agent sessions
- Implementing optimistic updates
Atom Architecture
Entity Map Pattern
import { atom, useAtom } from 'jotai';
import { atomFamily, atomWithStorage } from 'jotai/utils';
import type { Task, Outcome, AgentSession, AgentStep } from '@agentop/core';
export const tasksMapAtom = atom<Map<string, Task>>(new Map());
export const outcomesMapAtom = atom<Map<string, Outcome>>(new Map());
export const sessionsMapAtom = atom<Map<string, AgentSession>>(new Map());
export const stepsMapAtom = atom<Map<string, AgentStep>>(new Map());
export const taskAtomFamily = atomFamily((id: string) =>
atom(
(get) => get(tasksMapAtom).get(id),
(get, set, update: Partial<Task>) => {
const map = new Map(get(tasksMapAtom));
const existing = map.get(id);
if (existing) {
map.set(id, { ...existing, ...update });
set(tasksMapAtom, map);
}
}
)
);
export const outcomeAtomFamily = atomFamily((id: string) =>
atom(
(get) => get(outcomesMapAtom).get(id),
(get, set, update: Partial<Outcome>) => {
const map = new Map(get(outcomesMapAtom));
const existing = map.get(id);
if (existing) {
map.set(id, { ...existing, ...update });
set(outcomesMapAtom, map);
}
}
)
);
export const sessionAtomFamily = atomFamily((id: string) =>
atom(
(get) => get(sessionsMapAtom).get(id),
(get, set, update: Partial<AgentSession>) => {
const map = new Map(get(sessionsMapAtom));
const existing = map.get(id);
if (existing) {
map.set(id, { ...existing, ...update });
set(sessionsMapAtom, map);
}
}
)
);
export const stepAtomFamily = atomFamily((id: string) =>
atom(
(get) => get(stepsMapAtom).get(id),
(get, set, update: Partial<AgentStep>) => {
const map = new Map(get(stepsMapAtom));
const existing = map.get(id);
if (existing) {
map.set(id, { ...existing, ...update });
set(stepsMapAtom, map);
}
}
)
);
Derived Atoms
import { atom } from 'jotai';
import { atomFamily } from 'jotai/utils';
import {
tasksMapAtom,
outcomesMapAtom,
sessionsMapAtom,
stepsMapAtom,
} from './atoms';
export const taskChildrenAtomFamily = atomFamily((parentId: string | null) =>
atom((get) => {
const tasks = get(tasksMapAtom);
return Array.from(tasks.values())
.filter((task) => task.parentId === parentId)
.sort((a, b) => a.orderIndex - b.orderIndex);
})
);
export const rootTasksAtom = atom((get) => {
const tasks = get(tasksMapAtom);
return Array.from(tasks.values())
.filter((task) => !task.parentId)
.sort((a, b) => a.orderIndex - b.orderIndex);
});
export const taskOutcomesAtomFamily = atomFamily((taskId: string) =>
atom((get) => {
const outcomes = get(outcomesMapAtom);
return Array.from(outcomes.values())
.filter((outcome) => outcome.taskId === taskId)
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
})
);
export const outcomeSessionsAtomFamily = atomFamily((outcomeId: string) =>
atom((get) => {
const sessions = get(sessionsMapAtom);
return Array.from(sessions.values())
.filter((session) => session.outcomeId === outcomeId);
})
);
export const sessionStepsAtomFamily = atomFamily((sessionId: string) =>
atom((get) => {
const steps = get(stepsMapAtom);
return Array.from(steps.values())
.filter((step) => step.sessionId === sessionId)
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
})
);
export const taskRunningSessionsAtomFamily = atomFamily((taskId: string) =>
atom((get) => {
const outcomes = get(taskOutcomesAtomFamily(taskId));
const sessions = get(sessionsMapAtom);
let running = 0;
let completed = 0;
let failed = 0;
for (const outcome of outcomes) {
const outcomeSessions = Array.from(sessions.values())
.filter((s) => s.outcomeId === outcome.id);
for (const session of outcomeSessions) {
if (session.status === 'running') running++;
else if (session.status === 'completed') completed++;
else if (session.status === 'error') failed++;
}
}
return { running, completed, failed };
})
);
export const pendingAttentionCountAtom = atom((get) => {
const steps = get(stepsMapAtom);
return Array.from(steps.values())
.filter((step) =>
step.type === 'decision_point' &&
step.status === 'pending'
).length;
});
export const attentionItemsAtom = atom((get) => {
const steps = get(stepsMapAtom);
const sessions = get(sessionsMapAtom);
const outcomes = get(outcomesMapAtom);
return Array.from(steps.values())
.filter((step) =>
step.type === 'decision_point' &&
step.status === 'pending'
)
.map((step) => {
const session = sessions.get(step.sessionId);
const outcome = session ? outcomes.get(session.outcomeId) : undefined;
return { step, session, outcome };
});
});
UI State Atoms
import { atom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';
export const selectedTaskIdAtom = atom<string | null>(null);
export const selectedOutcomeIdAtom = atom<string | null>(null);
export const expandedTaskIdsAtom = atom<Set<string>>(new Set());
export const toggleTaskExpandedAtom = atom(
null,
(get, set, taskId: string) => {
const expanded = new Set(get(expandedTaskIdsAtom));
if (expanded.has(taskId)) {
expanded.delete(taskId);
} else {
expanded.add(taskId);
}
set(expandedTaskIdsAtom, expanded);
}
);
export const canvasViewStateAtom = atom({
x: 0,
y: 0,
zoom: 1,
});
export const settingsAtom = atomWithStorage('agentop-settings', {
defaultAttentionType: 'supervised' as const,
maxConcurrentSessions: 3,
theme: 'system' as 'light' | 'dark' | 'system',
});
IPC Sync Patterns
Initial Data Load
import { useSetAtom } from 'jotai';
import { useEffect } from 'react';
import {
tasksMapAtom,
outcomesMapAtom,
sessionsMapAtom,
stepsMapAtom,
} from './atoms';
export function useInitialDataSync() {
const setTasks = useSetAtom(tasksMapAtom);
const setOutcomes = useSetAtom(outcomesMapAtom);
const setSessions = useSetAtom(sessionsMapAtom);
const setSteps = useSetAtom(stepsMapAtom);
useEffect(() => {
async function loadData() {
const [tasks, outcomes, sessions, steps] = await Promise.all([
window.api.invoke('db:tasks:list'),
window.api.invoke('db:outcomes:list'),
window.api.invoke('db:sessions:list'),
window.api.invoke('db:steps:list'),
]);
setTasks(new Map(tasks.map((t) => [t.id, t])));
setOutcomes(new Map(outcomes.map((o) => [o.id, o])));
setSessions(new Map(sessions.map((s) => [s.id, s])));
setSteps(new Map(steps.map((s) => [s.id, s])));
}
loadData();
}, [setTasks, setOutcomes, setSessions, setSteps]);
}
Streaming Updates
import { useAtom, useSetAtom } from 'jotai';
import { useEffect, useCallback } from 'react';
import { stepsMapAtom, sessionAtomFamily } from './atoms';
import type { AgentStep } from '@agentop/core';
export function useSessionStream(sessionId: string) {
const setSteps = useSetAtom(stepsMapAtom);
const [session, setSession] = useAtom(sessionAtomFamily(sessionId));
const handleStep = useCallback((step: AgentStep) => {
setSteps((map) => {
const newMap = new Map(map);
newMap.set(step.id, step);
return newMap;
});
}, [setSteps]);
useEffect(() => {
const unsubscribe = window.api.on(
'agent:session:stream',
(data) => {
if (data.sessionId === sessionId) {
handleStep(data);
}
}
);
const unsubscribeStatus = window.api.on(
'agent:session:status',
(data) => {
if (data.id === sessionId) {
setSession(data);
}
}
);
return () => {
unsubscribe();
unsubscribeStatus();
};
}, [sessionId, handleStep, setSession]);
return { session };
}
export const sessionStreamAtomFamily = atomFamily((sessionId: string) =>
atom<AgentStep[]>((get) => {
const steps = get(stepsMapAtom);
return Array.from(steps.values())
.filter((s) => s.sessionId === sessionId)
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
})
);
Optimistic Updates
import { useAtom, useSetAtom } from 'jotai';
import { useCallback } from 'react';
import { tasksMapAtom, taskAtomFamily } from './atoms';
import type { Task } from '@agentop/core';
import { nanoid } from 'nanoid';
export function useCreateTask() {
const setTasks = useSetAtom(tasksMapAtom);
return useCallback(
async (input: { title: string; parentId?: string }) => {
const optimisticId = nanoid();
const optimisticTask: Task = {
id: optimisticId,
title: input.title,
parentId: input.parentId,
status: 'pending',
attentionType: 'supervised',
orderIndex: 0,
createdAt: new Date(),
updatedAt: new Date(),
};
setTasks((map) => {
const newMap = new Map(map);
newMap.set(optimisticId, optimisticTask);
return newMap;
});
try {
const realTask = await window.api.invoke('db:tasks:create', input);
setTasks((map) => {
const newMap = new Map(map);
newMap.delete(optimisticId);
newMap.set(realTask.id, realTask);
return newMap;
});
return realTask;
} catch (error) {
setTasks((map) => {
const newMap = new Map(map);
newMap.delete(optimisticId);
return newMap;
});
throw error;
}
},
[setTasks]
);
}
export function useUpdateTask() {
const setTasks = useSetAtom(tasksMapAtom);
return useCallback(
async (id: string, update: Partial<Task>) => {
let previousTask: Task | undefined;
setTasks((map) => {
const newMap = new Map(map);
previousTask = newMap.get(id);
if (previousTask) {
newMap.set(id, { ...previousTask, ...update, updatedAt: new Date() });
}
return newMap;
});
try {
const realTask = await window.api.invoke('db:tasks:update', { id, ...update });
setTasks((map) => {
const newMap = new Map(map);
newMap.set(id, realTask);
return newMap;
});
return realTask;
} catch (error) {
if (previousTask) {
setTasks((map) => {
const newMap = new Map(map);
newMap.set(id, previousTask!);
return newMap;
});
}
throw error;
}
},
[setTasks]
);
}
React Component Patterns
Using Atoms in Components
import { useAtom, useAtomValue } from 'jotai';
import {
taskAtomFamily,
taskChildrenAtomFamily,
taskRunningSessionsAtomFamily,
expandedTaskIdsAtom,
toggleTaskExpandedAtom,
} from '@/state/atoms';
export function TaskTreeNode({ taskId }: { taskId: string }) {
const task = useAtomValue(taskAtomFamily(taskId));
const children = useAtomValue(taskChildrenAtomFamily(taskId));
const { running, completed, failed } = useAtomValue(
taskRunningSessionsAtomFamily(taskId)
);
const [expandedIds] = useAtom(expandedTaskIdsAtom);
const [, toggleExpanded] = useAtom(toggleTaskExpandedAtom);
if (!task) return null;
const isExpanded = expandedIds.has(taskId);
const hasChildren = children.length > 0;
return (
<div className="task-node">
<div className="task-row" onClick={() => toggleExpanded(taskId)}>
{hasChildren && (
<ChevronIcon direction={isExpanded ? 'down' : 'right'} />
)}
<StatusDot status={task.status} />
<span className="task-title">{task.title}</span>
{running > 0 && <Badge>{running} running</Badge>}
</div>
{isExpanded && hasChildren && (
<div className="task-children">
{children.map((child) => (
<TaskTreeNode key={child.id} taskId={child.id} />
))}
</div>
)}
</div>
);
}
Provider Setup
import { Provider } from 'jotai';
import { DevTools } from 'jotai-devtools';
function App() {
return (
<Provider>
<DevTools />
<DataSyncProvider>
<MainLayout />
</DataSyncProvider>
</Provider>
);
}
function DataSyncProvider({ children }: { children: React.ReactNode }) {
useInitialDataSync();
return <>{children}</>;
}
Best Practices
- Use atomFamily for entities - Enables fine-grained updates without re-rendering
- Derive state with atoms - Computed values update automatically
- Optimize with selectAtom - Read specific parts of atom state
- Use atomWithStorage - Persist settings across sessions
- Implement optimistic updates - Better UX with rollback on error
- Split atoms by domain - Keep atoms organized and testable
Troubleshooting
| Issue | Solution |
|---|
| Infinite re-renders | Check atom dependencies, use stable references |
| Stale data | Ensure IPC sync updates correct atoms |
| Memory leaks | Clean up atomFamily atoms when entities removed |
| Slow updates | Use more granular atoms, avoid large objects |