In a Next.js App Router product list, the Delete control is a Client Component button that calls a Server Action from an onClick handler. QA reports that on a cold load over a slow connection, clicking Delete appears to do nothing for a second or two. What is happening, and how would you restructure the control?
answer
- the markup paints before the code arrives
- handlers are a bundle promise, not a browser one
- the endpoint existed; nothing could address it
- make the browser own the interaction
- feedback is enhancement, mutation is baseline
basics
~20 sThe click depends entirely on hydration: until React hydrates that component, no handler is attached, so the request cannot start. Restructure the control as a form whose action is the Server Action with the id bound, so the browser owns the submission from the moment the HTML paints.
solid answer
~50 sNothing is slow about the action itself — the button is simply not wired up yet. The markup paints as soon as HTML arrives, but the `onClick` handler only exists once the JavaScript for that Client Component has downloaded, parsed and hydrated, and on a slow connection that gap is seconds. Users see an interactive-looking control that swallows clicks. The fix is to stop making the browser wait for our code: render `<form action={deleteItem.bind(null, id)}>` with a submit button. That ships as a real POST form, so a click works the instant the page paints, and it works at all if the bundle fails outright. As a bonus the form can live in a Server Component, so the row no longer pulls a client bundle for its mutation. Pending and optimistic UI then layer on top as enhancement rather than being the only path.
code
tsx · 10 lines// app/products/delete-button.tsx — Server Component
import { deleteItem } from './actions'
export function DeleteButton({ id }: { id: string }) {
return (
<form action={deleteItem.bind(null, id)}>
<button type="submit">Delete</button>
</form>
)
}go deeper
Recognise the symptom: a button that looks ready but ignores clicks right after load usually has a handler that has not been attached yet, because its JavaScript is still arriving.
Explain that the control's only invocation path is a hydrated listener, and show the rewrite to a form whose action is the Server Action with the id bound so the browser can submit natively.
Diagnose it from evidence — throttled reload, blocked-bundle case — and restructure so the mutation is baseline while pending and optimistic UI become an additive client layer, noting the bundle saving across a long list.
Turn one incident into a standard: default mutations to form actions, require a stated reason for handler-only controls, and add a throttled or JS-disabled pass to QA so the class of bug is caught before users are.
## Naming the failure This is the **uncanny valley of hydration**: the page looks finished and behaves like it is broken. HTML paints in the first round trip; interactivity arrives whenever the JavaScript for that component finishes. Everything in between is a window where every click handler in the app is a no-op. On a fast laptop that window is invisible; on a mid-range phone on poor mobile data it can be several seconds, which is exactly the report QA filed. A click handler is not a promise the browser makes — it is a promise *your bundle* makes, once it arrives. ## Why the Server Action does not save it Candidates often reason: the action runs on the server, so surely it is robust. But the action being server-side says nothing about how the request gets started. In this design, the only thing that can start it is a JavaScript function attached during hydration. The endpoint exists; nobody can address it. That is the whole lesson of this leaf — progressive enhancement is a property of the *invocation site*, not of the action. The severity is also worse than the QA report suggests. A slow connection produces a delay; a failed or blocked bundle produces a control that is dead forever, with no error and no fallback. ## The restructure ```tsx // Before — Client Component, works only after hydration 'use client' export function DeleteButton({ id }: { id: string }) { return <button onClick={() => deleteItem(id)}>Delete</button> } // After — Server Component, works as soon as HTML paints import { deleteItem } from './actions' export function DeleteButton({ id }: { id: string }) { return ( <form action={deleteItem.bind(null, id)}> <button type="submit">Delete</button> </form> ) } ``` Three things change at once: 1. **The browser owns the interaction.** The submit button is native behaviour, live from first paint. No hydration required. 2. **The id travels in the submission**, bound as an argument, instead of living in a JavaScript closure. 3. **The row stops needing a client bundle for its mutation.** The component no longer has to be a Client Component, so a list of a hundred rows ships no per-row JavaScript for deleting. After hydration, React intercepts the same form and upgrades the submission to a client-side one — no full navigation, and the client-side niceties become available. The user gets the enhanced flow when the code is there and the working flow when it is not, from one implementation. ## Layering the polish back on The usual objection is that a form loses the disabled-while-pending button and the optimistic row removal. It does not — those become an enhancement rendered by a small client component *inside* the form, which does nothing before hydration and everything after. The distinction to hold on to: the mutation is baseline, the feedback is enhancement. If the feedback layer never loads, the delete still deletes; the user just gets a page navigation instead of an in-place update. One related detail: after the mutation the list must actually change. In the non-hydrated path the response is a fresh render of the page, so it reflects the write; in the hydrated path you still need the usual post-mutation revalidation so the cached view is not stale. Both paths want the same thing done inside the action. ## When a click handler is legitimately right Not every control can be a form. Drag-and-drop reordering, a canvas interaction, a keyboard shortcut, an autosave on blur — these have no meaningful no-JS behaviour, and pretending otherwise produces contorted markup. The senior judgment is to notice when a control *could* trivially be a form and is not, and to reserve handler-only invocation for interactions that genuinely have no native equivalent. ## How to catch this class of bug Throttle the network in DevTools to a slow profile, hard-reload, and start clicking the moment content paints. Anything that ignores you is handler-only. Disabling JavaScript entirely and walking the primary flows is the stricter version of the same pass, and it is worth having as an occasional checklist item rather than a one-time discovery by QA.
- The team objects that a form navigates the whole page. Is that objection correct?Only before hydration. Once React has hydrated, it intercepts the form's submission and handles it client-side, with no document navigation — the same experience the click handler gave. The full navigation happens exactly in the window where the alternative design does nothing at all, which is a strictly better failure mode.
- How would you keep a disabled-while-pending submit button without giving the property back?Put the pending indicator in a small client component rendered inside the form, so it is purely additive. Before hydration it renders as an ordinary enabled button and the form still submits; after hydration it reflects the in-flight state. The mutation never depends on that component having loaded.
- Are there controls where you would accept handler-only invocation?Yes — interactions with no native browser equivalent: drag-to-reorder, canvas or map manipulation, keyboard shortcuts, autosave on blur. Forcing those into forms produces contrived markup for no gain. The judgment is to reserve handler-only invocation for those cases rather than defaulting to it for ordinary create, update and delete controls.
saying these in an interview costs you the question
- Blames server latency instead of the hydration gap
- Says the action is safe because it runs on the server
- Assumes a form always means a full page reload
- Thinks the fix is adding a loading spinner
- Treats every mutation as impossible to express as a form