Tiptapとバックエンドの同期について
ブログサービスなどは記事の保存、再編集が最も基本的な機能として提供されています。この機能を実装するためにはエディターで編集したコンテンツをバックエンドに送信して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
で取得したコンテンツを指定しています。バックエンドからデータ取得中にdata
はundefined
になるためその時は空のコンテンツを表示し、取得完了後にコンテンツの内容を表示しています。
今回はデータ取得(非同期処理)とReactの同期はtanstack-query
行っています。データを取得する際の様々なイベントにtanstack-query
が反応して再レンダリングをしてくれます。 const content = data === undefined ? "" : `<p>${data.body}</p>`;
のcontent
はtanstack-query
によってReactにバインド(subscribe)されているdata
を参照しているため、useEditor
のdeps
にcontent
を指定することでeditorインスタンスが再生成され、データ取得に合わせてエディター内のコンテンツを更新できます。
useEditorのdepsにstateを入れてsetStateでエディターのコンテンツを更新することの問題点
useEditor
のdeps
によって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;
useEditor
のdeps
に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の統合の話になりました。まとめとしては、
- バックエンドから取得したデータをエディターコンテンツの初期値として入れたい場合は、
useEditor
のdeps
を使ってデータ取得と初期値の設定を同期させる - エディターのコンテンツとstateを双方向で同期させたいケースはないのでは?
でした。
今回使用したサンプルコードは以下のレポジトリのfeature/sync-api
ブランチにあります。
今回はバックエンドから取得したデータを初期値として設定するだけでしたが、エディターで編集したコンテンツをバックエンドに保存する部分も気が向いたら追記しようと思います。
Discussion