Skip to content

Zustand Store Patterns

DoCurious uses Zustand 5 for state management with 30 domain-specific stores. Stores export memoized selector functions to prevent unnecessary re-renders, and several use the persist middleware for localStorage hydration.

Pattern Overview

Each store follows a consistent pattern:

  1. State interface -- defines the shape of the store's data
  2. Actions interface -- defines the store's methods
  3. Store creation -- create<State & Actions>() with optional middleware
  4. Selectors -- exported functions for optimized component subscriptions
  5. Barrel export -- re-exported through src/store/index.ts
typescript
// Typical store structure
import { create } from 'zustand'

interface MyState {
  items: Item[]
  isLoading: boolean
}

interface MyActions {
  fetchItems: () => Promise<void>
  addItem: (item: Item) => void
}

type MyStore = MyState & MyActions

export const useMyStore = create<MyStore>()((set) => ({
  items: [],
  isLoading: false,
  fetchItems: async () => {
    set({ isLoading: true })
    const response = await myApi.getItems()
    if (response.success) {
      set({ items: response.data, isLoading: false })
    }
  },
  addItem: (item) => set((state) => ({
    items: [...state.items, item]
  })),
}))

// Selectors
export const selectItems = (state: MyStore) => state.items
export const selectIsLoading = (state: MyStore) => state.isLoading

Store Catalog

Production Stores

StorePurposeKey SelectorsFeature Guide
useAuthStoreAuthentication, current user, role checksselectUserRole, selectIsUnder13, selectHasRole, selectHasAnyRole, selectHasSchool, selectNeedsConsent, selectIsSchoolContext, selectCanAccessSchool, selectIsSchoolAdmin, selectIsTier1, selectCanCreateCommunity, selectCanAccessSavedList, selectCanReceiveGifts, selectCanUseDealersChoiceAccounts, Safety
useUserStoreUser profile, XP, levelselectProfile, selectLevel, selectXp, selectXpToNextLevel, selectXpProgressAccounts, Gamification
useChallengeStoreChallenge CRUD, categories, searchselectChallenges, selectCategories, selectFeatured, selectMyChallenges, selectMyChallengeStats, selectVendorChallenges, selectCurrentChallengeChallenges
useTrackRecordStoreTrack record entries, submissionselectTrackRecord, selectEntries, selectIsSubmitted, selectIsVerifiedTrack Records
useExploreStoreCurated views, saved list, Dealer's ChoiceselectCuratedViews, selectSavedList, selectDealersChoiceExplore
useCommunityStoreCommunities, feeds, membershipsselectCommunities, selectMyCommunities, selectCurrentCommunity, selectCurrentFeedCommunities
useGiftStoreGift sending and receivingselectSentGifts, selectReceivedGifts, selectPendingGifts, selectPendingGiftCountGifting
useSchoolStoreSchool administrationselectSchool, selectSchoolStats, selectClasses, selectStudents, selectTeachers, selectAssignmentsSchool
useNotificationStoreNotifications and preferencesselectNotifications, selectUnreadCount, selectNotificationCounts, selectPreferencesNotifications
useGamificationStoreBadges, XP events, streaks(no exported selectors -- uses direct access)Gamification
useLearningPathStoreLearning path progressionselectPaths, selectUserPaths, selectSelectedPathGamification
usePortfolioStorePortfolio/scrapbook managementselectPortfolios, selectSelectedPortfolioTrack Records
useCommunityGoalStoreCooperative community goals(no exported selectors)Communities
useVendorStoreVendor profile and onboardingselectVendorApprovalStatus, selectIsVendorApproved, selectNeedsModification, selectNeedsAgreement, selectOnboardingProgressVendor
useInvitationStoreChallenge invitationsselectSentInvitations, selectReceivedInvitations, selectPendingInvitations, selectPendingInvitationCountGifting
useOnboardingStoreOnboarding flow stateselectOnboardingStep, selectIsOnboardingComplete, selectTourActiveOnboarding
useShareStoreCross-community sharingselectSelectedItems, selectTargetCommunities, selectIsSharingCommunities
useAdminStorePlatform admin operationsselectAuditLogs, selectFlaggedContent, selectSelectedUser, selectAdminRolesAdmin
useDealersChoiceStoreDealer's Choice game stateselectCurrentGame, selectDealtCards, selectGameStatus, selectDCHistory, selectOnCooldownExplore
useLegalStoreConsent, cookiesselectConsentStatus, selectCookiePreferences, selectShowBannerPrivacy
useWalletStoreWallet balance and transactionsselectWalletBalance, selectWalletSummary, selectWalletTransactions, selectPayoutsVendor
useCheckoutStoreShopping cart and checkout flowselectCartItemCount, selectCartTotalChallenges
useReflectionStoreSEL reflection prompts and responses(direct access)Reflection
useVendorTeamStoreVendor team member management(direct access)Vendor
useImpersonationStoreAdmin user impersonation sessionsselectIsImpersonating, selectTargetUserAdmin
useToastStoreTransient auto-dismissing toast notifications (distinct from persistent notifications)(direct access via addToast, dismissToast)--

