Skip to content
tecminds

Clickable Table Rows: Five Edge Cases a Full-Row onClick Has To Get Right

Making a whole table row open its detail page is a one-line change until a user cmd-clicks, drags to copy a cell, or hovers over the list for a second. This is a writeup of the five edge cases a full-row click handler has to survive — new-tab modifiers, middle-click, nested links, text selection, and the hover prefetch that quietly turns into a prefetch of every row the pointer crosses — and the shape of the React handler that got them all right in one commit.

TTobias LüscherCo‑Founder · TecMinds2026-10-12 · 10 min read

Clickable Table Rows: Five Edge Cases a Full-Row onClick Has To Get Right

A list page is a table. Each row has a company name, some metadata, and a tiny arrow button on the right that opens the row. The whole row highlights on hover. Users click the row and nothing happens. They complain, because the row looks like a button. You spend ten seconds adding onClick={() => navigate(href)} to the <tr>, you ship it, and within the same week a sales engineer reports that cmd-click no longer opens a new tab, a candidate complains the row stops them from copying a company name, and the network tab shows the list prefetching every row the pointer crosses on its way to the real target.

This is the writeup of the full-row clickable table row change we shipped last week in the AC-Center of Wield — our Swiss recruiting-intelligence product — and the five edge cases a one-line onClick on a <tr> has to get right before the shortcut counts as "working." The whole patch is twenty-one lines of production code. Fourteen of those lines are there because the obvious version is wrong.

Why "Just Add onClick" Is Not Enough

The Auftragsliste — our list of recruitment engagements an assessor is running — had the standard shape: a company name as a <Link>, a mode and status badge, a count, a last-updated date, an arrow button on the right that opened the engagement. Hover highlighted the whole row. Click the number of candidates and nothing happened. This is the discoverability trap Fitts's law warns about: the hover target was a wide rectangle; the hit target was a thin link. The row looked like one hit box and was five.

The naive fix is one JSX attribute:

<tr onClick={() => navigate(href)}>

Ship it on a Friday afternoon and five things break between Monday morning and Monday coffee:

  1. Cmd-click, ctrl-click and shift-click no longer open a new tab. They navigate in place, because navigate() ignores modifier keys: the handler calls it whether or not cmd, ctrl or shift is held.
  2. Middle-click no longer opens a new tab. onClick only fires for primary-button clicks; the browser's native "middle-click opens in new tab" behaviour is tied to anchors with an href, not to a <tr>.
  3. Right-click opens the engagement — once you fix middle-click. onClick does not fire on a right-click, so the one-liner is safe here. The risk arrives with the fix for point 2: wire onMouseDown or onAuxClick to catch middle-click without filtering out button 2, and a right-click now opens the engagement as well.
  4. A drag that selects text now navigates away. The user tries to copy "Seeberg AG" out of the cell, releases the mouse, and lands on the engagement page with the selection gone. On a list of twenty companies this makes the table feel broken.
  5. The row's own <Link> double-fires. The anchor navigates; the row's handler navigates again. On the happy path the second navigation is a no-op, but on cmd-click the link opens a new tab and the row opens in the current one, which is worse than either behaviour alone.

To that list we added a sixth, product-specific one: the row prefetched its workspace on hover. That is a good pattern on a thin link, where the pointer only touches it when the user actually means it. On a full-width row, hover triggers the moment the pointer crosses the table, which on a list of thirty engagements means thirty prefetches of thirty workspaces whenever a user scrolls past looking for one.

The Handler That Survives All Five

The whole handler is twelve lines, and every line answers one of the edges above:

function openRow(event: MouseEvent<HTMLTableRowElement>) {
  if (
    event.button > 1 ||
    (event.target as HTMLElement).closest("a, button") ||
    window.getSelection()?.toString()
  ) return
  if (event.button === 1 || event.metaKey || event.ctrlKey || event.shiftKey) {
    window.open(href, "_blank", "noopener")
  } else {
    navigate(href)
  }
}

And it is wired to the row with two listeners, not one:

<tr onClick={openRow} onAuxClick={openRow} onMouseDown={warm} className="cursor-pointer ...">

That is the whole thing. The rest of this piece is why each line is there, because the shape is the kind that looks obvious in hindsight and is unforgiving if you remove any single piece.

event.button > 1 — the right-click guard

onAuxClick fires for every non-primary button, including right-click. MDN's MouseEvent.button reference lists 2 as the right button and 0 as primary; middle is 1. Returning on > 1 means right-click, back and forward side buttons all fall through to the browser's own context menu or history navigation, which is what every user expects a right-click on a table row to do.

closest("a, button") — leave the nested interactive alone

The row contains two things the user may actually be clicking: the company name <Link> and the arrow <Link> on the right. Both still work for keyboard and screen reader users, which is why they stay in the DOM instead of being replaced with the row handler. event.target.closest("a, button") returns truthy if the real click originated inside either, in which case the row's handler steps aside and the link does its job. This is the only part of the handler that is non-negotiable for accessibility: a <tr> is not a link, cannot have an href, is not focusable, and is not read as a link by assistive tech. The visible link inside it is doing the real semantic work; the row click is just a convenience for pointer users.

