Skip to content

Refactor: State persistence via Zustand persist middleware + projectStorage #39

@jpeggdev

Description

@jpeggdev

Problem

`useStatePersistence` (67 lines) hardcodes knowledge of 3 specific stores (uiStore, feedStore, endpointStore), their specific state shapes, and the `PersistedState` interface. Adding a new persisted field requires editing the hook, the interface, and both save/restore logic paths.

Additional issues:

  • Single shared debounce timer — a high-frequency store (feed filters) resets the timer for low-frequency ones (panel layout)
  • Single monolithic localStorage key per project — a corrupt `feedFilters` corrupts `activeSection`
  • No schema versioning — shape changes on deploy silently corrupt or discard state
  • Restore logic calls store internals directly (`setActiveSection`, `setSidebarCollapsed`, etc.)
  • Zoom persistence in uiStore bypasses the system entirely with raw localStorage calls

Proposed Interface

~20 lines wrapping Zustand's built-in `persist` middleware with a dynamic project-scoped key:

// src/shared/lib/persistence.ts

export function projectStorage<T>(baseKey: string): PersistStorage<T>
// Resolved key at runtime: ads:project:{projectId}:{baseKey}

export const globalStorage: PersistStorage<unknown>
// For non-project-scoped state (zoom, theme)

Usage — adding persistence to a store is one middleware addition:

export const useFeedStore = create<FeedState>()(
  persist(
    (set, get) => ({ /* ...store unchanged... */ }),
    {
      name: 'feed-filters',
      storage: projectStorage('feed-filters'),
      partialize: (s) => ({ filters: s.filters }),  // persist only filters
    }
  )
)

Project switching triggers rehydration:

// In projectStore.switchProject:
await useFeedStore.persist.rehydrate()
await useUiStore.persist.rehydrate()

`useStatePersistence` hook is deleted entirely.

Dependency Strategy

  • Category: In-process + local-substitutable (localStorage)
  • `projectStorage` reads `useProjectStore.getState().activeProjectId` at read/write time
  • Each store owns its own persistence config — no central hook knows about all stores
  • Zustand's `persist` middleware handles serialization, debouncing, and hydration
  • Each slice gets its own localStorage key — isolation prevents cross-corruption
  • `projectStore` itself uses `globalStorage` or no persistence (avoids circular dependency)

Testing Strategy

  • New boundary tests: Create store with `persist` + `projectStorage`, set state, switch project ID, verify new key written. Verify `partialize` excludes ephemeral state. Verify `rehydrate()` restores correct project's state.
  • Old tests to delete: Any tests for `useStatePersistence` hook (deleted entirely)
  • Test environment: Standard Vitest; can use in-memory storage adapter for Zustand persist

Implementation Recommendations

  • Each store should declare what to persist via `partialize` — adding a field is one line
  • `projectStorage` should hide the dynamic key computation and project ID lookup
  • `globalStorage` should be a thin alias for naming consistency
  • Export a `rehydrateProjectStores()` helper listing all project-scoped stores, called from project switch
  • The `version` option on Zustand persist handles schema migration (discard on mismatch, or use `migrate` callback for complex cases)
  • Zoom persistence in uiStore migrates from raw localStorage to `globalStorage` + `partialize`
  • The hook `useStatePersistence` is deleted — 67 lines removed, zero lines of orchestration code remain

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions