UI state

Shared UI state - sidebar open/closed, dialog targets, compose mode, visible calendar IDs, the currently-active section - lives in Zustand stores. Each store is a tiny module exporting a hook; components subscribe to exactly the fields they need. You don’t thread props through layouts and you don’t stand up a React context for every new piece of state.

When to reach for Zustand

Use a store when two or more components need to read or write the same piece of UI state without a direct parent-child relationship. Examples from shipped packages:

When not to

Zustand is for UI state. It is not the right tool for:

Where stores live

Store files live under stores/ in the package that owns them:

mail/
    stores/
        compose-store.ts
        thread-list-store.ts

@tinycld/core’s own stores are under @tinycld/core/lib/stores/. Don’t add package-specific state to those stores - keep each store co-located with the feature it serves.

The pattern

import { create, persist, asyncStorage } from '@tinycld/core/lib/store'

interface ComposeState {
    isOpen: boolean
    draftId: string | null
    recentSubjects: string[]
    open: (draftId: string) => void
    close: () => void
    rememberSubject: (subject: string) => void
}

export const useComposeStore = create<ComposeState>()(
    persist(
        (set) => ({
            isOpen: false,
            draftId: null,
            recentSubjects: [],
            open: (draftId) => set({ isOpen: true, draftId }),
            close: () => set({ isOpen: false, draftId: null }),
            rememberSubject: (subject) =>
                set((s) => ({
                    recentSubjects: [subject, ...s.recentSubjects].slice(0, 10),
                })),
        }),
        {
            name: 'tinycld_mail_compose',
            storage: asyncStorage,
            partialize: (s) => ({ recentSubjects: s.recentSubjects }),
        }
    )
)

create, persist, and asyncStorage are all re-exported from @tinycld/core/lib/store. Import them from there rather than reaching into zustand directly so the package stays pinned to @tinycld/core’s versions.

Selective persistence

partialize decides which slice of state is persisted. Above, isOpen and draftId reset on app restart (the compose window shouldn’t reopen itself), but recentSubjects survives so autocomplete stays useful. If you skip partialize, the whole store persists - often not what you want.

Pick a unique name per store. Convention: tinycld_<package>_<store>. The asyncStorage adapter handles the React Native side of AsyncStorage for you.

Reading from components

Always use a selector - never pull the whole store:

// good - only re-renders when isOpen changes
const isOpen = useComposeStore((s) => s.isOpen)

// also good - destructured selector for multiple fields
const { isOpen, close } = useComposeStore((s) => ({ isOpen: s.isOpen, close: s.close }))

// bad - re-renders on every store change
const store = useComposeStore()

Selectors are Zustand’s whole reason for being faster than Context. Use them.

Mutations stay out of stores

Do not put TanStack mutations inside a Zustand store. Mutations need reactive data from useLiveQuery and the isPending / isError tracking that useMutation provides - neither works cleanly from inside a store.

Compose the two in a small feature hook instead:

import { useComposeStore } from '../stores/compose-store'
import { useSendEmail } from './use-send-email'

export function useCompose() {
    const { isOpen, draftId, open, close } = useComposeStore((s) => ({
        isOpen: s.isOpen,
        draftId: s.draftId,
        open: s.open,
        close: s.close,
    }))
    const send = useSendEmail()
    return { isOpen, draftId, open, close, send }
}

The store holds UI state; the hook holds the mutation; the component consumes both through a single entry point.