---
title: "Tiptap v3 Fires onFocus During Init, So Subscribe to the DOM Instead"
canonical: https://dxdev.com/blog/tiptap-onfocus-fires-on-init-subscribe-to-the-dom/
datePublished: 2026-02-24
---
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:

```ts
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:

```ts
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.

## Related

- [A Tiptap Editor That's Invisible Until You Add ProseMirror's Base CSS](tiptap-invisible-without-prosemirror-base-css): another Tiptap integration where functional behavior still needed deliberate user-facing setup
- [PayPal Pay-Later Messages Won't Re-Render, So Destroy the Node and Recreate It](paypal-messages-wont-rerender-destroy-the-node): another third-party component whose update behavior required an explicit workaround
