tiptap: onUpdate callback does not update after re-render

What’s the bug you are facing?

I have a wrapper around tiptap, whenever the text changes I trigger a request to my back-end, this works fine the first time tiptap is mounted but the parent component (where tiptap is mounted) can change its internal variables, and therefore the closure should capture a new context, the problem is that it doesn’t after the parent component changes the state, the closure/lambda passed on the onUpdate function remains the same and therefore tiptap tries to update the wrong component.

Here is some of the code, my high level component on the parent, notice the id param, which is the param that changes at some point:

<Tiptap
  onFocus={({ editor }) => editor.commands.blur()}
  initialContent={project.notes ? JSON.parse(project.notes) : null}
  placeholder="You can add a default checklist in the settings."
  className="md:max-w-2xl lg:max-w-none"
  onChange={async (e) => {
    console.warn("URL PARAM ID", id) // ALWAYS REMAINS THE SAME, THEREFORE CANNOT UPDATE THE PROJECT CORRECTLY

    await updateProjectMutation({
      id,
      notes: JSON.stringify(e),
    })
    refetch()
  }}
  ref={tiptapRef}
/>

My internal TIptap implementation, notice the onUpdate function that I’m passing to the useEditor hook:

import Link from "@tiptap/extension-link"
import Placeholder from "@tiptap/extension-placeholder"
import TaskItem from "@tiptap/extension-task-item"
import TaskList from "@tiptap/extension-task-list"
import { BubbleMenu, EditorContent, Extension, useEditor } from "@tiptap/react"
import StarterKit from "@tiptap/starter-kit"
import React, { forwardRef, useImperativeHandle, useState } from "react"
import { useBoolean } from "../hooks/useBoolean"
import { Button } from "./Button"

interface IProps {
  editable?: boolean
  onClick?: (this: unknown, view: any, pos: number, event: MouseEvent) => boolean
  initialContent?: any
  // content?: any
  onChange?: (content: any) => void
  autofocus?: boolean | null | "end" | "start"
  onFocus?: (params: { editor: any }) => void
  placeholder?: string
  className?: string
}

export const Tiptap = forwardRef<any, IProps>(
  (
    {
      editable = true,
      onClick,
      initialContent,
      onChange,
      autofocus,
      onFocus,
      placeholder,
      className,
      // content,
    },
    ref
  ) => {
    const [isAddingLink, addLinkOn, addLinkOff] = useBoolean()
    const [link, setLink] = useState("")
    const editor = useEditor({
      autofocus,
      onFocus: onFocus ? onFocus : () => {},
      editorProps: {
        attributes: {
          class: "prose focus:outline-none dark:prose-dark dark:text-gray-300 text-base",
        },
        editable: () => editable,
        handleClick: onClick,
      },
      content: initialContent,
      onUpdate: ({ editor }) => {
        onChange?.(editor.getJSON())
      },
      extensions: [
        StarterKit,
        Placeholder.configure({
          showOnlyWhenEditable: false,
          placeholder,
        }),
        TaskList.configure({
          HTMLAttributes: {
            class: "pl-0",
          },
        }),
        TaskItem.configure({
          HTMLAttributes: {
            class: "before:hidden pl-0 flex items-center dumb-prose-remove",
          },
        }),
        Extension.create({
          // Do not insert line break when pressing CMD+Enter
          // Most of the time handled by upper components
          addKeyboardShortcuts() {
            return {
              "Cmd-Enter"() {
                return true
              },
              "Ctrl-Enter"() {
                return true
              },
            }
          },
        }),
        Link,
      ],
    })

    useImperativeHandle(ref, () => ({
      getEditorInstance() {
        return editor
      },
    }))


    return (
        <EditorContent editor={editor} className={className} />
    )
  }
)

In any case, it seems the useEditor hook saves only the first passed onUpdate function and does not update it in sub-sequent renders

How can we reproduce the bug on our side?

Attached the code above, but if necessary I can try to reproduce the issue in a code sandbox

Can you provide a CodeSandbox?

