Skip to content

Instantly share code, notes, and snippets.

@paralin
Last active December 24, 2024 07:41
Show Gist options
  • Save paralin/4a345be26ab7784206e4c39eedf8a45d to your computer and use it in GitHub Desktop.
Save paralin/4a345be26ab7784206e4c39eedf8a45d to your computer and use it in GitHub Desktop.
jotai hierarchical persistence concept

UI Component State Persistence Design

See a prototype: https://github.com/paralin/jotai-tree-state-prototype

Background

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.

Problem Statement

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.

Goals

  1. Persist UI component states across sessions
  2. Keep component implementations simple and decoupled from persistence logic
  3. Support dynamic, nested component hierarchies
  4. Maintain type safety and developer ergonomics

Requirements

  • 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

Implementation Details

The persistence system is implemented in jotai-persist.tsx with these key components:

  1. 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
  2. Namespace Context System

    • StateNamespaceProvider component manages state hierarchy
    • Nested providers build namespace paths using string arrays
    • Context tracks current namespace path and storage atom
  3. State Management Hooks

    • useStateNamespace creates namespace paths from current context
    • useStateNamespaceAtom manages state within a namespace
    • useParentStateNamespaceAtom accesses parent storage atom
    • Handles state updates by merging changes into root storage
    • Preserves type safety with TypeScript generics
  4. Debugging Tools

    • StateDebugger component shows current namespace state
    • Renders pretty-printed JSON of scoped state tree
    • Useful for development and troubleshooting

Usage Example

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment