How to Sync Tiptap with Async Data in Next.js with Supabase

In the world of modern web development, creating rich, interactive user interfaces often involves juggling multiple libraries and frameworks.

Next.jsFramework
SupabaseDatabase
TiptapRich Text Editor

Tom McCulloch

Read Time: 5 min

In the world of modern web development, creating rich, interactive user interfaces often involves juggling multiple libraries and frameworks. Today, we're going to dive into a common challenge that arises when using the Tiptap rich text editor within a Next.js application, particularly when fetching data from Supabase.

The Challenge

Imagine you're building a product management system. You have a product detail page where administrators can edit various fields, including a rich text description powered by Tiptap. You're using Next.js for your frontend and Supabase as your backend. Everything seems to work fine, except for one pesky issue: when you navigate to the product detail page, all fields populate correctly except for the description. The Tiptap editor appears empty, even though you can see in your React Query devtools that the correct data has been fetched.

This scenario highlights a common pitfall when working with rich text editors and asynchronous data loading. Let's break down the problem and walk through a solution.

Understanding the Root Cause

The core of the issue lies in the timing of events:

  • The component mounts and initializes the Tiptap editor.
  • The data is fetched asynchronously from Supabase via React Query.
  • By the time the data arrives, the Tiptap editor has already been initialized with empty content.

Our challenge is to ensure that the Tiptap editor updates its content once the data is available.

The Solution

To solve this, we need to make a few key modifications to our Tiptap component:

  • Use forwardRef to allow the parent component to interact directly with the Tiptap editor.
  • Implement useImperativeHandle to expose a method for updating the editor's content.
  • Add an additional useEffect hook to synchronize the editor's content with incoming data.

Here's what our updated Tiptap component looks like:

"use client";

import React, { useEffect, useState, forwardRef, useImperativeHandle } from "react";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Heading from "@tiptap/extension-heading";
import ToolBar from "./toolbar";

interface TiptapProps {
  description: string;
  onChange: (richText: string) => void;
}

const Tiptap = forwardRef<{ setContent: (content: string) => void }, TiptapProps>(
  ({ description, onChange }, ref) => {
    const [isMounted, setIsMounted] = useState(false);

    const editor = useEditor({
      extensions: [
        StarterKit.configure({}),
        Heading.configure({
          HTMLAttributes: {
            class: "text-xl font-bold",
            levels: [2],
          },
        }),
      ],
      content: description,
      editorProps: {
        attributes: {
          class:
            "rounded-md border min-h-[150px] border-input bg-background focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 p-2",
        },
      },
      onUpdate({ editor }) {
        onChange(editor.getHTML());
      },
    });

    useEffect(() => {
      setIsMounted(true);
    }, []);

    useEffect(() => {
      if (editor && description !== editor.getHTML()) {
        editor.commands.setContent(description);
      }
    }, [description, editor]);

    useImperativeHandle(ref, () => ({
      setContent: (content: string) => {
        editor?.commands.setContent(content);
      },
    }));

    if (!isMounted) {
      return null;
    }

    return (
      <div className="flex flex-col justify-stretch min-h-[250px]">
        <ToolBar editor={editor} />
        <EditorContent editor={editor} />
      </div>
    );
  }
);

Tiptap.displayName = 'Tiptap';

export default Tiptap;

Breaking Down the Changes

  • forwardRef: This allows our parent component to pass a ref to the Tiptap component, enabling direct interaction.
  • useImperativeHandle: This exposes a setContent method that can be called from the parent component, allowing direct manipulation of the editor's content.
  • Additional useEffect: This ensures that the editor's content is updated when the description prop changes, which is crucial for initializing the editor with the correct content when data is loaded asynchronously.

Implementing in the Parent Component

In your product detail page component, you'll need to utilize these new capabilities:

const editorRef = useRef<{ setContent: (content: string) => void } | null>(null);

useEffect(() => {
  if (product && !isModified) {
    setFormData({
      name: product.name,
      description: product.description || "",
      price: product.price,
      cost: product.cost,
    });
    
    if (editorRef.current && product.description) {
      editorRef.current.setContent(product.description);
    }
  }
}, [product, isModified]);

// In your JSX
<Tiptap 
  description={formData.description || ''}
  onChange={handleEditorChange}
  ref={editorRef}
/>

The Toolbar

The toolbar component remains largely unchanged, but it's worth noting how it integrates with the Tiptap editor:

"use client";
import React from "react";
import { type Editor } from "@tiptap/react";
import { Toggle } from "@/components/ui/toggle";
import {Bold, Heading2, Italic, List, ListOrdered, StrikethroughIcon} from "lucide-react";

type Props = {
  editor: Editor | null;
};

function ToolBar({ editor }: Props) {
  if (!editor) {
    return null;
  }

  return (
    <div className="flex gap-3 border border-input rounded-lg p-1 my-2" >
      {/* Toolbar buttons */}
    </div>
  );
}

export default ToolBar;

The toolbar receives the editor instance as a prop, allowing it to interact directly with the Tiptap editor.

Conclusion

By implementing these changes, we've solved the asynchronous content loading issue with Tiptap in our Next.js and Supabase application. The key takeaways are:

  • Use forwardRef and useImperativeHandle to allow direct interaction with child components.
  • Implement additional useEffect hooks to handle asynchronous data updates.
  • Always consider the lifecycle of your components and the timing of data fetching when working with complex, stateful UI elements like rich text editors.

This solution ensures a smooth user experience, with all fields, including rich text areas, populating correctly even when data is loaded asynchronously. Happy coding!