No response

What did you expect to happen?

The passed callback onUpdate should be updated when a new value is passed to it, instead of constantly re-using the first memoized value

Anything to add? (optional)

I tried to update tiptap to the latest version but then I faced this other crash: https://github.com/ueberdosis/tiptap/issues/577 so I reverted to my old/current versions

"@tiptap/extension-bubble-menu": "2.0.0-beta.51",
    "@tiptap/extension-link": "2.0.0-beta.33",
    "@tiptap/extension-placeholder": "2.0.0-beta.45",
    "@tiptap/extension-task-item": "2.0.0-beta.30",
    "@tiptap/extension-task-list": "2.0.0-beta.24",
    "@tiptap/react": "2.0.0-beta.98",
    "@tiptap/starter-kit": "2.0.0-beta.154",

Did you update your dependencies?

  • Yes, I’ve updated my dependencies to use the latest version of all packages.

Are you sponsoring us?

  • Yes, I’m a sponsor. 💖

About this issue

  • Original URL
  • State: closed
  • Created 2 years ago
  • Reactions: 12
  • Comments: 35 (3 by maintainers)

Most upvoted comments

yarn cache clean and node_modules + yarn.lock delete and yarn install. this may solve the problem.

I just ran into the same issue, it caught me completely off guard. @ospfranco I’ve found this seems to work too. Not sure if it’s the right / best way to use the off / on methods. 🤷

const { onChange } = props;

...

// Don't set onUpdate in here
const editor = useEditor(...);

...

useEffect(() => {
    editor.off("update");
    editor.on("update", ({ editor: updatedEditor }) => onChange(updatedEditor.getHTML()));
}, [editor, onChange]);

This was very hard to find. Please add this to the quickStart docs?

So I had the same issue as everyone here, this is how I fixed it in the end: image

So small clarifications… the importatant thing is this:

  const editor = useEditor(
   {
     extensions: [StarterKit, Document, Paragraph, Text, Image, Dropcursor],
     content: editorContent,
     // onUpdate: ({ editor }) => {
     //   setEditorContent(editor.getHTML());
     // },
     onBlur: ({ editor }) => {
       setEditorContent(editor.getHTML());
     },
   },
   [editorContent]
 );

I’ve added a “dependency” to the state of the content of my Editor. So everytime I do “onBlur” the function will be called.

Also I’ve added a useEffect which just updates the state value when the data loads. The tricky part was understanding that the “Dependency” in this case editorContent actually triggers after onBlur in this case. At least thats how I understood it, please correct me if I’m wrong.

When I used onUpdate I always lost focus on the Editor and had to write letter by letter and click on the input everytime, but with “onBlur” that is not the case anymore

chrome_u4oKIgKGW7

Hey everyone 👋,

I just want to share how we are handling this ourselves, and this is something we did from the beginning and never noticed any issues:

  • We have a wrapping component (i.e. RichTextEditor) around useEditor and <EditorContent> (just like the OP example)
  • We follow rules of hooks strictly, and every dependency of useEditor is added to the dependency array
  • We use a custom useEditor hook to make sure that the value returned is never null (this is not SSR compatible):
  • Then there are a bunch of things that we do in the consumer side:
    • We set the initial content like this:
      const contentRef = useRef(initialContent)
      return <RichTextEditor content={contentRef.current} />
      
      • This makes sure the editor instance is not recreated/rerendered when the content changes (i.e. when a user types in the editor)
    • We make sure to use useMemo where appropriate, for instance, our RichTextEditor component takes an extensions array to feed into useEditor, and since extensions is on the dependencies array, we need to memoize this value (rules of hooks)
    • We use useEventCallback instead of useCallback for every callback (e.g., onCreate, onUpdate, onSelectionUpdate, onTransaction, etc.)
  • We also use useImperativeHandle to expose the internal editor instance to parent components

And that’s pretty much it… We don’t seem to observe any flickers, every callback seems to work as expected, and everything is updated when we need it.

Facing new issues now, here is the updated version of my component:

