The app keeps two kinds of state apart.
  • Server state is fetched and cached with React Query. It is never copied into a store.
  • Client state that outlives a screen lives in a Zustand store. Examples are the session, the running workout and the color scheme.
Local component state covers the rest.

React Query

The single QueryClient is created in src/lib/api/queryClient.ts and provided in the root layout. The cache is in memory only. There is no persister, so a cold start always fetches again. authStore calls queryClient.clear() on sign in, studio switch and sign out.

Query keys

Each feature exports its keys from its hooks file.

Refresh on foreground

refetchOnWindowFocus is off because React Native has no window focus. Instead (app)/_layout.tsx listens to AppState and, on active, invalidates homeQueryKey and calls authStore.refreshSession(). Individual hooks add their own listeners where needed. usePushPermission refetches on active so the banner clears after a trip to system settings.

Patterns in use

  • placeholderData: keepPreviousData in useHome, so changing the nutrition day hint does not flash a skeleton.
  • Optimistic updates with onMutate and setQueryData, then an invalidation in onSettled. See the worked example in Feature modules.
  • Side effects on fresh data inside the hook. useHome calls syncHomeWidgets whenever data arrives.

Zustand stores

All stores use plain create. None use the persist middleware. Stores that survive a restart write to storage themselves.

Reading stores

Components subscribe with a selector: useAuthStore((s) => s.status). Code outside React reads with getState() and subscribes with subscribe(). (app)/_layout.tsx uses both to mirror the workout and cardio sessions into the Live Activity and the Android notification. Thin selector hooks wrap the common reads: useColors(), useColorScheme(), useBrand(), useBrandPalette(), useHomeTheme(), useWorkoutSummary().

Persistence layers

There are three places on the device.

SecureStore

src/lib/storage/secureStore.ts exports small typed wrappers. It is async, encrypted, and on iOS uses the after-first-unlock keychain class described in Authentication. On web it falls back to localStorage. Two more keys are written with the synchronous SecureStore API from src/lib/i18n/language.ts, because they are needed before the first render:

JSON files

src/lib/storage/jsonFileStore.ts provides readJsonSync, writeJsonSync and removeJsonSync. Files live in the app’s document directory through expo-file-system. On web they map to localStorage entries with the file name as the key. All three functions swallow errors and return null or nothing. They are synchronous on purpose. A store can read its snapshot while it is being created, so the first render already has the restored state. The workout snapshot write is throttled to one write per 400 ms, and flushed at once when the app leaves the foreground. Details are in Workout crash recovery. Every snapshot carries a version. Bump the constant when the stored shape changes so old files are discarded instead of being read into a new shape.

Shared widget directory

Home screen widgets and the Live Activity run in a separate iOS process. features/branding/widgetBrandLogo.ts copies the studio logo, or the bundled Perform mark, into widgetsDirectory from expo-widgets so SwiftUI can load it. See Live Activity and widgets.

What is never persisted

  • The React Query cache.
  • The preview token. In preview mode the stores skip all device writes by checking IS_PREVIEW.
  • Module-level latches such as pushRegistered in src/lib/notifications.ts and coldStartHandled in src/lib/notificationRouting.ts. They live for the JS process. Sign out does not reload the bundle, which is why resetPushRegistration() exists.

What survives sign out