Infrastructure Stores

StorePurposeNotes
useFeatureFlagStoreFeature flag management and evaluationPersists to localStorage; exports selectFlags, selectActiveFlags, selectFlagByKey. See Admin guide
useDemoModeStoreDemo mode persona switchingPersists to localStorage; exports DEMO_PERSONAS, getDemoPersona
useDebugPanelStoreDebug panel open/tab stateDev-only; exports DebugTab type
useNetworkLogStoreNetwork request loggingDev-only; exports NetworkLogEntry type

How to Use Selectors

Always use selectors when subscribing to store state in components. This ensures the component only re-renders when the selected slice changes.

tsx
import { useChallengeStore, selectChallenges, selectMyChallenges } from '../store'

function MyChallengesPage() {
  // Good: only re-renders when myChallenges changes
  const myChallenges = useChallengeStore(selectMyChallenges)

  // Bad: re-renders on ANY store change
  // const { myChallenges } = useChallengeStore()

  return <ChallengeGrid challenges={myChallenges} />
}

Parameterized Selectors

Some selectors accept parameters and return a selector function:

tsx
import { useAuthStore, selectHasAnyRole } from '../store'

function AdminPage() {
  // selectHasAnyRole returns a selector function
  const isAdmin = useAuthStore(selectHasAnyRole(['platform_admin', 'staff']))
  if (!isAdmin) return null
  // ...
}
tsx
import { useFeatureFlagStore, selectFlagByKey } from '../store'

function WalletFeature() {
  // selectFlagByKey returns a selector function
  const walletFlag = useFeatureFlagStore(selectFlagByKey('wallet_v2'))
  // ...
}

Persistence Pattern

Stores that need to survive page reloads use the persist middleware:

typescript
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'

export const useFeatureFlagStore = create<FeatureFlagStore>()(
  persist(
    (set, get) => ({
      // ...state and actions
    }),
    {
      name: 'docurious-feature-flags',       // localStorage key
      storage: createJSONStorage(() => localStorage),
      // Optional: only persist certain fields
      partialize: (state) => ({
        flags: state.flags,
      }),
    }
  )
)

Currently persisted stores:

  • useFeatureFlagStore -- docurious-feature-flags
  • useDemoModeStore -- docurious-demo-mode
  • useAuthStore -- docurious-auth
  • Mock DB -- docurious-v1-* (per collection)

How to Create a New Store

  1. Create src/store/useMyDomainStore.ts:
typescript
import { create } from 'zustand'
import { myDomainApi } from '../api'
import type { MyEntity } from '../types'

// State
interface MyDomainState {
  items: MyEntity[]
  selectedItem: MyEntity | null
  isLoading: boolean
  error: string | null
}

// Actions
interface MyDomainActions {
  fetchItems: () => Promise<void>
  selectItem: (id: string) => void
  clearError: () => void
}

type MyDomainStore = MyDomainState & MyDomainActions

// Store
export const useMyDomainStore = create<MyDomainStore>()((set, get) => ({
  items: [],
  selectedItem: null,
  isLoading: false,
  error: null,

  fetchItems: async () => {
    set({ isLoading: true, error: null })
    const response = await myDomainApi.getAll()
    if (response.success) {
      set({ items: response.data, isLoading: false })
    } else {
      set({ error: response.error ?? 'Failed to fetch', isLoading: false })
    }
  },

  selectItem: (id) => {
    const item = get().items.find(i => i.id === id) ?? null
    set({ selectedItem: item })
  },

  clearError: () => set({ error: null }),
}))

// Selectors
export const selectMyItems = (state: MyDomainStore) => state.items
export const selectSelectedItem = (state: MyDomainStore) => state.selectedItem
  1. Add to src/store/index.ts:
typescript
export {
  useMyDomainStore,
  selectMyItems,
  selectSelectedItem,
} from './useMyDomainStore'
  1. Use in components via selectors:
tsx
import { useMyDomainStore, selectMyItems } from '../store'

function MyComponent() {
  const items = useMyDomainStore(selectMyItems)
  const fetchItems = useMyDomainStore(s => s.fetchItems)

  useEffect(() => { fetchItems() }, [fetchItems])

  return <ItemList items={items} />
}

DoCurious Platform Documentation