See a prototype: https://github.com/paralin/jotai-tree-state-prototype
Modern web applications often contain complex UI hierarchies with stateful components like counters, panels, and nested layouts. Each component maintains its own state but needs to persist across page reloads and browser sessions. Additionally, these components may be dynamically nested within each other, creating a hierarchical state structure.
We need a state management solution that allows components to operate independently while enabling centralized persistence of their states. The system must handle dynamic component hierarchies where components can be added, removed, or nested at runtime.
- Persist UI component states across sessions
- Keep component implementations simple and decoupled from persistence logic
- Support dynamic, nested component hierarchies
- Maintain type safety and developer ergonomics
- Components should be able to define and manage their state without knowledge of persistence
- State persistence should be opt-in via props/context
- Support for nested state namespacing
- Single source of truth for persisted state
- Efficient updates that only affect changed components
- Type-safe state management
- Simple debugging and state inspection
The persistence system is implemented in jotai-persist.tsx
with these key components:
-
Root Storage Atom
- Uses
atomWithStorage
to create a root atom storing all state in localStorage - State is stored as a nested object structure with namespaced keys
- Automatically handles serialization/deserialization
- Uses
-
Namespace Context System
StateNamespaceProvider
component manages state hierarchy- Nested providers build namespace paths using string arrays
- Context tracks current namespace path and storage atom
-
State Management Hooks
useStateNamespace
creates namespace paths from current contextuseStateNamespaceAtom
manages state within a namespaceuseParentStateNamespaceAtom
accesses parent storage atom- Handles state updates by merging changes into root storage
- Preserves type safety with TypeScript generics
-
Debugging Tools
StateDebugger
component shows current namespace state- Renders pretty-printed JSON of scoped state tree
- Useful for development and troubleshooting
The demo shows how to use these components to build a nested UI with persisted state:
// Create a persisted root atom for the entire app
const persistedRootAtom = atomWithStorage<Record<string, unknown>>(
"app-state",
{},
);
// Counter component with persisted state
function Counter() {
const [count, setCount] = useStateNamespaceAtom(null, "count", 0);
return (
<button onClick={() => setCount((c) => c + 1)}>
Count: {count}
</button>
);
}
// Region component for namespaced sections
function Region({ title, namespace, children }) {
return (
<StateNamespaceProvider namespace={namespace}>
<div className="region">
<h5>{title}</h5>
{children}
<StateDebugger />
</div>
</StateNamespaceProvider>
);
}
// Example of custom namespace paths
function NamespacedCounter() {
const namespace = useStateNamespace(["custom", "path"]);
const [count, setCount] = useStateNamespaceAtom(namespace, "count", 0);
return (
<button onClick={() => setCount((c) => c + 1)}>
Namespaced Count: {count}
</button>
);
}
// Main App with nested namespaces
function App() {
return (
<StateNamespaceProvider rootAtom={persistedRootAtom}>
<Region title="Root" namespace="main">
<Counter />
<Region title="Nested" namespace="nested">
<Counter />
</Region>
</Region>
<NamespacedCounter />
</StateNamespaceProvider>
);
}
This creates a state structure like:
{
"main": {
"count": 1,
"nested": {
"count": 2
}
},
"custom": {
"path": {
"count": 3
}
}
}
The state persists in localStorage and rehydrates on page reload. Components remain independent while their state is automatically persisted in the correct namespace. The system supports:
- Nested namespaces with automatic path resolution
- Custom namespace paths
- Type-safe state management
- State debugging per namespace
- Efficient updates to nested state