import Link from "@tiptap/extension-link"
import Placeholder from "@tiptap/extension-placeholder"
import TaskItem from "@tiptap/extension-task-item"
import TaskList from "@tiptap/extension-task-list"
import { EditorContent, EditorEvents, Extension } from "@tiptap/react"
import StarterKit from "@tiptap/starter-kit"
import React, { FC, memo, useState } from "react"
import { useBoolean } from "../hooks/useBoolean"
import { useEditor } from "../hooks/useEditor"

interface IProps {
  editable?: boolean
  // onClick?: (this: unknown, view: any, pos: number, event: MouseEvent) => boolean
  content?: any
  onUpdate?: (params: EditorEvents["update"]) => void
  autofocus?: boolean | null | "end" | "start"
  onFocus?: (params: EditorEvents["focus"]) => void
  placeholder?: string
  className?: string
}

export const Tiptap: FC<IProps> = memo(
  ({ editable = true, content, onUpdate, autofocus, onFocus, placeholder, className }) => {
    console.warn("tiptap render content", content)

    const [isAddingLink, addLinkOn, addLinkOff] = useBoolean()
    const [link, setLink] = useState("")
    const editor = useEditor(
      {
        autofocus,
        // onFocus: onFocus ? onFocus : () => {},
        editorProps: {
          attributes: {
            class: "prose focus:outline-none dark:prose-dark dark:text-gray-300 text-base",
          },
        },
        content,
        onUpdate: onUpdate ? onUpdate : () => {},
        extensions: [
          StarterKit,
          Placeholder.configure({
            showOnlyWhenEditable: false,
            placeholder,
          }),
          TaskList.configure({
            HTMLAttributes: {
              class: "pl-0",
            },
          }),
          TaskItem.configure({
            HTMLAttributes: {
              class: "before:hidden pl-0 flex items-center dumb-prose-remove",
            },
          }),
          Extension.create({
            // Do not insert line break when pressing CMD+Enter
            // Most of the time handled by upper components
            addKeyboardShortcuts() {
              return {
                "Cmd-Enter"() {
                  return true
                },
                "Ctrl-Enter"() {
                  return true
                },
              }
            },
          }),
          Link,
        ],
      },
      [onUpdate, content]
    )

    return (
      <div>
        <EditorContent editor={editor} className={className} />
      </div>
    )
  }
)
  • I added the onUpdate callback and the content prop to the dependency list, this causes the component to flicker (I assume because it is destroying itself every time the dependency changes)
  • I had to comment out the BubbleMenu because that also crashes after the internal gets destroyed

All in all, these seem major issues with tiptap, never thought I would face so many problems by trying to attach a simple onUpdate handler

Hi @philippkuehn I have the similar issue. Here is a minimal codesandbox

Steps to reproduce:

  1. Type smth in editor, the message “updated” is logged as expected
  2. Click resubscribe
  3. Type smth in editor, the message “updated” is logged but the message “resubscribe updated” is expected.

Looking into core Editor code I see that subscription only happen in constructor https://github.com/ueberdosis/tiptap/blob/main/packages/core/src/Editor.ts#L89 and if setOptions is used with new handlers, the event emitter contains “stale” handlers because there is no resubscription in setOptions.

I have a similar problem where I created an extension that takes a function in (onSubmit) that gets called when a user presses MOD+Enter. However, the closure is stale.

  • We follow rules of hooks strictly, and every dependency of useEditor is added to the dependency array —> OF useEditor

For whatever reason, I completely did NOT understand the first 5 times I read this. The answer makes me feel stupid for wasting days. I completely neglected to realize this is a hook, just like useEffect. Use the dependency array.

const editor = useEditor(
    {
      //all the things
    },
    [valuesEditorNeedsToTrack, valuesEditorNeedsToTrack]
);

I’ve had the same issue with a stale onUpdate function, but I managed to fix it with this:

