Hydration Proof

Search documentation

Find a page or section

HP5001: An interaction before hydration was lost

HP5001 (lost-interaction) means a click made while the page loaded did nothing, because the button looked ready before React handled events. How to fix it.

HP5001 (lost-interaction) means a click made while the page was still loading had no effect: the server-rendered button was visible before React could handle its events. The same click after hydration works. Keep the control disabled until the page is interactive, or make it work without JavaScript.

CodeHP5001
Namelost-interaction
Default severityWarning
GroupInteraction during hydration
What it meansA click made while the page was loading had no effect: the page looked ready before React could handle events.

What the HP5001 lost interaction finding means

Server rendering shows the page before its JavaScript has loaded. Until React has hydrated and attached its event listeners, a button is only HTML: a click on it runs no handler, and the user has no way to tell.

The finding comes from the interaction checks, which run with checks.interactions: true or --interactions. hydration-proof finds the first button outside forms and links, then loads the page four times: twice without a click (to make sure the page renders the same each time), once with a click after hydration and once with a click while the page's scripts are held back. If the late click changes the page and the early one does not, the click was lost:

A click on this button while the page was loading did nothing; the same click after hydration changes the page.

It is a warning: the page works, but it looks ready before it is. On a slow connection, that window can last seconds.

Likely causes

  • A large JavaScript bundle for the first view, so hydration starts late.
  • An important control inside a part of the page that hydrates last.
  • Buttons that only work through a React onClick, with no form or link behind them.

How to fix it

Disable the control until the page is interactive

Show it as disabled or loading. The server and the first client render agree, and an effect enables it:

components/add-to-cart.tsx
"use client";
 
import { useEffect, useState } from "react";
 
export function AddToCart({ onAdd }: { onAdd: () => void }) {
  const [ready, setReady] = useState(false);
  useEffect(() => setReady(true), []);
 
  return (
    <button type="button" disabled={!ready} onClick={onAdd}>
      {ready ? "Add to cart" : "Loading…"}
    </button>
  );
}

Make it work without JavaScript

Links navigate and forms submit before hydration. With a Server Action, the same form works in both cases:

app/product/[id]/add-to-cart.tsx
import { addToCart } from "./actions";
 
export function AddToCart({ productId }: { productId: string }) {
  return (
    <form action={addToCart}>
      <input type="hidden" name="productId" value={productId} />
      <button type="submit">Add to cart</button>
    </form>
  );
}

Hydrate important controls first

Ship less JavaScript for the first view. Suspense boundaries hydrate in order of interaction, so splitting the page into boundaries lets React start with the part the user touches.

When it is acceptable

If a short dead window is fine for your app, add an ignore.issues rule with code: "HP5001" and a reason; see ignoring findings.