reactseverity: workaround
#421

React Error #421: This Suspense boundary received an update before it finished hydrating

Suspense updated before hydrating

88% fixable~12 mindifficulty: advanced

Verified against React 19 source (ReactFiberHydrationContext.js), React docs: hydration, Next.js docs: streaming · Updated May 2026

> quick_fix

A state update fired against a Suspense boundary before React finished hydrating it. The fix: defer client-only updates until after mount with useEffect, or wrap them in startTransition so React waits for hydration before applying.

import { useEffect, useState } from 'react'

function Theme({ children }) {
  const [theme, setTheme] = useState('light')  // SSR default
  useEffect(() => {
    // Read browser-only value AFTER hydration
    setTheme(localStorage.getItem('theme') ?? 'light')
  }, [])
  return <Suspense fallback={<Spinner />}>
    <ThemedContent theme={theme}>{children}</ThemedContent>
  </Suspense>
}

What causes this error

When SSR HTML is being hydrated by the client, React replays the render to attach event listeners. If a state update fires during that window (from a useEffect, a same-tick setState, or a router event), and that update reaches a Suspense boundary, React can't safely apply it - hydration must complete first. React throws #421 to say: this update arrived too early.

> advertisementAdSense placeholder

How to fix it

  1. 01

    step 1

    Find the update source

    The trace points to the Suspense boundary, but the bug is wherever the update came from. Look for setState calls in render bodies, useLayoutEffect that fires before hydration completes, or router-event subscriptions that call setState immediately on mount.

    // Common offenders
    function Bad() {
      const router = useRouter()
      setSomething(router.path)  // setState during render
      return <Suspense>...</Suspense>
    }
  2. 02

    step 2

    Move client-only state into useEffect

    If the value comes from window, localStorage, or any browser-only API, it cannot be set during SSR. Initialize with the SSR default, then update in useEffect after mount.

    function ClientOnly() {
      const [width, setWidth] = useState(0)  // SSR default
      useEffect(() => {
        setWidth(window.innerWidth)
        const handler = () => setWidth(window.innerWidth)
        window.addEventListener('resize', handler)
        return () => window.removeEventListener('resize', handler)
      }, [])
      return <p>{width}</p>
    }
  3. 03

    step 3

    Wrap router-driven updates in startTransition

    If you subscribe to router events that fire immediately on mount (Next.js App Router does this), the resulting setState may race hydration. Wrap in startTransition so React defers it until after hydration.

    import { startTransition, useEffect } from 'react'
    
    function Tracker() {
      const path = usePathname()
      useEffect(() => {
        startTransition(() => {
          analytics.track('pageview', path)
        })
      }, [path])
    }
  4. 04

    step 4

    Remove setState in render bodies

    Calling setState during render schedules an update synchronously. During hydration, this update can reach a Suspense boundary before hydration completes. Move to useEffect or compute the value derived from props directly.

    // Bad - setState in render
    function Bad({ user }) {
      const [name, setName] = useState('')
      if (user && !name) setName(user.name)  // hits #421 during hydration
      return <Suspense>...</Suspense>
    }
    
    // Good - derive directly
    function Good({ user }) {
      const name = user?.name ?? ''
      return <Suspense>...</Suspense>
    }
  5. 05

    step 5

    Check third-party providers that mount listeners early

    Auth providers, analytics SDKs, and feature-flag clients often fire their first event in their constructor or top-level useEffect. If these trigger setState in your tree mid-hydration, you'll see #421. Lazy-init or wrap their callbacks in startTransition.

    useEffect(() => {
      const sub = featureFlags.subscribe((flag) => {
        startTransition(() => setFlag(flag))
      })
      return () => sub.unsubscribe()
    }, [])

Why #421 happens at the runtime level

Hydration in React 19 is incremental: each Suspense boundary hydrates independently as its content becomes ready. ReactFiberHydrationContext.js tracks a hydrating flag per fiber. When an update is scheduled, the reconciler walks up to find the nearest Suspense ancestor; if that ancestor is still hydrating, the update cannot be applied without breaking the SSR alignment. React throws invariant #421 in this case. The check fires before any DOM mutation, so the SSR HTML stays intact. The error specifically guards the contract that hydration completes against the original server output before any client-driven divergence is allowed.

Common debug mistakes for #421

  • Reading window/localStorage/document directly in render and calling setState with the result.
  • Subscribing to router events that fire synchronously on mount and calling setState in their callback.
  • Mounting analytics or feature-flag SDKs whose constructors trigger setState as a side effect.
  • Using useLayoutEffect (which runs synchronously after render but during hydration) to set state on a Suspense-wrapped child.
  • Migrating an SSR component to streaming without auditing which effects depend on hydrated state.

When #421 signals a deeper problem

Repeated #421 errors signal that the component tree has implicit ordering dependencies that hydration exposes. The architectural fix is to separate three phases explicitly: SSR render (no browser APIs), hydration (no setState), and post-mount (effects can mutate state). Components that need browser data should use a useState default + useEffect-update pattern uniformly. For analytics and feature flags, lazy-load them after a useEffect tick rather than at the module top level. In Next.js, prefer server components for layout and only use 'use client' for the leaf components that genuinely need interactivity, minimising the surface where hydration timing matters.

Frequently asked questions

Why does React reject updates during hydration?

Hydration must produce a tree that exactly matches the SSR HTML, then attach event listeners. A state update mid-hydration would change the tree before React has finished comparing it to the existing DOM, breaking the alignment. React's solution is to throw #421 if an update reaches a Suspense boundary before its hydration completes. The right fix is to ensure client-only updates fire after hydration, either via useEffect (which runs after) or startTransition (which is deferred behind committed work).

Is #421 the same as #418 or #425?

No. #418 (hydration mismatch) means the SSR HTML differed from what the client would render at t=0. #425 (text mismatch) is the specific text-content version of #418. #421 is different: SSR and client markup may agree, but a state update arrived before React could finish attaching listeners. The fix patterns overlap (move browser-only code to useEffect) but the root cause differs: #418 is about render output; #421 is about update timing.

Does using 'use client' fix this?

Partially. 'use client' marks a component as needing client-side hydration; without it, the component is server-only and never hydrates. But within a client component, the same #421 rules apply: updates that fire mid-hydration still throw. Use 'use client' to opt into hydration, then ensure your client component's effects don't trigger setState before hydration completes. The two concerns are separate.

How does Next.js App Router interact with #421?

App Router uses Suspense extensively for streaming and partial hydration. Each loading.js boundary creates a Suspense. If your client component subscribes to router events (usePathname, useSearchParams) and immediately calls setState, you can hit #421 during navigation. The pattern that works: read the router value during render to derive UI, wrap any side-effect-triggered updates in startTransition or useEffect. Server components can't trigger #421 because they don't hydrate.

disclosure:Errordex runs AdSense, has zero third-party affiliate or sponsored links, and occasionally links to the editor’s own paid digital products (clearly labelled). Every fix is manually verified against official sources listed in the “sources” sidebar. If a fix here didn’t work for you, please email so we can update the page.