Skip to content

fix(redux-persist): render children immediately to avoid SSR hydration mismatch#1567

Merged
leecalcote merged 1 commit into
masterfrom
fix/persistentstateprovider
May 20, 2026
Merged

fix(redux-persist): render children immediately to avoid SSR hydration mismatch#1567
leecalcote merged 1 commit into
masterfrom
fix/persistentstateprovider

Conversation

@hortison
Copy link
Copy Markdown
Contributor

@hortison hortison commented May 20, 2026

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.

…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>
Copilot AI review requested due to automatic review settings May 20, 2026 22:14
@leecalcote leecalcote merged commit 08aef2d into master May 20, 2026
7 checks passed
@leecalcote leecalcote deleted the fix/persistentstateprovider branch May 20, 2026 22:15
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/error state gating that previously returned null on 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.

Comment on lines 44 to +50
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]);
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Add useRef to the React imports to support guarding the initialization effect against redundant execution cycles.

Suggested change
import { FC, ReactNode, useEffect } from 'react';
import { FC, ReactNode, useEffect, useRef } from 'react';

Comment on lines 42 to +50
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]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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]);

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants