State Management
December 18, 2025 · View on GitHub
Three-layer "onion" architecture for state management.
The Three Layers
┌─────────────────────────────────────┐
│ useState │ ← Component UI State
│ ┌─────────────────────────────────┐│
│ │ Zustand ││ ← Global UI State
│ │ ┌─────────────────────────────┐││
│ │ │ TanStack Query │││ ← Persistent Data
│ │ └─────────────────────────────┘││
│ └─────────────────────────────────┘│
└─────────────────────────────────────┘
Layer 1: TanStack Query (Persistent Data)
Use for data that:
- Comes from Tauri backend (file system, external APIs)
- Benefits from caching and automatic refetching
- Has loading, error, and success states
const { data, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => commands.getUser({ userId }),
enabled: !!userId,
})
See error-handling.md for retry configuration and error display patterns.
Layer 2: Zustand (Global UI State)
Use for transient global state:
- Panel visibility, layout state
- Command palette open/closed
- UI modes and navigation
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
interface UIState {
sidebarVisible: boolean
toggleSidebar: () => void
}
export const useUIStore = create<UIState>()(
devtools(
set => ({
sidebarVisible: true,
toggleSidebar: () =>
set(state => ({ sidebarVisible: !state.sidebarVisible })),
}),
{ name: 'ui-store' }
)
)
Layer 3: useState (Component State)
Use for state that:
- Only affects UI presentation
- Is derived from props or global state
- Is tightly coupled to component lifecycle
const [isDropdownOpen, setIsDropdownOpen] = useState(false)
const [windowWidth, setWindowWidth] = useState(window.innerWidth)
Performance Patterns (Critical)
The getState() Pattern
Problem: Subscribing to store data in callbacks causes render cascades.
Solution: Use getState() for callbacks that need current state.
// ❌ BAD: Causes render cascade on every store change
const { currentFile, isDirty, saveFile } = useEditorStore()
const handleSave = useCallback(() => {
if (currentFile && isDirty) {
void saveFile()
}
}, [currentFile, isDirty, saveFile]) // Re-creates on every change!
// ✅ GOOD: No cascade, stable callback
const handleSave = useCallback(() => {
const { currentFile, isDirty, saveFile } = useEditorStore.getState()
if (currentFile && isDirty) {
void saveFile()
}
}, []) // Stable dependency array
When to use getState():
- In
useCallbackdependencies when you need current state but don't want re-renders - In event handlers for accessing latest state without subscriptions
- In
useEffectwith empty deps when you need current state on mount only - In async operations when state might change during execution
Store Subscription Optimization
// ❌ BAD: Object destructuring subscribes to entire store
const { currentFile } = useEditorStore()
// ✅ GOOD: Selector only re-renders when this specific value changes
const currentFile = useEditorStore(state => state.currentFile)
// ✅ GOOD: Derived selector for minimal re-renders
const hasCurrentFile = useEditorStore(state => !!state.currentFile)
const currentFileName = useEditorStore(state => state.currentFile?.name)
CSS Visibility vs Conditional Rendering
For stateful UI components (like react-resizable-panels), use CSS visibility:
// ❌ BAD: Conditional rendering breaks stateful components
{sidebarVisible ? <ResizablePanel /> : null}
// ✅ GOOD: CSS visibility preserves component tree
<ResizablePanel className={sidebarVisible ? '' : 'hidden'} />
React Compiler (Automatic Memoization)
This app uses React Compiler which automatically handles memoization. You do not need to manually add:
useMemofor computed valuesuseCallbackfor function referencesReact.memofor components
Note: The getState() pattern is still critical - it avoids store subscriptions, not memoization.
Store Boundaries
UIStore - Use for:
- Panel visibility
- Layout state
- Command palette state
- UI modes and navigation
Feature-specific stores - Use for:
- Domain-specific state (e.g.,
useDocumentStore) - Feature flags and configuration
- Temporary workflow state
Adding a New Store
- Create store file in
src/store/ - Follow the pattern with
devtoolsmiddleware - Add no-destructure rule to
.ast-grep/rules/zustand/no-destructure.yml
rule:
any:
- pattern: const { $$$PROPS } = useUIStore($$$ARGS)
- pattern: const { $$$PROPS } = useNewStore($$$ARGS) # Add new store