Answer to the core questions
1. Does onRehydrateStorage fire before components first read the store or after the initial render?
In Zustand v4+ the onRehydrateStorage callback is invoked immediately when rehydration starts. The function you provide receives the state that will be merged into the store and returns a *completion callback* that runs after the persisted values have been applied. The completion callback does not block React rendering; it runs asynchronously once the merge is finished.
Therefore, if React starts rendering before the completion callback has fired, the first component render will see the store’s initial value, not the persisted one.
2. Which store value do selector‑subscribed components receive on the first render during SSR hydration?
During server‑side rendering the store is created in a Node environment where browser storage (e.g., localStorage) is unavailable, so persist skips rehydration and the store contains only its initial state. The server renders that state to HTML. When the client mounts, if rehydration has not yet completed, the first render of the client‑side component will also see the initial state. Once the persisted state is merged, the store notifies all subscribers, causing selectors to re‑evaluate and components to update. Thus, the component receives the persisted value only after the completion callback has executed and the store has emitted a change event.
3. When is it safe to treat persisted state as stable for rendering decisions?
Persisted state is stable only after two conditions are met:
- The
onRehydrateStorage completion callback has been executed, meaning the merge is finished.
- React hydration (the first commit) has completed so that any components that read the store now see the merged state.
In practice you can guard SSR‑sensitive rendering behind a flag such as hasHydrated that you set inside the completion callback and that you also set to true after the first client‑side render. Until that flag is true, render placeholders or skip logic that depends on persisted data.
Practical steps for a typical SSR‑React app
- Create the store in a module‑scoped file so that both the server and the client share the same store instance per request.
import create from 'zustand';
import { persist } from 'zustand/middleware';
export const useStore = create(persist((set) => ({
value: 0,
setValue: (v) => set({ value: v }),
}), {
name: 'my-app',
onRehydrateStorage: (state) => {
console.log('rehydration started', state);
return (prevState, error) => {
console.log('rehydration finished', prevState, error);
hasHydrated = true; // global flag
};
},
}));
- Trigger rehydration before mounting React on the client if you want the first client render to see persisted data.
import { useStore } from './store';
if (typeof window !== 'undefined') {
const { api } = useStore.getState();
api.rehydrate(); // ensures rehydrate runs before React mounts
}
- Use a hydration flag in components.
import { useStore } from './store';
const MyComponent = () => {
const value = useStore((s) => s.value);
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted || !hasHydrated) return null; // or a placeholder
return <div>Value: {value}</div>;
};
- Verify timing in development by adding
console.log statements in the component’s first render, in the onRehydrateStorage callback, and in the api.rehydrate() call. Check that the order matches the expected sequence.
Missing diagnostic detail that could change the recommendation
Could you confirm whether the store is created once per request (module‑scoped) or per component instance? If it is per request, rehydration will happen on every request and the timing may differ from the per‑module case.