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 useCallback dependencies when you need current state but don't want re-renders
  • In event handlers for accessing latest state without subscriptions
  • In useEffect with 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:

  • useMemo for computed values
  • useCallback for function references
  • React.memo for 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

  1. Create store file in src/store/
  2. Follow the pattern with devtools middleware
  3. 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