Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

onUpdate callback does not update after re-render #2403

Closed
1 of 2 tasks
ospfranco opened this issue Jan 18, 2022 · 43 comments · Fixed by #3811
Closed
1 of 2 tasks

onUpdate callback does not update after re-render #2403

ospfranco opened this issue Jan 18, 2022 · 43 comments · Fixed by #3811
Labels
Type: Bug The issue or pullrequest is related to a bug
Milestone

Comments

@ospfranco
Copy link

ospfranco commented Jan 18, 2022

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: #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. 💖
@ospfranco ospfranco added the Type: Bug The issue or pullrequest is related to a bug label Jan 18, 2022
@ospfranco
Copy link
Author

ok, it seems like I've been using the useEditor hook very wrong, as you can see I was using an imperative handle to try to set the content of the editor after a parent re-render, but everything needs to go as a dependency in the useEditor hook and tiptap should re-render itself...

Just as a comment, the examples on the documentation page should mention this more clearly

Anyways, sorry for the confusion and thanks for the package!

@ospfranco
Copy link
Author

ospfranco commented Jan 18, 2022

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

@ospfranco
Copy link
Author

I got it working by using refs, which is very inelegant and makes the code quite hard to understand:

const [updateProjectMutation] = useMutation(updateProjectNotes)
const tiptapOnUpdateRef = useRef(async ({ editor }: EditorEvents["update"]) => {
  await updateProjectMutation({
    id,
    notes: JSON.stringify(editor.getJSON()),
  })
  refetch()
})

useEffect(() => {
  if (project) {
    const editor: Editor = tiptapRef.current?.getEditorInstance()
    if (editor && project.notes) {
      editor.commands.clearContent() // Needed because the checkboxes do not update
      editor.commands.setContent(JSON.parse(project.notes))
      tiptapOnUpdateRef.current = async ({ editor }) => {
        await updateProjectMutation({
          id,
          notes: JSON.stringify(editor.getJSON()),
        })
        refetch()
      }
    }
  }
}, [id, project])

@colindb
Copy link

colindb commented Jan 20, 2022

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]);

@ospfranco
Copy link
Author

@colindb I assumed setOptions should do something similar, but it doesn't seem to be working, in any case, both solutions attempt to do the same, I like yours better, since passing refs is somewhat verbose and hard to reason about

@philippkuehn
Copy link
Contributor

I am not sure I understand the problem correctly. Can you set up an absolutely minimal codesandbox for this?

@productdevbook
Copy link

productdevbook commented Feb 8, 2022

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

@ospfranco
Copy link
Author

ospfranco commented Feb 8, 2022

Hmm if you mean if callback should get updated, this has nothing to do with the node_modules, I already tried updating the latest version, but it is rather a problem of how the hook + editor instance works

The hook takes an array of dependencies to re-render the editor, in order for the callbacks to be updated one has to declare them in the dependency array of the hook, but this causes other issues, basically, the internal instance of the editor gets destroyed and re-created, this causes flickering issues and what not

The workarounds we found, are basically hard replacing the saved callbacks on the first instantiation of the hook, but it's not ideal because: a) it's not documented (I think) and b) it's just really awkward and hard to understand

@roman-kulakov
Copy link

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.

@amorriscode
Copy link

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.

@rfgamaral
Copy link
Contributor

rfgamaral commented Mar 9, 2022

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.

@sharno
Copy link

sharno commented May 21, 2022

I faced the same issue and created a codesandbox for it.
To test it look at the console and try to select text in the editor.

Expected behavior: be able to continue selecting while onSelectionUpdate changes.

This seems to be a working workaround for now:

editor.off("selectionUpdate")
editor.on("selectionUpdate", () => ...)

edit: I tested out .setOptions({...}) but it doesn't seem to update the listeners at all, not sure if I should file another bug for that?

https://codesandbox.io/s/gifted-river-1v58kf?file=/src/App.tsx

@rfgamaral
Copy link
Contributor

Your missing the dependencies array in the useEditor hook. Add at least onChange in there, and it should work as you expect it.

