Naalya Handbook

Client State

What lives in Zustand versus React Query, and how the persisted stores for auth, theme, impersonation and panels work.

Every app has two kinds of state, and the fastest way to write a confusing bug is to mix them up. Some state belongs to the server — the list of students, the current user's profile, a department's roster. Some state belongs to the browser tab in front of you — is the side panel open, is the theme dark, who am I impersonating right now. The Education Hub keeps a hard line between the two, and once you internalize where that line sits, you'll always know which tool to reach for.

  • Server data lives in React Query. It fetches, caches, dedupes, and invalidates. You never store the result anywhere else.
  • Client-local state lives in Zustand. It's UI, preferences, and session bookkeeping — the stuff React Query has no business owning.

Client vs server state

The single rule that keeps this clean is short: never copy server data into Zustand. React Query already owns the lifecycle of anything that came over the wire — when it's stale, when to refetch, how to invalidate it after a mutation. The moment you snapshot that data into a store, you've created a second copy that nobody keeps in sync, and your UI starts showing yesterday's roster.

So before you add a field to a store, ask one question: does this come from the API? If yes, it's a React Query concern — reach for a query hook (see The Data Layer). If it's purely local — a toggle, a preference, a transient bit of session state — then and only then does it belong in Zustand.

The dividing line

Zustand = client-local / UI / persistent state. React Query = server data. Auth tokens are the one interesting edge case: the token itself isn't server data you re-fetch — it's a credential the client holds — so it lives in a Zustand store. The user it identifies (getMe) is server data, so that lives in React Query.

The persist pattern

Every client-state store in the Hub follows the same recipe: a Zustand create() wrapped in the persist middleware, writing to localStorage under a named key. That name is the whole trick — it means each store has a stable, human-readable home in devtools that survives reloads.

The auth store is the smallest example, so start there. It holds two strings and the two actions that set or clear them:

src/components/providers/auth.provider.tsx
export const useAuth = create<AuthState>()(
  persist(
    (set) => ({
      token: null,
      refreshToken: null,
      setTokens: (token, refreshToken) => set({ token, refreshToken }),
      clearTokens: () => set({ token: null, refreshToken: null }),
    }),
    { name: tokenKey, storage: createJSONStorage(() => localStorage) },
  ),
)

Two things to notice. createJSONStorage(() => localStorage) is what makes the state survive a refresh — without it, the store is in-memory only and a reload logs you out. And name: tokenKey (which resolves to "auth-token") is the localStorage key. Open your devtools, expand Local Storage, and you can read or wipe any store by its name — invaluable when you're debugging a stuck session.

Named keys are a debugging superpower

Because every store declares its name, you can inspect the entire client state of the app from the Application tab in devtools — auth-token, bellefull-theme, impersonation-origin, sidepanel-state. Stuck in a weird state? Delete the relevant key and reload. No code changes, no special tooling.

The stores

There are four persisted client-state stores, each owning one slice of local concern.

Authsrc/components/providers/auth.provider.tsx, key "auth-token". Holds token and refreshToken, plus setTokens / clearTokens. This is the credential store the API client reads from. The full lifecycle — how tokens are minted, refreshed, and cleared — is covered in Sessions & Tokens.

Themesrc/components/providers/theme.provider.tsx, key "bellefull-theme". A single 'light' | 'dark' value with toggleTheme and setTheme. A small provider syncs that value to a class on the <html> element:

src/components/providers/theme.provider.tsx
export const useTheme = create<ThemeProvider>()(
  persist(
    (set) => ({
      theme: 'light',
      toggleTheme: () =>
        set((state) => ({ theme: state.theme === 'light' ? 'dark' : 'light' })),
      setTheme: (theme) => set({ theme }),
    }),
    { name: 'bellefull-theme', storage: createJSONStorage(() => localStorage) },
  ),
)

Impersonationsrc/components/providers/impersonation.provider.tsx, key "impersonation-origin". A stash-and-swap store that lets an admin step into another user's session and back out again. More on its guard below.

Side-panel statesrc/components/side-panels/_registry/use-sidepanel.ts, key "sidepanel-state". Tracks whether a panel is open, its size, and which panel is current.

One flash you have to design around

The theme provider's useEffect runs after React renders, so on a cold load the page paints light, then snaps to dark — an ugly flash. That's why a tiny pre-paint script in __root.tsx reads bellefull-theme from localStorage and applies the class before React renders. The store is still the source of truth; the inline script just gets there first.

Partializing what you persist

The side-panel store carries fields you genuinely want to remember across reloads (is it open, how big) and fields that are pure validation noise (contextValid, missingContext). You don't want the noise in localStorage. The persist middleware's partialize option lets you whitelist exactly which slice gets written:

src/components/side-panels/_registry/use-sidepanel.ts
{
  name: 'sidepanel-state',
  storage: createJSONStorage(() => localStorage),
  partialize: (state) => ({
    isOpen: state.isOpen,
    panelSize: state.panelSize,
    currentPanel: state.currentPanel,
    currentPanelProps: state.currentPanelProps,
    overlay: state.overlay,
  }),
}

Everything in the returned object is persisted; everything left out is recomputed fresh on each load. Reach for partialize whenever a store mixes durable preferences with transient, derived state.

Guards in actions

Zustand actions get a get() alongside set(), which lets an action read the current state before it mutates — the key to guarding transitions that would otherwise corrupt the store. Two stores lean on this.

The side-panel togglePanel refuses to open when there's nothing to show:

src/components/side-panels/_registry/use-sidepanel.ts
togglePanel: () => {
  const { isOpen, currentPanel } = get()
  if (!currentPanel) return // no-op if there's no panel to show
  set({ isOpen: !isOpen })
},

The sharper example is impersonation. Its start action stashes the admin's real tokens as origin, then swaps in the target user's tokens. If start ran a second time while already impersonating, it would stash the impersonation tokens as origin — and the admin's real session would be gone for good. A single get() guard prevents that:

src/components/providers/impersonation.provider.tsx
start: ({ accessToken, refreshToken, targetName }) => {
  if (get().isImpersonating) return // guard re-entry
  const current = getStoredAuth()
  if (current.token && current.refreshToken) {
    set({ origin: { token: current.token, refreshToken: current.refreshToken } })
  }
  setStoredAuth(accessToken, refreshToken)
  set({ isImpersonating: true, targetName })
},

Read before you transition

Any action that swaps, stashes, or toggles based on the current value should open with a get() check. The impersonation guard is load-bearing: without if (get().isImpersonating) return, a double-click on "impersonate" silently destroys the admin's session and there is no way back. When you write a transition action, ask what happens if it fires twice.

Where to go next

On this page