🙆

react-queryをContentfulで試す

2023/01/07に公開

始めに

react-queryを試そうと思った際に、基本的にはAPIを用意する必要があるため、必然的にサーバーを用意する必要があると思います。しかし普段バックエンドをやらない人にとってはその辺用意するのが面倒だと思います。
そこでHeadless CMSであるContentfulを使うことでバックエンド側のコストを軽くしてreact-queryを試してみましたので、その辺についてまとめたいと思います。

なお、Contentful単体の検証については以下の記事でまとめており、これの続編となっております。
https://zenn.dev/wintyo/articles/7fb758edb723ed

Contentfulで使用するモジュールをContext経由で渡せるようにする

前回の記事ではContentfulClientApiEnvironmentをラップするContentfulApiを用意して、それ経由で呼び出していましたが、今回は最終的にreact-queryの呼び出しラッパーの方でまとめることになるのでContentfulClientApiEnvironmentをそれぞれ渡す流れにします。ただこのままだと色んなコンポーネントにバケツリレーをすることになってしまうため、React Context経由で取得できるようにします。

context/ContenfulApiContext.ts
import { createContext, useContext } from "react";
import { ContentfulClientApi } from "contentful";
import { Environment } from "contentful-management";

export type ContentfulApiContextValue = {
  /** fetch用のContenfulクライアントAPI */
  clientApi: ContentfulClientApi;
  /** POST, PATCH用のContentful管理API */
  managementApi: Environment;
};

export const ContentfulApiContext = createContext<
  ContentfulApiContextValue | undefined
>(undefined);
export const ContentfulApiProvider = ContentfulApiContext.Provider;

export const useContentfulApi = () => {
  // useQueryClientと同じ感じにかく
  // @see https://github.com/TanStack/query/blob/v4.20.8/packages/react-query/src/QueryClientProvider.tsx#L42-L52
  const contextValue = useContext(ContentfulApiContext);
  if (contextValue == null) {
    throw new Error("No Contentful API set, use ContefulApiProvider to set one");
  }
  return contextValue;
};

これをApp.tsxでProviderを使って渡してあげます。

App.tsx
import { ContentfulApiProvider } from "./context/ContentfulApiContext";

const App: FC = () => {
  // 一部省略

  return (
    <div>
      {/* ContentfulのclientApiとmanagementApiがある時にProviderを渡してContentfulが使える時に起動するAppを呼び出す */}
      {clientApi && managementApi && (
        <ContentfulApiProvider value={{ clientApi, managementApi }}>
          <hr />
          <ContentfulApp />
        </ContentfulApiProvider>
      )}
    </div>
  );
};

こうすることでProvider配下で呼ばれているコンポーネント内ではどこでもuseContentfulApiを呼び出してclientApimanagementApiを取得でき、バケツリレーが不要になります。

useContentfulApiの使用例
import { FC } from "react";
import { useContentfulApi } from "./context/ContentfulApiContext";

export const MyComponent: FC = () => {
  // ContentfulApiProvider配下で呼ばれているコンポーネントではどこでも取得できる
  const { clientApi, managementApi } = useContentfulApi();
  
  return <div></div>;
};

react-queryの初期設定

続いてreact-queryの初期設定をします。大元のアプリケーションを呼び出すときにQueryClientProviderを呼んでqueryClientが配下のコンポーネントで使えるようにします。

index.tsx
 import { StrictMode } from "react";
 import { createRoot } from "react-dom/client";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

 import App from "./App";

 const rootElement = document.getElementById("root");
 const root = createRoot(rootElement!);

+ const queryClient = new QueryClient();

 root.render(
   <StrictMode>
+    <QueryClientProvider client={queryClient}>
       <App />
+    </QueryClientProvider>
   </StrictMode>
 );

CRUD機能それぞれをreact-queryで実装

準備が整ったのでCRUDをそれぞれreact-queryを使って実装していきます。今回は1機能1ファイルのファイル構成にしました。なんとなくですがこの構成の方が1つのAPIが廃止された時にまとめて削除しやすく扱いやすいのかなと思っています。

src/
 ├ api
 |  └ todos
 |      ├ createTodo.ts
 |      ├ deleteTodo.ts
 |      ├ fetchTodos.ts
 |      ├ index.ts
 |      └ updateTodo.ts 

Read機能を実装(fetchTodos.ts)

まずは一覧を読み込む機能を実装します。Contentful単体で呼び出すfetch処理を先に定義し、それをreact-queryをラップしたuseQueryTodosのなかで呼び出します。先ほどuseContentfulApiはProvider配下にあるコンポーネントならどこでもと書きましたが、その中で呼び出されるメソッド内でも使用できるのでuseQueryTodosの中でContentfulのモジュールを取得してAPIを実行します。これによってなんのモジュールを使用しているか気にせず実行することができてコードが相当スッキリします。

api/todos/fetchTodos.ts
import { ContentfulClientApi } from "contentful";
import { useQuery } from "@tanstack/react-query";
import { useContentfulApi } from "../../context/ContentfulApiContext";

