🍧

Tiptapとバックエンドの同期について

2024/08/18に公開

ブログサービスなどは記事の保存、再編集が最も基本的な機能として提供されています。この機能を実装するためにはエディターで編集したコンテンツをバックエンドに送信してDBに保存したり、保存しておいた記事をDBから持ってきて再編集できるようにエディターに展開したりする必要があります。Tiptapでエディターを開発している場合もTiptapのコンテンツとバックエンドのコンテンツを同期させる必要が出てきます。

バックエンドから取得したデータをエディターに表示する

Tiptapでは以下のようにしてバックエンド側から取得したコンテンツをエディター内に展開することができます。

import { EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { useQuery } from "@tanstack/react-query";
import "./App.css";

function useGetSingleTodo() {
  return useQuery<{
    id: number;
    userId: number;
    title: string;
    body: string;
  }>({
    queryKey: ["get-single-todo"],
    queryFn: async () => {
      const response = await fetch(
        "https://jsonplaceholder.typicode.com/posts/1"
      );
      const data = await response.json();

      return data;
    },
  });
}

const extensions = [StarterKit];

function App() {
  const { data } = useGetSingleTodo();
  const content = data === undefined ? "" : `<p>${data.body}</p>`;

  const editor = useEditor(
    {
      extensions,
      content,
    },
    [content]
  );

  if (editor === null) return false;

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

export default App;

useEditorの二つ目の引数にdepsを指定できるためそこにtanstack-queryで取得したコンテンツを指定しています。バックエンドからデータ取得中にdataundefinedになるためその時は空のコンテンツを表示し、取得完了後にコンテンツの内容を表示しています。
今回はデータ取得(非同期処理)とReactの同期はtanstack-query行っています。データを取得する際の様々なイベントにtanstack-queryが反応して再レンダリングをしてくれます。 const content = data === undefined ? "" : `<p>${data.body}</p>`;contenttanstack-queryによってReactにバインド(subscribe)されているdataを参照しているため、useEditordepscontentを指定することでeditorインスタンスが再生成され、データ取得に合わせてエディター内のコンテンツを更新できます。

useEditorのdepsにstateを入れてsetStateでエディターのコンテンツを更新することの問題点

useEditordepsによってeditorインスタンスが再生成されることでコンテンツを同期させていますが、この方法を使うとエディターのコンテンツをReactのstateと完全に同期させることもできます。今回はtanstack-queryを使っているため自動的に取得したデータがキャッシュとして保存されReactのグローバルstateとして扱えるようになっています。つまり、tanstack-queryのキャッシュを更新することで、エディターのコンテンツを更新することができます。

import { EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import "./App.css";

const GET_SINGLE_TODO = "get-single-todo";

function useGetSingleTodo() {
  return useQuery<{
    id: number;
    userId: number;
    title: string;
    body: string;
  }>({
    queryKey: [GET_SINGLE_TODO],
    queryFn: async () => {
      const response = await fetch(
        "https://jsonplaceholder.typicode.com/posts/1"
      );
      const data = await response.json();

      return data;
    },
  });
}

const extensions = [StarterKit];

function App() {
  const queryClient = useQueryClient();

  const { data } = useGetSingleTodo();
  const content = data === undefined ? "" : `<p>${data.body}</p>`;

  const editor = useEditor(
    {
      extensions,
      content,
    },
    [content]
  );

  if (editor === null) return false;

  return (
    <>
      <EditorContent editor={editor} />
      <button
        type="button"
        onClick={() => {
          if (data === undefined) return;

          /* tanstack-queryのキャッシュを更新する */
          queryClient.setQueryData([GET_SINGLE_TODO], {
            ...data,
            body: "<p>updated content</p>",
          });
        }}
      >
        コンテンツを更新
      </button>
    </>
  );
}

export default App;

useEditordepsにstateに保存したコンテンツを指定してsetState(setQueryData)することでReactからeditorのコンテンツを更新することができるようになりました。
しかし、個人的にはeditorインスタンス経由のメソッドでコンテンツの操作はできてReact側からコンテンツを更新したいケースはほぼないと思っているのと、React側からコンテンツを変えた場合editorインスタンスが都度作成し直されるため、エディター内のコンテンツに対しての参照、操作は前回の記事で紹介したようにonUpdateなどのイベントやeidtorインスタンスのメソッドを使えば十分だと思っています。
また、エディターのコンテンツはReactから副作用を受けない方がシンプルだと思いますし、エディターのコンテンツはHTMLでHTMLの操作を自前でやると(例えば正規表現でいじるとかすると)バグが生まれやすくなると思います。

特に一番の問題なのはHistoryExtensionとの同期が難しくなることです。HistoryExtensionはコンテンツのUndo, RedoをサポートするためのExtensionです。StarterKitにデフォルトで組み込まれているため今回のサンプルコードもCmd+ZとかCmd+Shift+Zとかを押すとUndo, Redoできます。

上記のtanstack-queryのキャッシュを更新(stateを更新)してエディターのコンテンツを更新するサンプルコードでは、「コンテンツを更新」ボタンを押してコンテンツを更新した後にCmd+Zを押してもUndoができないです。TiptapはReactとprosemirrorの統合をそこまで細かく行っていません。

まとめ

バックエンドとの同期と言いながらなんだかんだTiptap(prosemirror)とReactの統合の話になりました。まとめとしては、

  • バックエンドから取得したデータをエディターコンテンツの初期値として入れたい場合は、useEditordepsを使ってデータ取得と初期値の設定を同期させる
  • エディターのコンテンツとstateを双方向で同期させたいケースはないのでは?

でした。

今回使用したサンプルコードは以下のレポジトリのfeature/sync-apiブランチにあります。
https://github.com/kirikirisu/tiptap-rendering-sandbox/tree/main

今回はバックエンドから取得したデータを初期値として設定するだけでしたが、エディターで編集したコンテンツをバックエンドに保存する部分も気が向いたら追記しようと思います。

Discussion