26 KiB
Phase 3: State Management - Research
Researched: 2026-02-12 Domain: Frontend state persistence and snapshot/restore patterns for multi-context workspace switching Confidence: HIGH
Summary
Phase 3 implements snapshot-based state management to enable instant context switching between local and SSH workspaces. The core challenge is capturing complete Zustand state (12 slices totaling ~20+ state properties), persisting snapshots to IndexedDB, and restoring them without flickering or stale data flash. This requires: (1) a contextSlice managing the snapshot/restore lifecycle, (2) IndexedDB persistence with TTL-based expiration, (3) validation logic to ensure restored state references valid data (e.g., projectIds exist in current context), and (4) loading overlays to prevent UI flicker during transition.
The research validates that Zustand's architecture supports manual snapshot/restore via getState()/setState(), IndexedDB provides the storage layer (with third-party TTL libraries for expiration), and React Suspense-style loading overlays prevent stale data flash. The key architectural pattern is snapshot-on-exit + validate-on-restore: capture full state when switching away from a context, persist to IndexedDB, then restore and validate when switching back.
Primary recommendation: Use Zustand's native snapshot/restore with idb-keyval for IndexedDB storage, implement custom TTL tracking (simpler than external library), validate restored tabs/selections against current context data, and show full-screen loading overlay during context switch to prevent any visual artifacts.
Standard Stack
Core
| Library | Version | Purpose | Why Standard |
|---|---|---|---|
| Zustand | 4.x (installed) | State management | Already used app-wide; getState()/setState() enable manual snapshot/restore |
| idb-keyval | Latest (5.x) | IndexedDB wrapper | Official recommendation from Zustand docs for async storage; minimal API (get/set/del) |
| React 18 | 18.x (installed) | UI framework | Suspense/transition APIs enable loading states without flicker |
Supporting
| Library | Version | Purpose | When to Use |
|---|---|---|---|
| zustand-indexeddb | 0.1.1 | IndexedDB integration via persist middleware | NOT RECOMMENDED - designed for persist() middleware which auto-hydrates; we need manual control for context switching |
| ttl-db | Latest | TTL support for IndexedDB | NOT NEEDED - manual TTL tracking is simpler for this use case (single timestamp per snapshot) |
| zustand/middleware persist | Built-in | Auto-persist state | NOT SUITABLE - auto-hydrates on app start; we need per-context snapshots with manual restore |
Alternatives Considered
| Instead of | Could Use | Tradeoff |
|---|---|---|
| Manual snapshot/restore | Zustand persist middleware | Persist middleware auto-hydrates into a single global state; we need isolated per-context snapshots with manual restoration |
| idb-keyval | localForage | localForage is heavier (~5KB vs ~600B), adds async wrapper complexity we don't need |
| Custom TTL tracking | ttl-db library | ttl-db adds dependency for lazy expiration (checked on read); we need eager cleanup (background interval) to free IndexedDB space |
Installation:
pnpm add idb-keyval
Architecture Patterns
Recommended Project Structure
src/renderer/
├── store/
│ └── slices/
│ └── contextSlice.ts # New: snapshot/restore orchestration
├── services/
│ └── contextStorage.ts # New: IndexedDB persistence layer
├── hooks/
│ └── useContextSwitch.ts # New: hook for triggering context switches
└── components/
└── common/
└── ContextSwitchOverlay.tsx # New: full-screen loading overlay
Pattern 1: Manual Snapshot/Restore (NOT persist middleware)
What: Use Zustand's getState() to capture state snapshot, store in IndexedDB, then restore via setState() on context switch.
When to use: When you need isolated state snapshots per context with manual control over when to capture/restore. NOT when you want auto-hydration on app start (that's what persist middleware does).
Example:
// Capture snapshot
const captureSnapshot = (contextId: string) => {
const state = useStore.getState();
// Extract persistable slices (exclude transient state)
const snapshot = {
projects: state.projects,
selectedProjectId: state.selectedProjectId,
sessions: state.sessions,
selectedSessionId: state.selectedSessionId,
openTabs: state.openTabs,
activeTabId: state.activeTabId,
paneLayout: state.paneLayout,
notifications: state.notifications,
// ... all other slices
_metadata: {
contextId,
capturedAt: Date.now(),
version: 1 // for future migrations
}
};
await contextStorage.saveSnapshot(contextId, snapshot);
};
// Restore snapshot
const restoreSnapshot = async (contextId: string) => {
const snapshot = await contextStorage.loadSnapshot(contextId);
if (!snapshot) return false; // Never visited this context
// Validate snapshot against current context data
const validated = validateSnapshot(snapshot);
// Restore via setState
useStore.setState(validated);
return true;
};
Why manual over persist middleware:
- Persist middleware auto-hydrates on app init (single global state)
- We need per-context snapshots with manual restore on switch
- Switching to "never-visited" context must show empty state, not auto-hydrate from IndexedDB
Pattern 2: IndexedDB Storage Layer with TTL
What: Wrapper around idb-keyval that stores snapshots with timestamps and provides TTL-based cleanup.
When to use: When persisting context snapshots with expiration to prevent unbounded IndexedDB growth.
Example:
// src/renderer/services/contextStorage.ts
import { get, set, del, keys } from 'idb-keyval';
interface StoredSnapshot {
snapshot: StateSnapshot;
timestamp: number;
version: number;
}
const SNAPSHOT_TTL_MS = 5 * 60 * 1000; // 5 minutes (from phase notes)
const STORAGE_KEY_PREFIX = 'context-snapshot:';
export const contextStorage = {
async saveSnapshot(contextId: string, snapshot: StateSnapshot): Promise<void> {
const stored: StoredSnapshot = {
snapshot,
timestamp: Date.now(),
version: 1
};
await set(`${STORAGE_KEY_PREFIX}${contextId}`, stored);
},
async loadSnapshot(contextId: string): Promise<StateSnapshot | null> {
const stored = await get<StoredSnapshot>(`${STORAGE_KEY_PREFIX}${contextId}`);
if (!stored) return null;
// Check TTL
const age = Date.now() - stored.timestamp;
if (age > SNAPSHOT_TTL_MS) {
await del(`${STORAGE_KEY_PREFIX}${contextId}`); // Expired, delete
return null;
}
return stored.snapshot;
},
async deleteSnapshot(contextId: string): Promise<void> {
await del(`${STORAGE_KEY_PREFIX}${contextId}`);
},
// Background cleanup - call periodically (e.g., on app init, every 5 min)
async cleanupExpired(): Promise<void> {
const allKeys = await keys();
const now = Date.now();
for (const key of allKeys) {
if (typeof key === 'string' && key.startsWith(STORAGE_KEY_PREFIX)) {
const stored = await get<StoredSnapshot>(key);
if (stored && (now - stored.timestamp) > SNAPSHOT_TTL_MS) {
await del(key);
}
}
}
}
};
Why custom TTL over ttl-db:
- ttl-db uses lazy expiration (checked on read) - we need eager cleanup to free space
- Simple timestamp comparison is easier to reason about than external library
- Only storing one snapshot per context - not a large-scale key-value use case
Pattern 3: Snapshot Validation
What: Validate restored snapshots to ensure references (projectIds, sessionIds, tabIds) exist in the current context.
When to use: Always validate after restoring a snapshot to prevent rendering errors from stale references.
Example:
// Validate that restored tabs reference projects/sessions that exist in current context
const validateSnapshot = (snapshot: StateSnapshot): StateSnapshot => {
const currentProjects = useStore.getState().projects; // Freshly fetched for new context
const validProjectIds = new Set(currentProjects.map(p => p.id));
// Filter tabs to only those with valid projectIds
const validTabs = snapshot.openTabs.filter(tab => {
if (tab.type === 'session' && tab.projectId) {
return validProjectIds.has(tab.projectId);
}
return true; // Keep non-session tabs (e.g., dashboard)
});
// Reset activeTabId if it references invalid tab
let activeTabId = snapshot.activeTabId;
if (activeTabId && !validTabs.find(t => t.id === activeTabId)) {
activeTabId = validTabs[0]?.id ?? null;
}
// Validate pane layout tabs
const validatedPanes = snapshot.paneLayout.panes.map(pane => ({
...pane,
tabs: pane.tabs.filter(tab => {
if (tab.type === 'session' && tab.projectId) {
return validProjectIds.has(tab.projectId);
}
return true;
}),
activeTabId: validTabs.find(t => t.id === pane.activeTabId) ? pane.activeTabId : null
}));
return {
...snapshot,
openTabs: validTabs,
activeTabId,
paneLayout: {
...snapshot.paneLayout,
panes: validatedPanes
}
};
};
Why validation is critical:
- Switching to a different context means different projects/sessions exist
- Restored tabs may reference projectIds that don't exist in the new context
- Without validation: React will error trying to render non-existent data
Pattern 4: Loading Overlay During Context Switch
What: Display full-screen overlay during context switch to prevent stale data flash.
When to use: During context switches to mask the snapshot/restore + data refetch sequence.
Example:
// src/renderer/components/common/ContextSwitchOverlay.tsx
export const ContextSwitchOverlay: React.FC = () => {
const isSwitching = useStore(state => state.isContextSwitching);
const targetContext = useStore(state => state.targetContextId);
if (!isSwitching) return null;
return (
<div className="fixed inset-0 bg-surface z-[9999] flex items-center justify-center">
<div className="flex flex-col items-center gap-4">
<div className="animate-spin h-8 w-8 border-4 border-text border-t-transparent rounded-full" />
<p className="text-text-secondary">
Switching to {targetContext === 'local' ? 'Local' : targetContext}...
</p>
</div>
</div>
);
};
// In App.tsx
return (
<>
<ContextSwitchOverlay />
{/* Rest of app */}
</>
);
Based on Next.js loading.js pattern: Show instant loading state while content switches, then remove overlay once restoration completes. See Loading UI and Streaming - Next.js.
Anti-Patterns to Avoid
-
DON'T use Zustand persist middleware for context snapshots: Persist middleware auto-hydrates on app init into a single global state. We need per-context snapshots with manual restore on switch.
-
DON'T skip validation after restore: Restored state may contain references to projects/sessions that don't exist in the new context. Always validate and filter invalid references.
-
DON'T restore without showing loading state: Users will see stale data flash as the old context's UI renders briefly before restoration completes. Always show full-screen overlay during transition.
-
DON'T store derived/computed state: Only persist base state. Derived values (e.g., filtered lists, computed counts) should be recomputed from restored base state.
-
DON'T persist transient UI state: Loading flags, error messages, and other transient state should NOT be persisted. Only persist user-facing data and selections.
Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---|---|---|---|
| IndexedDB wrapper | Custom promise-based IndexedDB code | idb-keyval |
Handles browser quirks, transaction management, error recovery; battle-tested across browsers |
| State serialization | Custom JSON serialization with special cases | Native JSON.stringify/parse |
Zustand state is already JSON-serializable (no Maps, Sets, Dates in store) |
| TTL expiration checking | Complex timestamp algebra | Simple Date.now() - stored.timestamp > TTL_MS |
Single comparison is sufficient; no need for date libraries |
| Loading state coordination | Manual boolean flags and setTimeout | React 18 transitions (optional) | Built-in concurrent rendering prevents UI flicker |
Key insight: State persistence is deceptively complex due to:
- Browser compatibility: IndexedDB behavior varies across browsers (quota limits, transaction lifetimes, error modes)
- Race conditions: Multiple tabs accessing same IndexedDB can cause conflicts
- Quota limits: Browsers impose storage limits; need cleanup strategy
- Serialization edge cases: Circular references, non-serializable types (functions, symbols)
Using idb-keyval eliminates these concerns with a 600-byte library that's maintained by the Dexie.js team (IndexedDB experts).
Common Pitfalls
Pitfall 1: Auto-hydration with persist middleware
What goes wrong: Using Zustand's persist middleware causes auto-hydration on app init, loading the last-saved snapshot into the global store before the user switches contexts.
Why it happens: Persist middleware is designed for single-state persistence (e.g., saving user preferences). It auto-calls rehydrate() on store creation.
How to avoid: Use manual snapshot/restore with getState()/setState(). Only call restore when the user explicitly switches to a context.
Warning signs:
- UI shows data from a different context on app start
- "Hydration mismatch" errors in console
- State resets unexpectedly after reload
Pitfall 2: Stale Data Flash During Restore
What goes wrong: When restoring a snapshot, the UI briefly shows the previous context's data before the new snapshot applies, causing visual flicker.
Why it happens: React re-renders with old state before setState() completes. Without a loading overlay, users see the old context's projects/sessions for 50-100ms.
How to avoid:
- Set
isContextSwitching: trueBEFORE callingsetState() - Show full-screen loading overlay while
isContextSwitchingis true - Complete restore sequence (snapshot + data refetch)
- Set
isContextSwitching: falseto remove overlay
Warning signs:
- Users report seeing "flickering" or "old data" during context switch
- Tabs briefly show wrong session titles before updating
- Sidebar jumps between different project lists
Pitfall 3: Invalid References After Restore
What goes wrong: Restored tabs reference projectIds or sessionIds that don't exist in the new context, causing React to throw errors or render empty states.
Why it happens: Contexts have different projects/sessions. Restoring a snapshot from Context A into Context B means tab.projectId may not exist in Context B's data.
How to avoid: Always validate restored state against current context data:
const validTabs = snapshot.openTabs.filter(tab => {
if (tab.type === 'session' && tab.projectId) {
return currentProjectIds.has(tab.projectId);
}
return true;
});
Warning signs:
- Console errors: "Cannot read property 'name' of undefined"
- Tabs show blank content or "Session not found"
- Sidebar selections don't match visible tabs
Pitfall 4: IndexedDB Quota Exceeded
What goes wrong: Snapshots accumulate in IndexedDB until browser quota is exceeded, causing storage failures.
Why it happens: No cleanup strategy for expired snapshots or old SSH contexts that no longer exist.
How to avoid:
- Implement TTL-based expiration (5 minutes per phase notes)
- Run cleanup on app init and periodically (every 5 minutes)
- Delete snapshots when SSH context is destroyed
- Store only essential state (exclude transient loading/error flags)
Warning signs:
- "QuotaExceededError" in console
- Snapshots fail to save silently
- IndexedDB inspector shows hundreds of old snapshot entries
Pitfall 5: Restoring Transient State
What goes wrong: Loading flags, error messages, and other transient UI state get persisted and restored, causing confusing behavior (e.g., "Loading..." shown on restore).
Why it happens: Snapshot captures entire Zustand state including transient flags like projectsLoading: true.
How to avoid: Use partialize pattern to exclude transient state:
const snapshot = {
// Include
projects: state.projects,
selectedProjectId: state.selectedProjectId,
openTabs: state.openTabs,
// Exclude transient state
// projectsLoading: false, // NEVER persist loading flags
// projectsError: null, // NEVER persist errors
};
Warning signs:
- Restored context shows loading spinners that never complete
- Error messages from previous context appear in new context
- UI is "stuck" in loading state after restore
Code Examples
Verified patterns from official sources:
Zustand Manual State Capture/Restore
// Source: Zustand docs - https://zustand.docs.pmnd.rs/guides/how-to-reset-state
import { useStore } from './store';
// Capture current state
const currentState = useStore.getState();
// Restore state later
useStore.setState({
projects: restoredProjects,
selectedProjectId: restoredSelectedProjectId,
// ... all other slices
}, true); // Second arg `true` = replace entire state (not merge)
Note: The replace parameter (second arg) controls whether to merge or replace. For context switching, use replace: false (default) to merge the snapshot with current state, preserving any runtime-only state.
IndexedDB with idb-keyval
// Source: Zustand persist docs - https://zustand.docs.pmnd.rs/integrations/persisting-store-data
import { get, set, del } from 'idb-keyval';
// Save
await set('context-snapshot:local', {
snapshot: { /* state */ },
timestamp: Date.now()
});
// Load
const stored = await get('context-snapshot:local');
// Delete
await del('context-snapshot:local');
Context Switch Hook
// Pattern based on Zustand actions + async state transitions
export const useContextSwitch = () => {
const switchContext = useStore(state => state.switchContext);
const handleSwitch = async (targetContextId: string) => {
// 1. Show loading overlay
useStore.setState({ isContextSwitching: true, targetContextId });
try {
// 2. Capture current context's snapshot
const currentContextId = await window.electronAPI.context.getActiveContextId();
await captureSnapshot(currentContextId);
// 3. Switch context in main process (updates ServiceContextRegistry)
await window.electronAPI.context.switch(targetContextId);
// 4. Try to restore snapshot for target context
const restored = await restoreSnapshot(targetContextId);
if (!restored) {
// Never visited this context - show empty state
useStore.setState(getEmptyContextState());
}
// 5. Fetch fresh data for target context
await Promise.all([
useStore.getState().fetchProjects(),
useStore.getState().fetchRepositoryGroups(),
useStore.getState().fetchNotifications()
]);
// 6. Hide loading overlay
useStore.setState({ isContextSwitching: false, targetContextId: null });
} catch (error) {
console.error('Context switch failed:', error);
useStore.setState({
isContextSwitching: false,
targetContextId: null,
// Show error to user
});
}
};
return { switchContext: handleSwitch };
};
Empty State for New Context
// When switching to a never-visited context, reset to empty state
const getEmptyContextState = (): Partial<AppState> => ({
// Projects
projects: [],
selectedProjectId: null,
// Sessions
sessions: [],
selectedSessionId: null,
sessionsPagination: { hasMore: false, currentPage: 0 },
// Tabs
openTabs: [{ type: 'dashboard', id: 'dashboard', label: 'Dashboard' }],
activeTabId: 'dashboard',
// Pane layout - single pane with dashboard tab
paneLayout: {
panes: [{
id: 'pane-default',
tabs: [{ type: 'dashboard', id: 'dashboard', label: 'Dashboard' }],
activeTabId: 'dashboard',
selectedTabIds: [],
widthFraction: 1
}],
focusedPaneId: 'pane-default'
},
// Notifications
notifications: [],
unreadCount: 0,
// Repository
repositoryGroups: [],
// Session detail
sessionDetail: null,
sessionChunks: [],
sessionMetrics: null,
// Conversation
conversationGroups: [],
// Subagent
subagentDetail: null,
selectedSubagentId: null,
});
State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|---|---|---|---|
| localStorage for state | IndexedDB for state | 2020+ | IndexedDB supports larger quotas (50MB+) and structured data; localStorage limited to 5-10MB strings |
| Redux persist middleware | Zustand manual snapshot | 2021+ | Zustand's simpler API enables custom snapshot/restore without middleware complexity |
| Manual TTL tracking | Libraries like ttl-db | 2023+ | For simple TTL use cases (single timestamp), manual tracking is simpler than library dependency |
| Class components with getDerivedStateFromProps | React 18 Suspense/Transitions | 2022+ | Concurrent rendering prevents UI flicker during async state changes |
Deprecated/outdated:
- localStorage for large state: Replaced by IndexedDB for quota limits and structured data support
- Zustand persist middleware for multi-context: Designed for single-state persistence; manual snapshot/restore needed for per-context isolation
- react-loading-overlay package: React 18's Suspense provides built-in loading state coordination
Open Questions
-
Snapshot size optimization
- What we know: Full Zustand state snapshot includes all 12 slices (projects, sessions, tabs, notifications, etc.)
- What's unclear: If snapshots exceed 1MB, should we compress (e.g., pako) or partialize more aggressively?
- Recommendation: Start without compression; monitor snapshot sizes in production. Add compression if snapshots exceed 500KB.
-
TTL tuning for different contexts
- What we know: Phase notes suggest 5-minute TTL based on "typical user switching patterns"
- What's unclear: Should SSH contexts have longer TTL than local (users might stay in SSH for hours)?
- Recommendation: Start with uniform 5-minute TTL; add per-context TTL configuration if users report frequent "empty state on switch back" issues.
-
Graceful degradation when IndexedDB unavailable
- What we know: Private browsing modes and some browsers disable IndexedDB
- What's unclear: Should we fall back to in-memory snapshots or disable persistence entirely?
- Recommendation: Detect IndexedDB availability on app init; if unavailable, log warning and use in-memory Map for current session only (no persistence across restarts).
-
Migration strategy for snapshot schema changes
- What we know: Zustand persist middleware supports version migrations
- What's unclear: How to handle breaking changes to snapshot structure in future releases?
- Recommendation: Include
versionfield in stored snapshot; implement migration function that transforms old versions to current schema on load.
Sources
Primary (HIGH confidence)
- Zustand Persist Middleware Documentation - Official docs covering persist middleware, custom storage engines, version migration, and sync vs async storage differences
- GitHub: zustand-indexeddb - Official IndexedDB integration library showing API and limitations
- GitHub: How can I use zustand persist with indexeddb? - Official discussion confirming idb-keyval as recommended approach
- Zustand Beginner TypeScript Guide - TypeScript patterns for selectors and derived state
Secondary (MEDIUM confidence)
- Loading UI and Streaming - Next.js - Pattern for instant loading states during transitions
- Data Grid - Overlays - MUI X - Loading overlay patterns for preventing stale data display
- GitHub: ttl-db - TTL implementation for IndexedDB showing API and expiration patterns
Tertiary (LOW confidence)
- Understanding Zustand: A Lightweight State Management Library - Third-party blog covering derived state patterns
- Fixing React UI Updates: Stale Data and Caching Issues - Community articles on preventing stale data flash (validate patterns against official docs)
Metadata
Confidence breakdown:
- Standard stack: HIGH - Zustand + idb-keyval confirmed via official docs and recommendations
- Architecture: HIGH - Manual snapshot/restore pattern verified through Zustand docs and community discussions
- Pitfalls: MEDIUM - Common issues identified through GitHub issues and community discussions; validated against official docs
Research date: 2026-02-12 Valid until: 2026-03-12 (30 days - Zustand is stable, patterns unlikely to change)
Key findings:
- Zustand persist middleware is NOT suitable for per-context snapshots (auto-hydrates single global state)
- Manual snapshot/restore via
getState()/setState()is the correct pattern for multi-context isolation - IndexedDB via
idb-keyvalprovides simple async storage with browser compatibility handled - Custom TTL tracking (timestamp comparison) is simpler than library dependency for single-snapshot use case
- Snapshot validation is CRITICAL to prevent rendering errors from stale references
- Loading overlay during context switch prevents stale data flash (Next.js loading.js pattern)