window.getSelection()?.toString() — do not navigate away from a drag-to-copy

This is the one most people miss. If a user drags across the row to select text and releases on the row, click fires with the selection still populated. Checking window.getSelection()?.toString() and returning on a non-empty selection lets drag-to-copy behave like it does in every other table. There is a subtle version of this bug where selecting text in one row and releasing on another also fires click; the same guard handles it. It costs one DOM read per click, which is nothing.

event.button === 1 || metaKey || ctrlKey || shiftKey — new-tab contract

The browser's default new-tab modifiers for anchors are: cmd on macOS, ctrl on Windows and Linux, shift for a new window, and middle-click. On a <tr> all four of these are lost unless you reproduce them yourself. The order inside the condition is deliberate: button === 1 has to be checked here because by the time we reach this branch we have already filtered right-click out above, but middle-click has to open in a new tab, not navigate in place.

onAuxClick for middle-click

onClick never fires for the middle mouse button. Browsers route non-primary clicks through auxclick. Wiring the same handler to both onClick and onAuxClick is what makes middle-click actually reach the handler at all. This is a one-line consequence of the fact that the HTML click event's history predates browsers caring about middle-click new-tab semantics; auxclick is the modern event that fixes it.

The Hover Prefetch That Became A Prefetch Of Every Row

The patch has one more piece that is specific to lists this size, and worth lifting out because it generalizes. The row used to call prefetchWorkspace and prefetchView via TanStack Query on the link's onMouseEnter / onFocus, through our useIntent helper. That is the right pattern for a thin link: hover is a strong signal of intent because the pointer only touches the link when the user means it. Make the whole row the hit target and hover is suddenly a strong signal of nothing in particular: the pointer crosses rows on its way elsewhere, scrolls through the list, hovers over a header.

The fix is to move the warm call from hover to mousedown:

<tr onMouseDown={warm} ...>

mousedown fires when the button goes down, click only once it comes back up. That gap is enough to kick off a prefetch while the browser is still deciding whether the click is going to be a click or a drag. It is also only fired when the user is actually committing to an action, so the thirty-prefetches-while-scrolling problem goes away. The pattern is the same one we described in the serverless cold-start warmup ping writeup: warm the thing you are about to need, but only when there is real evidence the user is going to need it. On the backend that evidence is a claim arm firing. On the frontend it is mousedown, not mouseenter.

A side benefit worth naming: the keyboard and screen reader path still runs through the inner <Link>, which still carries the useIntent focus-based prefetch. Keyboard users get the warm; pointer users get the warm; the only thing that no longer gets the warm is the pointer trajectory, which was never a signal in the first place.

Three Rules That Generalize

Three rules survive this patch and apply to any "make the whole container clickable" change, whether the container is a list row, a card, a dashboard tile, or an item in a kanban column:

A <tr> is not a link, and must not pretend to be one for the keyboard. Full-row click is a pointer-only affordance. The real semantic link stays inside the row and keeps carrying focus, keyboard activation, href, and aria-current. Removing the inner link to "simplify" the markup is how you ship an inaccessible list that passes manual QA because the QA engineer uses a mouse. This is the same discipline that made the URL token portal auth rewrite safe: the pointer-friendly affordance sits on top of a correct underlying contract, not in place of one.

Modifier keys and middle-click are a contract, not a feature. Users expect cmd-click to open a new tab everywhere, not just on <a>. If your component intercepts clicks, it owns the whole contract: modifier handling, middle-click handling, right-click pass-through, text-selection pass-through. Shipping half of it is worse than shipping none of it, because the half you shipped silently breaks a muscle-memory shortcut users do not file a bug for; they just stop using the list.

Hover is intent only when the hit box is small. A warm on mouseenter is a cheap trick on a narrow link and an expensive mistake on a full-width row. The hit box width is the heuristic: the wider the hit box, the weaker hover is as an intent signal, and the stronger the case for moving the warm to mousedown or focus. The same thinking powers the dashboard prefetch pattern we unpicked in the useQuery vs useEffect rewrite: intent is a signal with a cost, and the cost is paid by whoever is standing closest to the signal.

The composition is what makes the patch small. Any one of the five edge cases is a two-minute fix on its own. Shipped together, as one handler, they are the difference between a row that is a button and a row that looks like a button. The one-line version is the shape most developers write first. The twelve-line version is the shape that survives contact with real users who cmd-click, drag to copy, scroll past, and press ctrl on Windows.

If you are turning a list into a set of clickable rows — or finding that users of your table are still clicking the arrow instead of the row — book a free AI Potenzial-Check, or read the debounced autosave race-conditions writeup for the same "obvious one-line change exposes four invisible edges" theme on the save path instead of the navigation path.

Sources: MDN — MouseEvent.button · TanStack Query — Prefetching

wield · The recruiting pipeline that actually scales with your volume.

CV pipeline with AI dossier generation and evaluation. For recruiters sorting a hundred applications an hour — without losing quality.

NEXT STEPWas this useful?