Appearance
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:
- State interface -- defines the shape of the store's data
- Actions interface -- defines the store's methods
- Store creation --
create<State & Actions>()with optional middleware - Selectors -- exported functions for optimized component subscriptions
- 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.isLoadingStore Catalog
Production Stores
| Store | Purpose | Key Selectors | Feature Guide |
|---|---|---|---|
useAuthStore | Authentication, current user, role checks | selectUserRole, selectIsUnder13, selectHasRole, selectHasAnyRole, selectHasSchool, selectNeedsConsent, selectIsSchoolContext, selectCanAccessSchool, selectIsSchoolAdmin, selectIsTier1, selectCanCreateCommunity, selectCanAccessSavedList, selectCanReceiveGifts, selectCanUseDealersChoice | Accounts, Safety |
useUserStore | User profile, XP, level | selectProfile, selectLevel, selectXp, selectXpToNextLevel, selectXpProgress | Accounts, Gamification |
useChallengeStore | Challenge CRUD, categories, search | selectChallenges, selectCategories, selectFeatured, selectMyChallenges, selectMyChallengeStats, selectVendorChallenges, selectCurrentChallenge | Challenges |
useTrackRecordStore | Track record entries, submission | selectTrackRecord, selectEntries, selectIsSubmitted, selectIsVerified | Track Records |
useExploreStore | Curated views, saved list, Dealer's Choice | selectCuratedViews, selectSavedList, selectDealersChoice | Explore |
useCommunityStore | Communities, feeds, memberships | selectCommunities, selectMyCommunities, selectCurrentCommunity, selectCurrentFeed | Communities |
useGiftStore | Gift sending and receiving | selectSentGifts, selectReceivedGifts, selectPendingGifts, selectPendingGiftCount | Gifting |
useSchoolStore | School administration | selectSchool, selectSchoolStats, selectClasses, selectStudents, selectTeachers, selectAssignments | School |
useNotificationStore | Notifications and preferences | selectNotifications, selectUnreadCount, selectNotificationCounts, selectPreferences | Notifications |
useGamificationStore | Badges, XP events, streaks | (no exported selectors -- uses direct access) | Gamification |
useLearningPathStore | Learning path progression | selectPaths, selectUserPaths, selectSelectedPath | Gamification |
usePortfolioStore | Portfolio/scrapbook management | selectPortfolios, selectSelectedPortfolio | Track Records |
useCommunityGoalStore | Cooperative community goals | (no exported selectors) | Communities |
useVendorStore | Vendor profile and onboarding | selectVendorApprovalStatus, selectIsVendorApproved, selectNeedsModification, selectNeedsAgreement, selectOnboardingProgress | Vendor |
useInvitationStore | Challenge invitations | selectSentInvitations, selectReceivedInvitations, selectPendingInvitations, selectPendingInvitationCount | Gifting |
useOnboardingStore | Onboarding flow state | selectOnboardingStep, selectIsOnboardingComplete, selectTourActive | Onboarding |
useShareStore | Cross-community sharing | selectSelectedItems, selectTargetCommunities, selectIsSharing | Communities |
useAdminStore | Platform admin operations | selectAuditLogs, selectFlaggedContent, selectSelectedUser, selectAdminRoles | Admin |
useDealersChoiceStore | Dealer's Choice game state | selectCurrentGame, selectDealtCards, selectGameStatus, selectDCHistory, selectOnCooldown | Explore |
useLegalStore | Consent, cookies | selectConsentStatus, selectCookiePreferences, selectShowBanner | Privacy |
useWalletStore | Wallet balance and transactions | selectWalletBalance, selectWalletSummary, selectWalletTransactions, selectPayouts | Vendor |
useCheckoutStore | Shopping cart and checkout flow | selectCartItemCount, selectCartTotal | Challenges |
useReflectionStore | SEL reflection prompts and responses | (direct access) | Reflection |
useVendorTeamStore | Vendor team member management | (direct access) | Vendor |
useImpersonationStore | Admin user impersonation sessions | selectIsImpersonating, selectTargetUser | Admin |
useToastStore | Transient auto-dismissing toast notifications (distinct from persistent notifications) | (direct access via addToast, dismissToast) | -- |
Infrastructure Stores
| Store | Purpose | Notes |
|---|---|---|
useFeatureFlagStore | Feature flag management and evaluation | Persists to localStorage; exports selectFlags, selectActiveFlags, selectFlagByKey. See Admin guide |
useDemoModeStore | Demo mode persona switching | Persists to localStorage; exports DEMO_PERSONAS, getDemoPersona |
useDebugPanelStore | Debug panel open/tab state | Dev-only; exports DebugTab type |
useNetworkLogStore | Network request logging | Dev-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-flagsuseDemoModeStore--docurious-demo-modeuseAuthStore--docurious-auth- Mock DB --
docurious-v1-*(per collection)
How to Create a New Store
- 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- Add to
src/store/index.ts:
typescript
export {
useMyDomainStore,
selectMyItems,
selectSelectedItem,
} from './useMyDomainStore'- 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} />
}