With a React Native store persisted to AsyncStorage, why does a workout app first flash an empty log, and how do you gate on hydration?
answer
- AsyncStorage reads are asynchronous
- first render sees initial state
- PersistGate renders loading until bootstrapped
- Zustand: persist.hasHydrated and onFinishHydration
- early writes lose to the merge
basics
~20 sAsyncStorage reads are asynchronous, so the store starts with its initial state and the saved log arrives a moment later. Gate on hydration: redux-persist's PersistGate renders its loading prop until rehydration completes, and Zustand exposes persist.hasHydrated and onFinishHydration.
solid answer
~40 sThe store is created synchronously with its initial state, an empty log, and the persist layer starts an asynchronous AsyncStorage read. React renders immediately, so the first frame shows 'No workout in progress', and a moment later rehydration replaces the state with the saved log. Worse, if the user taps 'Start workout' in that window, the merge of the stored state can overwrite the change. The fix is to render nothing meaningful until hydration completes. With redux-persist, wrap the app in `<PersistGate loading={...} persistor={persistor}>`; it renders `loading` until the persistor reports `bootstrapped`, optionally awaiting `onBeforeLift`. With Zustand's `persist`, read `useWorkout.persist.hasHydrated()` and subscribe with `onFinishHydration` in a small hook, or set a flag in `onRehydrateStorage`. Keep the gate at the root, above the navigator, so no screen reads unhydrated state.
code
tsx · 20 linesimport { useEffect, useState } from 'react';
import { View } from 'react-native';
import { useWorkout } from './workoutStore'; // a Zustand store wrapped in persist
import { AppNavigator } from './AppNavigator';
function useWorkoutHydrated() {
const [hydrated, setHydrated] = useState(() => useWorkout.persist.hasHydrated());
useEffect(() => {
const unsubscribe = useWorkout.persist.onFinishHydration(() => setHydrated(true));
setHydrated(useWorkout.persist.hasHydrated()); // in case it finished before the effect ran
return unsubscribe;
}, []);
return hydrated;
}
export function Root() {
const hydrated = useWorkoutHydrated();
if (!hydrated) return <View style={{ flex: 1 }} />; // placeholder matching the launch screen
return <AppNavigator />;
}go deeper
Recall that saved state loads asynchronously from AsyncStorage, so the app must wait for it before showing restored data.
Explain creation versus hydration, how PersistGate and Zustand's persist API expose the finish, and why the gate sits at the root.
Show you guard against writes during the hydration window, handle hydration failure, and keep the start seamless with the launch screen.
Decide how much state may sit behind the startup gate, since every persisted kilobyte is paid on every cold start.
## Where the flash comes from A persisted store has two moments: **creation**, when the store exists with its initial state, and **hydration**, when the saved state has been read from storage and merged in. With AsyncStorage every read returns a promise, so those moments are separated by at least one render: 1. the JavaScript bundle loads and the store module creates the store with an empty workout log; 2. the persist layer calls AsyncStorage to read the saved entry, and that read resolves later; 3. React renders the first screen from the initial state: "No workout in progress"; 4. the read resolves, the saved log is merged into the store, and subscribed components re-render with the restored workout. On a fast phone the gap is short; on a low-end Android device with a large saved state it is visible, and it is the reason users report that "the app forgot my workout for a second". ## The worse problem: acting before hydration The flash is cosmetic. A **write before hydration** is not. If the user taps "Start workout" during the gap: - the store changes the in-memory state (Zustand's `persist` also writes it at once; redux-persist only starts writing after rehydration); - then hydration finishes and **merges the stored state over the current one**, so fields present in storage win; - the user's tap is silently undone, or two sources of truth fight over the same key. Gating rendering removes the window in which a user can act on state that is about to be replaced. ## Gating with redux-persist redux-persist exposes the progress through the **persistor** returned by `persistStore(store)`. Its state has a `bootstrapped` flag that becomes true once every persisted reducer has rehydrated. `PersistGate` subscribes to it: - until `bootstrapped`, it renders the `loading` prop, which can be `null` or a small placeholder; - when bootstrapped, it calls an optional `onBeforeLift` (awaiting it if it returns a promise) and then renders its children; - alternatively, pass a function as the child, which receives the `bootstrapped` boolean; do not combine it with `loading`. Place it directly under the Redux `Provider` and above the navigator. ## Gating with Zustand persist Zustand's `persist` middleware adds a `persist` API to the store hook: | API | Use | |---|---| | `useWorkout.persist.hasHydrated()` | read the current hydration status | | `useWorkout.persist.onFinishHydration(cb)` | subscribe; returns an unsubscribe function | | `onRehydrateStorage` option | run code before and after hydration, including on error | A small hook that starts from `hasHydrated()` and flips on `onFinishHydration` lets the root render a placeholder until the workout is back. With a **synchronous** storage adapter, Zustand hydrates during store creation and the gap disappears, which is one reason teams move persisted state to MMKV. ## Designing the gate for a phone - Gate **once, at the root**. Gating individual screens leaves navigation state and deep links to act on unhydrated data. - Keep the placeholder visually close to the launch screen so the user sees one continuous start, not a spinner after a splash. - Handle **failure**: a rejected read or a failed migration still has to open the gate, with the initial state and ideally a logged error, or the app never starts. - Do not hide slow hydration behind the gate forever; if it is slow, the persisted state is probably too large.
- Why does the flash disappear with Zustand when the storage adapter wraps MMKV?MMKV reads are synchronous, so the persist middleware can read, migrate and merge the saved state while the store is being created. The first render already sees the restored workout and `hasHydrated()` is true from the start. With AsyncStorage the read is a promise, so at least one render happens before hydration.
- What should the gate do if rehydration fails?Open anyway. A rejected read, a timeout or a failed migration leaves the store at its initial state; keeping the gate closed would leave the user on a blank screen forever. Render the app, log the error, and consider telling the user their unfinished workout could not be restored rather than failing silently.
A cloakroom attendant who opens the doors before fetching the coats: guests see an empty rack, and anyone who hangs up a new coat has it swapped when the stored ones are finally brought out.
saying these in an interview costs you the question
- AsyncStorage reads are synchronous, so the first render already has saved state
- Showing the empty state briefly is harmless because hydration fixes it
- Each screen should check hydration itself rather than gating once at the root
- PersistGate also waits for network requests to finish before rendering
- A failed hydration should keep the gate closed until storage recovers