export type TodoEntry = {
  text: string;
  isDone: boolean;
};

export type Todo = TodoEntry & {
  id: string;
  createdAt: string;
  updatedAt: string;
};

export const fetchTodos = async (clientApi: ContentfulClientApi) => {
  const entries = await clientApi.getEntries<TodoEntry>({
    content_type: "todo",
    order: "-sys.createdAt"
  });
  return entries.items.map((item) => {
    const todo: Todo = {
      id: item.sys.id,
      ...item.fields,
      createdAt: item.sys.createdAt,
      updatedAt: item.sys.updatedAt
    };
    return todo;
  });
};

export const fetchTodosQueryKey = () => ["todos"];

export const useQueryTodos = () => {
  const { clientApi } = useContentfulApi();

  return useQuery({
    queryKey: fetchTodosQueryKey(),
    queryFn: () => fetchTodos(clientApi)
  });
};

これをコンポーネント側で使用すると以下のようになります。

ContentfulApp.tsx
import { FC } from "react";
import { useQueryTodos } from "./api/todos";
import { TodoItem } from "./components/TodoItem";

export const ContentfulApp: FC = () => {
  const { data: todos } = useQueryTodos();

  return (
    <div>
      Contentful Todo App With react-query
      <div>
        {todos?.map((todo) => (
          <TodoItem key={todo.id} todo={todo} />
        ))}
      </div>
    </div>
  );
};

Create機能を実装(createTodo.ts)

続いて作成機能を実装します。作成が終わったら一覧を更新したいのでonSuccessの時にqueryClient.invalidateQueriesキャッシュをクリアして再fetchを促します。

api/todos/createTodo.ts
import { Environment } from "contentful-management";
import { useQueryClient, useMutation } from "@tanstack/react-query";
import { useContentfulApi } from "../../context/ContentfulApiContext";
import { fetchTodosQueryKey } from "./fetchTodos";

export type TodoNew = {
  text: string;
  isDone?: boolean;
};

export const createTodo = async (managementApi: Environment, todo: TodoNew) => {
  const res = await managementApi.createEntry("todo", {
    fields: {
      text: {
        "en-US": todo.text
      }
    }
  });
  await res.publish();
  return res.fields;
};

export const useMutationCreateTodo = () => {
  const { managementApi } = useContentfulApi();
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (todo: TodoNew) => createTodo(managementApi, todo),
    onSuccess: () => {
      queryClient.invalidateQueries({
        queryKey: fetchTodosQueryKey()
      });
    }
  });
};

これをコンポーネント側で呼び出すと以下のようになります。前回はAPIの実行順などを管理するために親コンポーネント側でAPIを呼んでいましたが、その辺をreact-query側で担保してくれているので子コンポーネント側で呼びます。子コンポーネントで完結しているため通信中フラグをpropsで受け取る必要がなくてスッキリしたコードになっていると思います。

components/TodoForm.tsx
import { FC, useState } from "react";
import { useMutationCreateTodo } from "../api/todos";

export const TodoForm: FC = () => {
  const [text, setText] = useState("");
  const {
    mutate: mutateNewTodo,
    isLoading: isCreating
  } = useMutationCreateTodo();
  return (
    <form
      onSubmit={(event) => {
        event.preventDefault();
        mutateNewTodo(
          {
            text
          },
          {
            onSuccess: () => {
              setText("");
            }
          }
        );
      }}
    >
      <input
        type="text"
        value={text}
        disabled={isCreating}
        onChange={(event) => {
          setText(event.currentTarget.value);
        }}
      />
      <button type="submit" disabled={isCreating || text === ""}>
        {isCreating ? "作成中..." : "作成"}
      </button>
    </form>
  );
};

子コンポーネントで処理が完結しているため、親コンポーネントはただ呼び出すだけでOKです。

ContentfulApp.tsx
 import { FC } from "react";
 import { useQueryTodos } from "./api/todos";
+import { TodoForm } from "./components/TodoForm";
 import { TodoItem } from "./components/TodoItem";

 export const ContentfulApp: FC = () => {
   const { data: todos } = useQueryTodos();

   return (
     <div>
       Contentful Todo App With react-query
+      <TodoForm />
       <div>
         {todos?.map((todo) => (
           <TodoItem key={todo.id} todo={todo} />
         ))}
       </div>
     </div>
   );
 };

Update, Delete機能を実装(updateTodo.ts, deleteTodo.ts)

最後に更新と削除機能を実装します。こちらも同じように更新と削除を終えたら一覧のキャッシュをクリアして再fetchを促します。

api/todos/updateTodo.ts
import { Environment } from "contentful-management";
import { useQueryClient, useMutation } from "@tanstack/react-query";
import { useContentfulApi } from "../../context/ContentfulApiContext";
import { TodoEntry, Todo, fetchTodosQueryKey } from "./fetchTodos";

