react-queryをContentfulで試す
始めに
react-queryを試そうと思った際に、基本的にはAPIを用意する必要があるため、必然的にサーバーを用意する必要があると思います。しかし普段バックエンドをやらない人にとってはその辺用意するのが面倒だと思います。
そこでHeadless CMSであるContentfulを使うことでバックエンド側のコストを軽くしてreact-queryを試してみましたので、その辺についてまとめたいと思います。
なお、Contentful単体の検証については以下の記事でまとめており、これの続編となっております。
Contentfulで使用するモジュールをContext経由で渡せるようにする
前回の記事ではContentfulClientApi
とEnvironment
をラップするContentfulApi
を用意して、それ経由で呼び出していましたが、今回は最終的にreact-queryの呼び出しラッパーの方でまとめることになるのでContentfulClientApi
とEnvironment
をそれぞれ渡す流れにします。ただこのままだと色んなコンポーネントにバケツリレーをすることになってしまうため、React Context経由で取得できるようにします。
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を使って渡してあげます。
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
を呼び出してclientApi
とmanagementApi
を取得でき、バケツリレーが不要になります。
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
が配下のコンポーネントで使えるようにします。
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を実行します。これによってなんのモジュールを使用しているか気にせず実行することができてコードが相当スッキリします。
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)
});
};
これをコンポーネント側で使用すると以下のようになります。
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を促します。
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で受け取る必要がなくてスッキリしたコードになっていると思います。
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です。
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を促します。
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()
});
}
});
};
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()
});
}
});
};
これらをコンポーネントで呼び出すと以下のようになります。
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とか用意しないと動きを確認することはできませんが、興味がある方はご参照ください。
参考記事
Discussion