export default function TipTap({ onChange }) {
  // Save the content in a local state
  const [theContent, setTheContent] = useState<string>();

  const editor = useEditor(
    {
      // ...
      onUpdate: ({ editor }) => {
        // Update the local state with the content of the editor
        setTheContent(editor.getHTML());
      },
      // ...
    },
  );

  // Listen for changes to the theContent and call the 'onChange' prop
  useEffect(() => {
    if (!(theContent && props.onChange)) {
      return;
    }

    props.onChange(theContent);
  }, [theContent]);

  return <EditorContent editor={editor} />;
}

So far it seems to work as I want it to 😃

@rfgamaral - would you mind providing a small amount of sample code to show you you can use an external function inside of the onUpdate call? Specifically elaborating on this?

useEventCallback instead of useCallback for every callback (e.g., onCreate, onUpdate, onSelectionUpdate, onTransaction, etc.)

When call the effect useEditor, I define the code I want to call in that callback declaration. But when I call other functions inside of onUpdate, those functions have stale data, which I assume means the function itself is stale, but I don’t know. useEventCallback and useCallback but have no familiarity with how to use them in this context to force the functions to be able to use fresh data.

My editor looks like this

const editor = useEditor(
    {
      extensions: [StarterKit, Comment, Underline],
      //autofocus: "start", //autofocus: getPosition() || "start",
      content: contentRef.current,
      onUpdate({ editor }) {
        if (commentAction) return;
        clearTimeout(timerRef.current);
        timerRef.current = setTimeout(() => {
          handleChange(editor.getHTML(), documentPosition, projectData); //Stale closures
        }, 2500);
      },
    },
  ), [timerRef, chapterIdRef.current];

onUpdate always sends stale data in the form of projectData to handleChange If I add handleChange to the dependency array of the editor, it gets the right data, but then it rerenders the editor causing flickering and losing focus and cursor position.