Don't forget to wrap onChange with useCallback on the consumer, otherwise the editor will be recreated with every keystroke.

@sharno
Copy link

sharno commented May 22, 2022

I didn't get what you mean, I'm using [state] as a dependency array in useEditor and it's causing it to rerender the whole editor while the state changes every second, so if you try to select the text you cannot really select it.

I didn't understand where the onChange you mean is? Can you show your edits in a codesandbox?

@gthemiller
Copy link

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

@dbousamra
Copy link

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

@jedgrant
Copy link

  • 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]
);

@jedgrant
Copy link

jedgrant commented Jul 24, 2022

@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]);

@alminisl
Copy link

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

@piotrkulpinski
Copy link

Any update on this? It seems to still be the case as of today.

@joe-pomelo
Copy link

Here is a demo of how I got callbacks working:

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

@piotrkulpinski
Copy link

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.

@matheusbaumgart
Copy link

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.

@matheusbaumgart
Copy link

I'm struggling so much with this 😭

@jezzdk
Copy link

jezzdk commented Feb 2, 2023

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 :)

@RipaltaOriol
Copy link

RipaltaOriol commented Feb 4, 2023

[content]

Did this work for you? Passing in [content] as dependency of useEditor is not doing anything for me. The problem is that I have multiple notes to edit with the TipTapEditor and the contents of the editor should dynamically change depending on what note I select.

So I need to 1. pass note as the content to update contents for every time I change note and 2. update note when I type in the editor. But it looks like this two functions are stepping in each others toes because when I type something on the editor it runs onUpdate which re-renders the editor because note is passed as a dependency.

My code:

const TipTapEditor = ({ note, setNote }) => {

    const editor = useEditor(
        {
            extensions: [StarterKit, Underline],
            content: note,
            onUpdate({ editor }) {
                setNote(editor.getHTML());
            },
        },
        [note]
    );

    return (
        <NotesBorder className="text-editor">
            <MenuBar editor={editor} />
            <EditorContent editor={editor} />
        </NotesBorder>
    );
};

export default TipTapEditor;

@mclazer
Copy link

mclazer commented Feb 5, 2023

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

@RipaltaOriol
Copy link

RipaltaOriol commented Feb 18, 2023

@mclazer I really like your approach! I've tried replicating it and although I had some success for the most part I'm finding it difficult to update the contents of the editor; I want to change the text editor when necessary.

For example, I initialize useTextEditor to text in row 0, but when I click on row 5 I want to be able to update the contents of the editor to the text in there. I've tried using custom handler functions and useEffect but so far nothing is working because useTextEditor is a hook itself. Were you able to find a work around?

@mclazer
Copy link

mclazer commented Feb 18, 2023

@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

@RipaltaOriol
Copy link

@mclazer its the latter. I solved the problem thanks to you sandbox. I was importing the useTextEditor wrong and caused React to think the function was a custom hook instead.

@iandsouza2000
Copy link

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

@adamjerickson
Copy link

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.

@NAllred91
Copy link

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 #2403 (comment)

  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.

@anargiris
Copy link

anargiris commented Dec 31, 2023

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

I can confirm that it fixed the issue for me too, but is this the "correct" solution? Using the onBlur instead of onUpdate feels hacky.

@purfectliterature
Copy link

purfectliterature commented Apr 5, 2024

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 💀

@nperez0111
Copy link
Contributor

This should be resolved with tiptap 2.5

@iafan
Copy link

iafan commented Jul 28, 2024

