Skip to content

Fix usePersistedState hydration mismatch for SSR#8541

Closed
WouldYouKindly wants to merge 2 commits into
developfrom
web/fix/use-persisted-state-hydration
Closed

Fix usePersistedState hydration mismatch for SSR#8541
WouldYouKindly wants to merge 2 commits into
developfrom
web/fix/use-persisted-state-hydration

Conversation

@WouldYouKindly

Copy link
Copy Markdown
Contributor

The previous implementation read from localStorage during the initial useState call. This causes React hydration mismatches because:

  1. Server renders with defaultValue (no localStorage on server)
  2. Client initial render reads localStorage and gets stored value
  3. React sees mismatch between server HTML and client render

This broke useCurrentUser which would redirect to login immediately on page load because userId was null during SSR hydration, even for logged-in users.

Changes:

  • localStorage: defer reading to useEffect, return default initially
  • sessionStorage: read immediately (per-tab, no SSR concern)
  • Add isHydrated flag so consumers know when value is loaded
  • useAuthStore exposes isHydrated in authState
  • useCurrentUser only redirects after hydration completes

The previous implementation read from localStorage during the initial
useState call. This causes React hydration mismatches because:

1. Server renders with defaultValue (no localStorage on server)
2. Client initial render reads localStorage and gets stored value
3. React sees mismatch between server HTML and client render

This broke useCurrentUser which would redirect to login immediately
on page load because userId was null during SSR hydration, even for
logged-in users.

Changes:
- localStorage: defer reading to useEffect, return default initially
- sessionStorage: read immediately (per-tab, no SSR concern)
- Add isHydrated flag so consumers know when value is loaded
- useAuthStore exposes isHydrated in authState
- useCurrentUser only redirects after hydration completes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@WouldYouKindly
WouldYouKindly requested a review from nabramow as a code owner May 11, 2026 21:23
@vercel

vercel Bot commented May 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
couchers Ready Ready Preview May 11, 2026 9:51pm

Request Review

renderPage() already awaits "About Me" text. Second call fails because
both tab button and h2 heading now contain same text after hydration.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Comment on lines +10 to 16
// Only redirect if auth has been hydrated from storage and there's still no userId
if (authState.isHydrated && !authState.userId) {
console.error("No user id available to get current user.");
if (typeof window !== "undefined") router.push(loginRoute);
}
return userQuery;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

As far as I can see, only moving the original code inside an effect here would be a simpler fix for the issue? This should not run at the top level of the component ("the render function") in the first place since it's a side effect.

The changes to usePersistedState seems way too convoluted and over complex to me. I'm also sceptical of the hydration mismatches claim by the comments generated by Claude as the isMounted in AppRoute is meant to prevent the mismatch on initial render

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@darrenvong I don't trust claude as well, but I know very little about React. The hydration issue is real though - see my comment below

@darrenvong

darrenvong commented May 12, 2026

Copy link
Copy Markdown
Member

Also, currently before any fixes, how do we reproduce this issue? 🤔

@WouldYouKindly

WouldYouKindly commented May 12, 2026

Copy link
Copy Markdown
Contributor Author

@darrenvong

  1. Run backend with cd app/backend; make run
  2. Run frontend with cd app/web; yarn start
  3. Register a new account
  4. Click on a link from registration email
Screenshot 2026-05-12 at 17 17 27
Error details ## Error Type Recoverable Error

Error Message

Hydration failed because the server rendered HTML didn't match the client. As a result this tree will be regenerated on the client. This can happen if a SSR-ed Client Component used:

  • A server/client branch if (typeof window !== 'undefined').
  • Variable input such as Date.now() or Math.random() which changes each time it's called.
  • Date formatting in a user's locale which doesn't match the server.
  • External changing data without sending a snapshot of it along with the HTML.
  • Invalid HTML tag nesting.

It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.

https://react.dev/link/hydration-mismatch

...


<Styled(Component) disableGutters={true} variant="full-screen" maxWidth={false}>








<Container component="section" maxWidth="lg" sx={{display:"flex", ...}}>
<MuiContainer-root as="section" ownerState={{...}} className="MuiContain..." ref={null} ...>



<Styled(div)>




<MuiTypography-root as="h2" ref={null} className="MuiTypogra..." sx={{...}} ...>

  •                             <h2
    
  •                               className="MuiTypography-root MuiTypography-h2 MuiTypography-gutterBottom css-xkig..."
    
  •                               style={{}}
    
  •                             >
    
  •                             <p
    
  •                               className="MuiTypography-root MuiTypography-body1 MuiTypography-gutterBottom css-1..."
    
  •                             >
                            ...
                          ...
    

    at h2 (unknown:0:0)
    at SignupFormContent (features/auth/signup/SignupFormContent.tsx:131:9)
    at Signup (features/auth/signup/Signup.tsx:188:13)
    at SignupPage (pages/signup.tsx:19:10)
    at MyApp (pages/_app.tsx:91:34)

Code Frame

129 | return (
130 | <>

131 |
| ^
132 | {t("auth:sign_up_need_verification_title")}
133 |
134 |

Next.js version: 15.5.7 (Webpack)

@darrenvong

darrenvong commented May 13, 2026

Copy link
Copy Markdown
Member
  1. Run backend with cd app/backend; make run
  2. Run frontend with cd app/web; yarn start
  3. Register a new account
  4. Click on a link from registration email

@WouldYouKindly Thanks for the repro steps! After looking more, I reckon a much simpler fix would be to mirror the isMounted pattern we already use in AppRoute and add it to Signup. That code's been there since the beginning and it still works, as well as being a known pattern (it's essentially a variation of solution 1 in the Next doc)

I had a play around and that seems to have fixed things. I could push something up unless this comment is useful enough for Claude to try and replicate (which probably from experience... probably not as slop tends to build on slop 🙈) if helpful?

@WouldYouKindly

Copy link
Copy Markdown
Contributor Author

@darrenvong please do. I’d love to learn from your fix :)

@darrenvong

Copy link
Copy Markdown
Member

@WouldYouKindly I opened a PR on the side (#8607) with what I had in mind as a fix as I thought it might be cleaner than pushing to your branch!

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.

2 participants