Skip to content
Crixaa
← All posts

React portals still bubble

The Crixaa team4 min read

The colour picker looked fine. You could click a swatch, drag around the saturation square, and then click into the hex field, type six characters, and watch absolutely nothing happen.

What it was not

The input was not disabled and had no readOnly. Its onChange worked — picking a swatch updated the displayed value through the same state, and dispatching a change event at the node ran the handler. Nothing was covering it: document.elementFromPoint at the centre of the field returned the input itself.

What was missing was focus. After clicking the field, document.activeElement was still document.body. No caret, no selection, and every keystroke went to the document instead of the input — which is why the field behaved like a picture of a field.

Portals escape the DOM, not the React tree

The popover is rendered with createPortal into document.body, for the usual reason: the toolbar has overflow: hidden and a stacking context, and a popover that lives inside it gets clipped.

What moves is the DOM node. The event path does not. React's synthetic events propagate along the React tree — the component hierarchy you wrote — not the DOM tree the browser ended up with. A mousedown inside a portaled popover is still delivered to the React ancestors of the component that called createPortal, however far away those ancestors sit in the document.

In our case the ancestor was the formatting toolbar:

function Toolbar() {
  return (
    <div onMouseDown={(e) => e.preventDefault()}>
      <ColourButton />  {/* renders its popover into document.body */}
    </div>
  )
}

In the DOM the popover was a sibling of the whole application. As far as React was concerned it was a child of that div.

The handler was doing its job

That preventDefault is not a mistake; most rich text editors have some version of it. Pressing a toolbar button with the pointer moves focus off the editable canvas, which collapses the selection, which means that by the time the button's onClick fires there is nothing selected to make bold. Cancelling the default action of mousedown keeps focus and selection where they were, and the button applies to the text the user actually chose.

The part that is easy to forget is what else that default action does. Focusing the element you clicked is the default behaviour of mousedown. Cancel it and nothing gains focus — not the toolbar, which is the point, and not the hex input, which is not. The field was being denied focus by a handler written for a different element, reached through a tree that does not exist in the DOM.

stopPropagation, not preventDefault

The reflex when a parent handler misbehaves is to call preventDefault in the child. That makes it worse. The input needs the default behaviour: focus on click, caret placement at the character you clicked, drag to select. Nothing about the browser's handling of that event is wrong.

What has to stop is the event reaching a handler that will cancel it:

createPortal(
  <div className="picker-popover" onMouseDown={(e) => e.stopPropagation()}>
    <input value={hex} onChange={(e) => setHex(e.target.value)} />
  </div>,
  document.body,
)

Two calls that get used interchangeably and should not:

What is wrongWhat to call
The browser's default action is wrong for youpreventDefault()
An ancestor's handler is wrong for youstopPropagation()

Since React 17 the real DOM listener lives on the root container and React replays propagation itself, so what you are stopping here is React's walk up the component tree — which is the walk that was causing the problem.

Ask the event, not the code

What settled this was not reading components. We had a shortlist of suspects and a plausible story for each, which is the state in which you can spend an hour being confidently wrong. Ask the event instead:

const el = document.querySelector('.picker-popover input')
const ev = new MouseEvent('mousedown', { bubbles: true, cancelable: true })
el.dispatchEvent(ev)
console.log(ev.defaultPrevented) // true — something upstream cancelled it

A bubbling, cancellable mousedown reaches React's root listener, React runs its handlers, and any preventDefault one of them calls shows up on the native event you still hold. true converts "which of these five things might be responsible" into "something cancels mousedown above this node", which is a grep.

There is a second hint nearby. Chrome's getEventListeners($0) on the input shows nothing interesting, because React's listeners are not on the input — and that emptiness is the tell that you should stop reading the DOM and start reading the component tree.

What to check when a field will not take focus

Four questions, in order, each with a one-line answer in the console. Is the node hit testable — elementFromPoint at its centre. Does it receive the event — a listener that logs. Is the default prevented — defaultPrevented on a dispatched event. Does it end up focused — document.activeElement. Whichever one first answers no is where the bug is, and answering them in order stops you rewriting a component that was never at fault.

The general form: a portal changes where a node lives, not where its events go. If you moved something to document.body to escape a z-index or an overflow, that is all you escaped — every handler above you in the React tree is still above you.

Try it on your own document

Design a template in the browser and generate a real PDF — free, no card.