My formatting toolbar showed up before anyone had touched the page. The editor mounted, the page finished painting, and there was the bold/italic/link strip sitting open and active, attached to an input box nobody had clicked into. It looked broken because it was broken, and the cause was one line I had every reason to trust.

I was building a WYSIWYG editor on Tiptap v3. The toolbar is supposed to be a focus affordance: you click into the editor, the toolbar appears; you click away, it hides. Standard inline-editing pattern. The obvious wiring is to hang it off the editor’s focus lifecycle, which Tiptap hands you on a plate:

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

focused drives whether the toolbar renders. Clean, declarative, exactly the kind of thing you write without a second thought because the library is clearly offering you the hook for precisely this purpose. The callback is named onFocus. I want to react to focus. Done.

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

What’s actually happening

useEditor does setup work when it initializes. As part of that initialization, 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 there is no way to tell the two cases apart. The callback signature is identical whether the focus came from the user clicking in or from the editor wiring itself up during construction. Both arrive as “onFocus fired.” Your handler runs, setFocused(true), toolbar opens, and the page has done nothing but load.

This is the trap with lifecycle callbacks in general. The library exposes a hook named after an intent (the user focused the editor) but the hook actually fires on an event (the editor’s focus state changed, for any reason including its own startup). The name promises user semantics. The implementation delivers raw state changes. 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, and you are now maintaining a timing assumption about a third-party library’s construction sequence. That assumption breaks the next time they touch initialization, and it breaks silently, because all that happens is your toolbar flickers on at the wrong moment again and you are back here in six months wondering why.

The real problem was that I was listening 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 actual contenteditable node exposes browser focus events. In this integration, initialization invoked Tiptap’s callback without producing the DOM focus event I was using to represent an active editor.

The fix: listen on the node, not the library

Drop onFocus entirely. Reach through the editor instance to the underlying ProseMirror DOM node and add 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 programmatically, so this is an observed integration behavior to test, not a universal proof of user intent.

Two details in that snippet earn their place.

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

The cleanup function (removeEventListener with the same capture flag) 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 or the removal silently no-ops, since the browser keys listener identity on type, handler, and the capture boolean together.

The general move

The reason I’m writing this up isn’t really Tiptap. It’s the shape of the mistake, because I keep watching myself make it 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 whether it also fires during the component’s own setup and teardown. A lot of them fire on init. The callback is the library telling you about its internal state, and the library’s internal state changes during construction. onFocus, onChange, onReady, onUpdate: any of these can fire once on mount with values you never intended to react to. If your handler has side effects that assume “this only runs when the user did something,” init will trip it.

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

There’s a cost, and I want to be honest about it. 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. I decided that’s a better bet than a debounce-the-first-call timing hack, because a structural break is loud (the property is gone, you get an error, you fix it) whereas a timing assumption fails quietly and intermittently. A loud break you’ll catch in five minutes. A quiet one you’ll ship.

This shipped as a one-line conceptual change that was really a three-line code change: 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 I ever wanted it to do. The lesson I’m keeping is the cheaper one: when a library’s lifecycle callback fires on init and you only care about real user events, stop arguing with the abstraction and listen on the underlying node.