fix(redux-persist): render children immediately to avoid SSR hydration mismatch#1567
Conversation
…n mismatch
The previous implementation returned `null` on the first render while a
`loading` state flag flipped, then mounted children on the next tick
after dispatching `loadPersistedState`. That broke server-side hydration
for any host application that statically pre-renders pages:
- Server-generated HTML contains the page DOM (rendered with default
Redux state, since persisted-state lookup is browser-only).
- Client's first render returns `null` from this provider.
- React detects the hydration mismatch and triggers its recovery path.
- In Next.js's Pages Router, recovery calls `Router.change` against
the current URL and throws "Invariant: attempted to hard navigate
to the same URL".
- The unhandled promise rejection out of `Router.change` short-
circuits any in-flight effect on the page.
Observed against Meshery Cloud's /login static export (Next.js 16, React
19): the `useAuthFlow` hook's data fetch never fired, and the page sat
on its loading spinner indefinitely. Strip the conditional null-return,
dispatch the rehydrate on mount, render children unconditionally. Same
final state, no hydration discrepancy.
Behavior change for consumers: components that read rehydrated slices
render once with initial-state defaults, then re-render after the
rehydrate action commits — identical to redux-persist's own PersistGate
in its default (non-blocking) mode. Consumers that need to block on
persisted-state availability should gate locally via a selector
(`useSelector(state => state.<slice>.hydrated)`) rather than depending
on this provider to gate the whole tree.
Signed-off-by: Lee Calcote <leecalcote@gmail.com>
There was a problem hiding this comment.
Pull request overview
This PR updates the PersistedStateProvider in the redux-persist utilities to avoid SSR hydration mismatches by rendering children immediately and dispatching persisted-state rehydration on mount (instead of returning null during an initial loading tick).
Changes:
- Remove the initial
loading/errorstate gating that previously returnednullon first render. - Dispatch
loadPersistedState()in a mount effect while rendering children unconditionally. - Add an in-file doc comment explaining the SSR hydration mismatch and the resulting consumer behavior change.
| useEffect(() => { | ||
| if (!loading) { | ||
| return; | ||
| } | ||
|
|
||
| let error: Error | null = null; | ||
| try { | ||
| dispatch(loadPersistedState()); | ||
| } catch (e) { | ||
| error = e as Error; | ||
| console.error('Error Loading Persisted State', e); | ||
| } | ||
|
|
||
| // Use queueMicrotask to defer state updates and avoid cascading renders | ||
| queueMicrotask(() => { | ||
| setLoading(false); | ||
| if (error) { | ||
| setError(error); | ||
| } | ||
| }); | ||
| }, [loading, dispatch, loadPersistedState]); | ||
|
|
||
| if (error) { | ||
| console.error('Error Loading Persisted State', error); | ||
| } | ||
|
|
||
| if (loading) { | ||
| return null; | ||
| } | ||
| }, [dispatch, loadPersistedState]); |
There was a problem hiding this comment.
Code Review
This pull request refactors PersistedStateProvider to remove internal loading and error states, ensuring children are rendered immediately to prevent SSR hydration mismatches in environments like Next.js. Feedback suggests using useRef to guard the initialization effect against redundant execution cycles, which ensures the rehydration logic only runs once even if the loadPersistedState prop is unstable.
| /* eslint-disable @typescript-eslint/no-explicit-any */ | ||
| import { ThunkDispatch } from '@reduxjs/toolkit'; | ||
| import { FC, ReactNode, useEffect, useState } from 'react'; | ||
| import { FC, ReactNode, useEffect } from 'react'; |
| const dispatch = useDispatch<ThunkDispatch<any, unknown, RehydrateStateAction>>(); | ||
|
|
||
| useEffect(() => { | ||
| if (!loading) { | ||
| return; | ||
| } | ||
|
|
||
| let error: Error | null = null; | ||
| try { | ||
| dispatch(loadPersistedState()); | ||
| } catch (e) { | ||
| error = e as Error; | ||
| console.error('Error Loading Persisted State', e); | ||
| } | ||
|
|
||
| // Use queueMicrotask to defer state updates and avoid cascading renders | ||
| queueMicrotask(() => { | ||
| setLoading(false); | ||
| if (error) { | ||
| setError(error); | ||
| } | ||
| }); | ||
| }, [loading, dispatch, loadPersistedState]); | ||
|
|
||
| if (error) { | ||
| console.error('Error Loading Persisted State', error); | ||
| } | ||
|
|
||
| if (loading) { | ||
| return null; | ||
| } | ||
| }, [dispatch, loadPersistedState]); |
There was a problem hiding this comment.
To prevent redundant rehydration cycles and potential state loss if the loadPersistedState prop is unstable (e.g., if it's recreated on every render of the parent), use a useRef to ensure the initialization logic only runs once on mount. This restores the safety of the previous implementation while maintaining the fix for SSR hydration mismatches.
const dispatch = useDispatch<ThunkDispatch<any, unknown, RehydrateStateAction>>();
const initialized = useRef(false);
useEffect(() => {
if (!initialized.current) {
initialized.current = true;
try {
dispatch(loadPersistedState());
} catch (e) {
console.error('Error Loading Persisted State', e);
}
}
}, [dispatch, loadPersistedState]);
The previous implementation returned
nullon the first render while aloadingstate flag flipped, then mounted children on the next tickafter dispatching
loadPersistedState. That broke server-side hydrationfor any host application that statically pre-renders pages:
Redux state, since persisted-state lookup is browser-only).
nullfrom this provider.Router.changeagainstthe current URL and throws "Invariant: attempted to hard navigate
to the same URL".
Router.changeshort-circuits any in-flight effect on the page.
Observed against Meshery Cloud's /login static export (Next.js 16, React
19): the
useAuthFlowhook's data fetch never fired, and the page saton its loading spinner indefinitely. Strip the conditional null-return,
dispatch the rehydrate on mount, render children unconditionally. Same
final state, no hydration discrepancy.
Behavior change for consumers: components that read rehydrated slices
render once with initial-state defaults, then re-render after the
rehydrate action commits — identical to redux-persist's own PersistGate
in its default (non-blocking) mode. Consumers that need to block on
persisted-state availability should gate locally via a selector
(
useSelector(state => state.<slice>.hydrated)) rather than dependingon this provider to gate the whole tree.