Also, handleChange is in the parent component, passed as a prop to the editor component, and handle change also is in the form of (which I think does nothing because the editor is sending old data because it doesn’t realize there’s a newer version of the function?

const updatedData = useCallback(await handleContentChange(projectData) {

  },[projectData]);

Moving the onUpdate part out entirely like this may actually be working with focus remaining and no flickering…

useEffect(() => {
    const onChange = (editor) => {
      if (commentAction) return;
      clearTimeout(timerRef.current);
      console.log("%cClear", "color: cyan");
      timerRef.current = setTimeout(() => {
        console.log("%cExecuting", "color: lime");
      }, 2500);
    };
    editor &&
      editor.on("update", ({ editor }) =>
        onChange(editor)
      );
    return () => {
      editor && editor.off("update", ({ editor }) => onChange(editor));
    };
  }, [documentPosition, editor, handleChange, projectData]);

Last one hopefully, I think I finally understood the gentlemen’s comment from above.

This is the onChange function for me, something I made, and used the useCallback hook with it so that it doesn’t create MANY instances of the function and cause each press to call it dozens of times.

const onChange = useCallback(({editor}) => {
    if (commentAction) return;
    clearTimeout(timerRef.current);
    console.log("%cClear", "color: cyan");
    timerRef.current = setTimeout(() => {
      console.log("%cExecuting", "color: lime");
      //handleUpdateChapter({ ...chapter, content: editor.getHTML() }); //WORKS RIGHT
      handleChange(editor.getHTML(), documentPosition, projectData); //Stale closures
    }, 2500);
  }, [documentPosition, handleChange, projectData]);

onChange is in the dependency array of the useEffect that is keeping track of the editor so that the editor, and the onChange function are always in sync. At least I hope so 😅

  useEffect(() => {
    editor && editor.on("update", onChange);
    return () => {
      editor && editor.off("update", onChange);
    };
  }, [editor, onChange]);

This issues persists inside the addKeyboardShortcuts(). I’m trying to submit data on the click of the Enter button.

I’m struggling so much with this 😭

Here is a demo of how I got callbacks working:

codesandbox.io/s/upbeat-hermann-d3dlw5?file=/src/App.js:1634-1670

@joe-pomelo Thanks! That’s what I did as well. Would be great if I could use the default onUpdate method for this tho.

Any updates on this issue? I have the same problem. I cannot use closures inside Tiptap extensions. They are always stale

I need to have a single source of truth for my content outside of Tiptap, especially because I have my own history system for undo/redo. With Tiptap not reacting to content changes, my undo/redo won’t work. If I use useEditor’s dependency list, I will lose focus at every keystroke, and dealing only with onBlur is a no-go for me. autofocus doesn’t work for me, either.

I somehow manage to get Tiptap to react to external content changes, emitting onChange with onUpdate, and retaining focus. This is the minimal snippet to get this behaviour working.

const RichTextEditor = ({ value, onChange }) => {
  const cursor = useRef<number>();

  const editor = useEditor({
    content: value,
    parseOptions: { preserveWhitespace: "full" },
    onUpdate: ({ editor }) => {
      onChange(editor.getHTML());
    },
    onSelectionUpdate: ({ editor }) => {
      cursor.current = editor.state.selection.anchor;
    },
  });

  useEffect(() => {
    if (cursor.current === undefined) return;

    editor?
      .chain()
      .setContent(value, false, { preserveWhitespace: "full" })
      .setTextSelection(cursor.current)
      .run();
  }, [value, editor])

  return <EditorContent editor={editor} />;
};

Basically, we track the cursor position so that we can return it later. setContent will set the cursor to the last character, hence setTextSelection. The key is actually the preserveWhitespace: "full" on both useEditor and setContent. This will make it so that you can actually type spaces at the ends of lines in Tiptap. Otherwise, Tiptap will trim your whitespace, and when you press Space, the cursor moves to the next line (if any) or return back to the last character (trimmed).

Since we’re using useRef to track this, and using setContent to sync content, there shouldn’t be any recreations of editor (though I’m too lazy to prove it).

[!NOTE] You can get a similar effect by using useEditor’s dependency list instead of setContent in useEffect above, but I found that recreating the editor seemed to cause a lag/delay such that if the user types really really fast, the cursor “can’t keep up” and characters get jumbled up.

If the “cursor restoration” is not to your liking, you can modify onSelectionUpdate and choose which position to save based on editor.state.selection.empty, maybe? Go crazy. I find that this set-up works fine for me, for now.

On another note, I think Tiptap is a great library with the best DX I’ve seen so far for building rich text editors. But the lack of first-party controlled state support is almost a turn-off for me. I hope we can see a better solution soon, because what I have here is really, really, really hacky 💀

This isn’t ideal either, but is probably better than the editor continuously being recreated, blurred, and then putting the old content into the new editor like is done in https://github.com/ueberdosis/tiptap/issues/2403#issuecomment-1248013862

  const editor = useEditor({
    content: initialContent,
    editorProps: {
      handleKeyDown: onKeyDown,
    },
  });

  useEffect(() => {
    editor?.setOptions({
      editorProps: {
        handleKeyDown: onKeyDown,
      },
    });
  }, [editor, onKeyDown]);

This way, every time the onKeyDown callback changes, we just call editor.setOptions with the new editorProps. This way you don’t need to worry about getting a new instance of editor on every render.

Ideally, I would like useEditor to always return the same editor instance with updated content and editorProps, but as it is now it always returns a new editor instance.

This issues persists inside the addKeyboardShortcuts(). I’m trying to submit data on the click of the Enter button.

@iandsouza2000 Yes it does indeed persist. I am also trying to submit data on Enter. This seems like an extremely common use case.

@RipaltaOriol so you have different components for each row and the initialization of the editor is in row 0? Or do you just have 1 component and different rows and set the text initially to row0 and want to be able to click other rows and update the text accordingly. Could you provide a codesandbox with the code? Do you mean something like this here?: https://codesandbox.io/s/tiptap-editor-update-text-from-rows-60lci8?file=/src/EditorContainer.tsx

I had exactly the same problem with the TipTap editor. I wanted to create a generic TextEditor component, which I in turn use from several other components. From these other components I wanted to always be able to retrieve the state of the TextEditor component (e.g. to provide adhoc validation), but as has already been written here, the updates within the editor instance were not correctly propagated out to the parent.

I have now found another solution for this. In doing so, I still have a generic TextEditor component, but I exported a method outside the rendering function that returns the useEditor hook and at the same time uses the extensions and options I need. I then have to use this extracted method in my other components and can then use my generic TextEditor component at the same time and have the ability to check the current state at any time.

TextEditor.tsx:

import Document from "@tiptap/extension-document";
import Paragraph from "@tiptap/extension-paragraph";
import Placeholder from "@tiptap/extension-placeholder";
import Text from "@tiptap/extension-text";
import { Editor, EditorContent, useEditor } from "@tiptap/react";
import { styled } from "@stitches/react";

const TextEditor = styled("div", {
  ".text-editor": {
    "> p": {
      margin: 0,

      "&.is-editor-empty:first-child::before": {
        color: "grey",
        content: "attr(data-placeholder)",
        float: "left",
        height: 0,
        pointerEvents: "none",
      },
    },
    "&[contenteditable=false] > p.is-editor-empty": {
      cursor: "pointer",
    },
    minHeight: "200px"

  },
});

export const useTextEditor = (
  isInitiallyEditable = true,
  content?: string
): Editor | null => {
  return useEditor({
    extensions: [
      Document,
      Text,
      Paragraph,
      Placeholder.configure({
        placeholder: "Type to add something …",
        showOnlyWhenEditable: false,
      })
    ],
    editorProps: {
      attributes: {
        class: "text-editor",
      },
    },
    content: content ? JSON.parse(content) : null,
    editable: isInitiallyEditable,
    autofocus: true,
  });
};

interface TextEditorProps {
  editor: Editor | null;
}

const DefaultTextEditor: React.FC<TextEditorProps> = ({ editor }) => {
  return (
    <TextEditor>
      <EditorContent editor={editor} />
    </TextEditor>
  );
};

export default DefaultTextEditor;

EditorContainer.tsx:

import DefaultTextEditor, { useTextEditor } from "./TextEditor";

const EditorContainer: React.FC = () => {
  const editor = useTextEditor();
  return (
    <>
      <div>is editor empty?: {editor?.isEmpty ? "true" : "false"}</div>
      <span>content: {JSON.stringify(editor?.getJSON())}</span>
      <DefaultTextEditor editor={editor} />
    </>
  );
};

export default EditorContainer;

here is my working codesandbox: https://codesandbox.io/s/tiptap-editor-state-between-child-and-parent-component-9m0bkh

I’m also struggling with this. I have a setup similar to the ones above, with a wrapper passing dynamic content down to the RichTextEditor and handling updates. The problem is when content changes, I call the onChange through the onUpdate, it updates Firestore, comes back and causes the editor to lose focus, so I can’t continue typing.

I could call onChange through the onBlur but I don’t want users to lose data if they type and close the tab without blurring. How is everyone handling that flow of content being updated and coming back?

Here is my code for reference:

function RichEditor(props) {
  const { hideToolbar, content, onChange } = props;

  let editor = useEditor(
    {
      extensions: [
        StarterKit,
        TaskList.configure({
          HTMLAttributes: {
            class: 'task-list',
          },
        }),
        TaskItem.configure({
          nested: true,
        }),
      ],
      parseOptions: {
        preserveWhitespace: true,
      },
      content: content,

      onUpdate({ editor }) {
        onChange(editor);
      },
    },
    [content]
  );

  return (
    <>
      <EditorContent editor={editor} className='note' />
      {!hideToolbar && <MenuBar editor={editor} />}
    </>
  );
}

And the RichEditor instance:

<RichEditor
      hideToolbar={hideToolbar}
      content={note.body_html}
      onChange={updateNoteDebounced}
 />

updateNoteDebounced will send the note to Firestore, which will then re-render the component above and send the new content (note.body_html) back to the editor, causing the lose of focus.