export const updateTodo = async (
  managementApi: Environment,
  todoId: string,
  updatingTodo: TodoEntry
) => {
  const todoEntry = await managementApi.getEntry(todoId);
  todoEntry.fields = Object.assign(
    {},
    ...Object.keys(todoEntry.fields).map((key) => ({
      [key]: {
        "en-US": updatingTodo[key as keyof TodoEntry]
      }
    }))
  );
  const res = await todoEntry.update();
  await res.publish();
  return res.fields;
};

export const useMutationUpdateTodo = () => {
  const { managementApi } = useContentfulApi();
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (updatingTodo: Todo) =>
      updateTodo(managementApi, updatingTodo.id, updatingTodo),
    onSuccess: () => {
      queryClient.invalidateQueries({
        queryKey: fetchTodosQueryKey()
      });
    }
  });
};
deleteTodo.ts
import { Environment } from "contentful-management";
import { useQueryClient, useMutation } from "@tanstack/react-query";
import { useContentfulApi } from "../../context/ContentfulApiContext";
import { fetchTodosQueryKey } from "./fetchTodos";

export const deleteTodo = async (
  managementApi: Environment,
  todoId: string
) => {
  const todoEntry = await managementApi.getEntry(todoId);
  await todoEntry.unpublish();
  await todoEntry.delete();
};

export const useMutationDeleteTodo = () => {
  const { managementApi } = useContentfulApi();
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (todoId: string) => deleteTodo(managementApi, todoId),
    onSuccess: () => {
      queryClient.invalidateQueries({
        queryKey: fetchTodosQueryKey()
      });
    }
  });
};

これらをコンポーネントで呼び出すと以下のようになります。

TodoItem.tsx
import { FC } from "react";
import {
  Todo,
  useMutationUpdateTodo,
  useMutationDeleteTodo
} from "../api/todos";

export type TodoItemProps = {
  todo: Todo;
};

export const TodoItem: FC<TodoItemProps> = (props) => {
  const {
    mutate: mutateUpdateTodo,
    isLoading: isUpdating
  } = useMutationUpdateTodo();
  const {
    mutate: mutateDeleteTodo,
    isLoading: isDeleting
  } = useMutationDeleteTodo();

  return (
    <div
      style={{ position: "relative", padding: "8px", border: "solid 1px #000" }}
      onClick={() => {
        mutateUpdateTodo({
          ...props.todo,
          isDone: !props.todo.isDone
        });
      }}
    >
      {(isUpdating || isDeleting) && (
        <div
          style={{
            position: "absolute",
            display: "flex",
            alignItems: "center",
            justifyContent: "center",
            top: 0,
            left: 0,
            width: "100%",
            height: "100%",
            backgroundColor: "rgba(255, 255, 255, 0.5)"
          }}
          onClick={(event) => {
            event.stopPropagation();
          }}
        >
          {isUpdating ? "更新中..." : "削除中..."}
        </div>
      )}
      <div style={{ display: "flex", justifyContent: "space-between" }}>
        <div>
          <input type="checkbox" checked={props.todo.isDone} readOnly />
          <span>{props.todo.id}</span>
        </div>
        <button
          disabled={isDeleting}
          onClick={(event) => {
            event.preventDefault();
            event.stopPropagation();
            const result = window.confirm("削除してもよろしいですか?");
            if (result) {
              mutateDeleteTodo(props.todo.id);
            }
          }}
        >
          削除
        </button>
      </div>
      <div>
        <span>{props.todo.text}</span>
        <button
          style={{ marginLeft: "4px" }}
          onClick={(event) => {
            event.preventDefault();
            event.stopPropagation();
            const text = window.prompt("TODOテキストの変更", props.todo.text);
            if (text) {
              mutateUpdateTodo({
                ...props.todo,
                text
              });
            }
          }}
        >
          テキスト変更
        </button>
      </div>
      <div style={{ fontSize: "14px", marginTop: "4px" }}>
        <span>作成日: {props.todo.createdAt}</span>
        <span>, </span>
        <span>更新日: {props.todo.updatedAt}</span>
      </div>
    </div>
  );
};

終わりに

以上がreact-queryをContentfulで試す方法でした。useQuery~またはuseMutation~でreact-queryのコードをラップすることでどんなAPI形式であっても上手くラップすることができ、コンポーネント内のコードが大分スッキリするなと感じました。今回Access Tokenなどを環境変数などで直接アクセスできない関係上動的にContentfulのモジュールを作ることになっておりますが、そこもReact Contextを使うことで上手くラップされていて見通しの良い状態を維持することができました。

最後に検証コードはCodeSandboxに置いてありますので以下にリンクを乗せておきます。ContentfulのAccess Tokenとか用意しないと動きを確認することはできませんが、興味がある方はご参照ください。

参考記事

https://zenn.dev/ryota_koba04/articles/18df7cbfeb4155
https://zenn.dev/taisei_13046/articles/1202f4d107d890

Discussion