While Tiptap 2.5 is may be solving the original problem, it still (as of Tiptap 2.5.7) does not solve the issue described above (#2403 (comment)), and the suggested workaround there remains to be required to maintain the state and make Tiptap not lose focus and not forget the caret placement.

Is the fix for this specific issue still in the plans? Or is there a better way to work with Tiptap as a truly reactive component?

@nperez0111
Copy link
Contributor

This discussion wildly deviated from the original issue which is, in fact, resolved with Tiptap 2.5.

The problem with treating Tiptap as a "controlled component" is that it fundamentally is not how contenteditable & prosemirror work. Rich text editing is not easy, this is why you are leveraging a library like Tiptap to deal with that fact, it is a very different model from how React works with it's ideals. Even in React, they spent a lot of effort to get controlled & uncontrolled behaviors out of normal inputs & textareas, the difference being they have hundreds of engineers and we are just 3 guys.

If anyone has ideas on how to do this better, I would be happy to hear about it, but this really should be a separate discussion.

My idea for how we could achieve something like this is to have a different API, where you can specify initialContent separately from content where if you use the initialContent version you get the current uncontrolled input behavior and if you use the content you can use a controlled input behavior, and we can listen to changes to it specifically & set the content of the editor for you.

As mentioned by #2403 (comment)

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.

This actually should be much more performant now with the Tiptap 2.5 update, so if you want to try it out please do.

@jedgrant
Copy link

On that note... I absolutely love Tiptap. Thank you for all the work you all are doing!

@Bobbyjsx
Copy link

Thank you to everyone who posted solutions.

I encountered the same issue and tried all the solutions provided, but unfortunately, none worked for me. After some investigation, I was able to resolve the problem with the following approach:

const editor = useEditor({
  content: value,
  editorProps: {
    attributes: {
      class: 'px-3 h-40',
    },
  },
  extensions: [StarterKit, Underline, FontFamily, TextStyle],
  onFocus: ({ event }) => {
    event.preventDefault();
    event.stopPropagation();
  },
  onUpdate: ({ editor }) => {
    const html = editor.getHTML();
    if (!html || editor.isEmpty) return;
    onChange(html);
  },
});

useEffect(() => {
  if (!editor) return;
  editor.commands.setContent(value || '');
}, [value, editor]);

The key part of the fix that solved the bug for me was this:

useEffect(() => {
  if (!editor) return;
  editor.commands.setContent(value || '');
}, [value, editor]);

By ensuring the editor's content is updated when value changes, this solution worked perfectly for my case.

@Sands-45
Copy link

Sands-45 commented Nov 4, 2024

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 💀

For those saving the value on local storage you can also add the "onCreate" Event handler so sync on load.

onCreate: ({ editor }) => {
cursor.current = editor.state.selection.anchor;
},

@nperez0111
Copy link
Contributor

nperez0111 commented Nov 4, 2024

I cannot stress it enough the controlled component pattern is THE WRONG WAY TO USE TIPTAP. Another solution around this is to use the useEditorState hook which can derive state from the editor instance. If the parent of the editor needs this, you can pass the editor instance in a callback or a ref.

There are other solutions to this, controlled components is the least performant and most hacky since it goes against the grain of how normal JS works, forcing the editor to reinstantiate because you felt the state should live in a useState instead

@JM-M
Copy link

JM-M commented Jan 2, 2025

Thank you to everyone who posted solutions.

I encountered the same issue and tried all the solutions provided, but unfortunately, none worked for me. After some investigation, I was able to resolve the problem with the following approach:

const editor = useEditor({
  content: value,
  editorProps: {
    attributes: {
      class: 'px-3 h-40',
    },
  },
  extensions: [StarterKit, Underline, FontFamily, TextStyle],
  onFocus: ({ event }) => {
    event.preventDefault();
    event.stopPropagation();
  },
  onUpdate: ({ editor }) => {
    const html = editor.getHTML();
    if (!html || editor.isEmpty) return;
    onChange(html);
  },
});

useEffect(() => {
  if (!editor) return;
  editor.commands.setContent(value || '');
}, [value, editor]);

The key part of the fix that solved the bug for me was this:

useEffect(() => {
  if (!editor) return;
  editor.commands.setContent(value || '');
}, [value, editor]);

By ensuring the editor's content is updated when value changes, this solution worked perfectly for my case.

This worked for me without any focus issues.

import { isNil } from "lodash-es";

useEffect(() => {
  if (!editor || isNil(value) || value === editor.getHTML()) return;
  editor.commands.setContent(value || "");
}, [value, editor]);
    
    

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Type: Bug The issue or pullrequest is related to a bug
Projects
None yet