Skip to content

fix(toast): replace useMediaQuery with SSR-safe pattern to avoid hydration mismatch#6513

Open
aosmcleod wants to merge 2 commits into
heroui-inc:v3from
aosmcleod:fix/toast-ssr-hydration
Open

fix(toast): replace useMediaQuery with SSR-safe pattern to avoid hydration mismatch#6513
aosmcleod wants to merge 2 commits into
heroui-inc:v3from
aosmcleod:fix/toast-ssr-hydration

Conversation

@aosmcleod

Copy link
Copy Markdown
Contributor

Closes #6509

📝 Description

ToastProvider uses useMediaQuery("(max-width: 768px)") to conditionally render the action button in different DOM positions (inside ToastContent on mobile, outside on desktop). During SSR, useMediaQuery returns false because window.matchMedia is unavailable. On mobile clients, the first render returns true, creating a hydration mismatch.

⛳️ Current behavior (updates)

  • useMediaQuery hook is used directly — returns false on server, potentially true on client
  • Server renders action button outside ToastContent
  • Mobile client expects it inside ToastContent
  • React logs hydration mismatch warning

🚀 New behavior

  • Replace useMediaQuery with a useState(false) + useEffect pattern
  • Always returns false on server AND first client render (matching SSR output)
  • Updates to correct value after hydration via useEffect
  • Also listens for viewport changes via MediaQueryList.addEventListener for responsive behavior
  • Removed the useMediaQuery import (no longer needed in this file)

💣 Is this a breaking change (Yes/No):

No — the action button position may briefly render in desktop layout on mobile before the effect fires, but this is a single-frame flash that's preferable to a hydration error.

📝 Additional Information

A more comprehensive fix would render both positions and use CSS hidden/sm:flex classes to toggle visibility without changing DOM structure. That approach avoids even the single-frame flash but requires changes to the toast CSS. This PR takes the minimal approach; happy to switch to the CSS approach if preferred.

`useMediaQuery` returns `false` during SSR but `true` on mobile clients,
causing a hydration mismatch when the action button renders in different
DOM positions based on viewport width.

Replace with a `useState(false)` + `useEffect` pattern that:
- Always returns `false` on the server and first client render (matching SSR output)
- Updates to the correct value after hydration via useEffect
- Listens for viewport changes via MediaQueryList.addEventListener

Fixes heroui-inc#6509
@aosmcleod aosmcleod requested a review from jrgarciadev as a code owner May 11, 2026 23:43
@vercel

vercel Bot commented May 11, 2026

Copy link
Copy Markdown

@aosmcleod is attempting to deploy a commit to the HeroUI Inc Team on Vercel.

A member of the Team first needs to authorize it.

@vercel

vercel Bot commented May 15, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
heroui Ready Ready Preview, Comment May 28, 2026 3:41pm
heroui-sb Ready Ready Preview, Comment May 28, 2026 3:41pm

Request Review

@pkg-pr-new

pkg-pr-new Bot commented May 15, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@heroui/react@6513
npm i https://pkg.pr.new/@heroui/styles@6513

commit: 038505b

@wingkwong wingkwong self-assigned this May 24, 2026
@wingkwong wingkwong changed the title fix(toast): replace useMediaQuery with SSR-safe pattern to avoid hydration mismatch (fix #6509) fix(toast): replace useMediaQuery with SSR-safe pattern to avoid hydration mismatch May 24, 2026

@wingkwong wingkwong left a comment

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.

In the narrow sense, it will remove the hydration warnings but it trades with a visual flash. I think CSS approach would be preferable.

Render the action button in both DOM positions and toggle visibility
with CSS modifier classes (toast__action--mobile, toast__action--desktop)
instead of branching on a JS isMobile state. Eliminates the SSR
hydration mismatch and the post-hydration visual flash that the
useState/useEffect approach introduced.

Aligns the action placement breakpoint with the existing CSS sm: (640px)
used by .toast__action and .toast__close-button responsive rules.
@wingkwong

Copy link
Copy Markdown
Member

Please click re-request review once it is ready for review. Thanks.

@aosmcleod

Copy link
Copy Markdown
Contributor Author

@wingkwong addressed your feedback — agree the previous useState + useEffect approach traded the hydration warning for a visual flash. Switched to a CSS-only solution.

Now renders the action button in both DOM positions and uses two new modifier classes (toast__action--mobile, toast__action--desktop) to toggle visibility per breakpoint:

```css
.toast__action--mobile {
@apply sm:hidden;
}
.toast__action--desktop {
@apply max-sm:hidden;
}
```

CSS decides which copy is visible, so the server and client render identically — no hydration mismatch — and there's no post-hydration flash because there's no JS state to wait for. Aligns the breakpoint with the existing CSS (sm: at 640px), which the previous JS (768px) didn't match.

Ready for another look.

Comment on lines +159 to +169
/* Responsive placement: action button is rendered in two DOM positions
* (inside .toast__content for stacked-below layout, and as a sibling of
* .toast__content for inline layout). CSS — not JS — picks which one is
* visible, so there is no SSR/CSR mismatch and no post-hydration flash. */
.toast__action--mobile {
@apply sm:hidden;
}

.toast__action--desktop {
@apply max-sm:hidden;
}

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.

The previous JS used (max-width: 768px) while the new CSS uses sm: (640px). Devices in the 640–767px range (tablets in portrait, smaller Chromebooks) used to get the mobile stacked layout.

</ToastContent>
{!isMobile && actionProps?.children ? (
<ToastActionButton {...actionProps}>{actionProps.children}</ToastActionButton>
{actionProps?.children ? (

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.

actionProps is spread onto two DOM nodes. There will be some issues like two elements share one id, ref called for both mounts but the second one always wins etc.

You may consider a single-node CSS layout. I think that can be achieved with the existing wrapper plus flex direction + order so that we can just have a single <ToastActionButton> as a sibling of <ToastContent> and don't need --mobile / --desktop modifiers.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] - Toast: useMediaQuery for action button position causes SSR hydration mismatch

2 participants