The formatting toolbar was open the moment the page finished painting, and nobody had clicked anything. The editor mounted, and there was the bold/italic/link strip, active, attached to an input box nobody had touched. It looked broken because it was broken, and the cause was one line we had every reason to trust.

We were building a WYSIWYG editor on Tiptap v3. The toolbar is meant to be a focus affordance. You click into the editor and the toolbar appears, you click away and it hides. The obvious wiring hangs it off the editor’s focus lifecycle, which Tiptap hands you directly:

const editor = useEditor({
extensions: [...],
onFocus: () => setFocused(true),
onBlur: () => setFocused(false),
});

focused drives whether the toolbar renders. We wrote it without a second thought, because the library was clearly offering that hook for exactly this purpose. The callback is named onFocus, and we wanted to react to focus.

Then the toolbar showed up open on mount, with no interaction. So onFocus was firing when nobody focused anything.

What the callback was reporting

useEditor does setup work when it initializes. As part of that, Tiptap v3 either programmatically focuses the editor or reports a focus state through its event system, and your onFocus callback gets invoked as a result. From React’s side the two cases look identical. The callback signature is the same whether the focus came from a user clicking in or from the editor wiring itself up during construction. Both arrive as “onFocus fired.” Our handler ran, setFocused(true) ran, the toolbar opened, and the page had done nothing but load.

The hook is named after an intent (the user focused the editor), but it fires on an event (the editor’s focus state changed, for any reason, including its own startup). Init is a state change, so init fires the hook.

You can try to paper over it. Track a “has the editor finished initializing” flag and ignore onFocus until it flips true. Debounce the first call. Compare against a mounted ref. Every one of those is a guess about Tiptap’s internal ordering, which means you are maintaining a timing assumption about a third-party library’s construction sequence. That assumption breaks the next time they touch initialization, and it breaks without any error. Your toolbar just flickers on at the wrong moment again and you are back here in six months wondering why.

What we had actually done was listen at the wrong altitude. The library callback sits above the DOM, in Tiptap’s abstraction layer, where “focus” is a state the library reports about itself. One layer down, the contenteditable node exposes browser focus events. In this integration, initialization invoked Tiptap’s callback without producing the DOM focus event we were using to represent an active editor.

The fix: listen on the node

We dropped onFocus entirely and reached through the editor instance to the underlying ProseMirror DOM node, then added a native listener:

useEffect(() => {
const el = editor?.view.dom;
if (!el) return;
const show = () => setFocused(true);
el.addEventListener("focus", show, true);
return () => el.removeEventListener("focus", show, true);
}, [editor]);

editor.view.dom is the editable element Tiptap renders. In this setup, listening for its captured focus event left the toolbar closed on initialization and opened it when the editor subsequently received focus. Browser focus can also be moved by code, so treat this as behavior we observed in our integration and test it in yours.

Two details in that snippet earn their place.

The true third argument puts the listener in the capture phase. focus does not bubble, so a listener on the bubbling phase can miss it depending on where focus lands inside the editor’s DOM. Capture catches it on the way down, on the container, regardless of which descendant technically received focus. For a node-rooted focus listener like this, capture is the reliable choice, not an optimization.

The cleanup function matters because the effect re-runs whenever the editor instance changes, and the editor can be recreated. Without cleanup you stack a new listener on each recreation and leak the old node’s binding. The capture flag has to match on the way out too, or the removal does nothing, since the browser keys listener identity on type, handler, and the capture boolean together.

Why this goes past Tiptap

We keep watching ourselves make this mistake across different libraries. When a library gives you a lifecycle callback named after a user action, check whether it fires only on that action or also during the component’s own setup and teardown. A lot of them fire on init. onFocus, onChange, onReady, onUpdate: any of these can fire once on mount with values you never meant to react to. If your handler has side effects that assume “this only runs when the user did something,” init will trip it.

The DOM gives you a lower-level event source. A native focus event means the element received focus, whether by user action or by code. An input event reports an input change, and you still need to understand how your own code can dispatch it. When a library hook leaks initialization into a handler, inspect the DOM-level behavior in your integration and subscribe at that layer if it matches the distinction you need.

There is a cost. Reaching into editor.view.dom couples you to Tiptap’s internal structure. If they rename or restructure view.dom in a future major, this breaks. We decided that was a better bet than a debounce on the first call, because a structural break is loud (the property is gone, you get an error, you fix it) while a timing assumption fails intermittently and without an error. A loud break you catch in five minutes. A silent one you ship.

The change reads as one line conceptually and came to three lines of code: delete the onFocus option, add the useEffect with the capture-phase native listener and its cleanup. The toolbar now stays shut until you click in, which is all we wanted it to do.

Test startup before you hang UI on it

If a library’s lifecycle callback is named after a user action, check whether it also fires during the component’s own startup before you attach visible behavior to it. If it does, and you only care about real user events, listen on the underlying DOM node and accept the coupling to the library’s internals. A structural break shows up as an error, and a timing assumption does not.