いまさらRecoil入門
いままで状態管理についてはReduxを使っていたのですが、流石に他の状態管理も使えるようにならないとな、、、と思い、Recoilに入門しました。この記事はその備忘録となります。
本記事のコードは以下から参照できます。
なぜ状態管理ツールが必要か
- 兄弟コンポーネント間で同一の状態を持ちたいことがあります。例. あるコンポーネントで状態に応じた表示を行い、別のコンポーネントでその状態更新を行いたい。
- 上記の場合、共通親コンポーネントで状態を
useState
で定義し、state
,setState
をそれぞれのコンポーネントに受け渡す方法があります。しかし、以下の欠点があります。- prop drillingが発生しやすく、見通しが悪い。
- また
setState
は関数のためサイレンダリングのためにコンポーネントをmemo化する必要が出てきます。
- もう一つの方法ではContextを扱う案があります。しかし、以下の問題点があります。
- 扱う状態ごとにContextを作成する手間が発生。
- Contextを使うためにRootLayout
layout.ts
にProviderでラップする必要があるが、状態が増えるとラップ地獄になる。
- こういった問題点を解消するために状態管理ツールが採用されています。
状態管理ツール
状態管理ツールは主に以下の4つが広く採用されていると感じています。
これらの状態管理ツールの詳細な説明は以下の記事がわかりやすかったです。
イメージとしては、大まかにRedux系統とRecoil系統があり、それぞれの方針で使いやすくなったのがZustandとJotaiという立ち位置です。
以下によると、ZustandとJotaiが2トップのようです。
今回は、以下の観点からRecoilを勉強することにしました。
- Reduxよりもフックとの相性がよく、使いやすそう
- Meta Open Sourceに所属しており、ドキュメントが豊富であり、今後のサポートも期待される。
Recoil
Recoilは状態をAtomと呼ばれるストアに格納し、Atom単位で管理します。そのため、管理したい状態の数だけAtomができます。Atomは任意のコンポーネントで呼び出せ、状態の値を読んだり、設定することができます。また、単に読み出すだけでなく、読み出した後に任意の処理を行うことができます。これはSelectorと呼ばれる機構で実現できます。
以下は user
(ユーザー) と item
(商品) の2つの状態を管理する時のイメージ図です。user
・item
それぞれの状態を userAtoms
, itemAtoms
の2つのAtomで管理し、任意のReact Componentから読み出します。また、商品の数や、商品の最大の値段などの処理を施す機構を itemCounteSelector
, itemMaxPriceSelector
という2つのSelectorが担っています。
Recoilを使った簡単なEchoアプリ
Recoilの具体的な操作方法の解説のため、簡単なWebアプリを共有します。
アプリの概要
作成するアプリは非常にシンプルです。任意の文字列を入力可能なフォーム(Echo Form)と、そのフォームで設定された内容を表示するボード(Echo Board)の2つのコンポーネントからなります。
具体的には、Echo Formに文字を入力し、Sendボタンを押すと、入力された内容が上部のBoardに表示されます。
フロントだけで完結すると面白くないため、バックエンドを挟んでいます。バックエンドはEchoサーバーとなっています。具体的な流れは以下の通りです。
- Form Component: フロントでSendボタンが押された時にFormの入力内容がバックエンドに渡されます
- バックエンド: バックエンドは渡された内容をそのまま返却
- Board Component: バックエンドから返された内容を表示
実装概要
このWebアプリを実現するためには、Recoilを使用して以下のように実現すると良さそうです。
- 管理する状態: message(=フォームで入力されたメッセージ)
- Board Component:
messageAtom
を取得し、その内容を読み出して画面に表示 - Form Component:
messageAtom
を取得する。FormのSendボタンがクリックされれば、バックエンドAPIを呼び出す。呼び出された結果をmessageAtom
に書き出す。
実装詳細
まず、Recoilを使用するための設定を行います。Recoilを使うには、Recoilを使用するComponentをラップするように RecoilRoot
ReactNodeを設定する必要があります。
一度これを設定すると、その配下のComponentでは自由にRecoilが使えます。このような設定はNext.js App Routerではルート直下のlayout.tsx
で行うのが良さそうです。
しかし、 RecoilRoot
は内部でフックを使用するため Client Componentでなければなりません。layout.tsx
はWebページのタイトルやdescriptionを設定することもあり、layout.tsx
自体をClient Componentにはできません。そこで、RecoilRoot
を設定するプロバイダーを別のComponentにし、そこをClientComponentとします。
以下は、RecoilRoot
を設定するプロバイダーを定義するClient Componentです。
"use client";
import React from "react";
import { RecoilRoot } from "recoil";
export default function RecoilProvider({
children,
}: {
children: React.ReactNode;
}) {
return <RecoilRoot>{children}</RecoilRoot>;
}
RecoilProvider
コンポーネントを使用し、 RecoilRoot
を layout.tsx
で設定します。
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import RecoilProvider from "@/provider/recoil";
import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Recoil Sandbox",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<RecoilProvider>
<html lang="en">
<body className={inter.className}>{children}</body>
</html>
</RecoilProvider>
);
}
次に、ページ全体の設定を行う page.tsx
についてです。こちらは非常にシンプルで、Form ComponentとBoard Component を使用しているだけです。
冒頭でも話しました「弟コンポーネント間で同一の状態を持ちたい場合、共通親コンポーネントで状態をuseState
で定義し、state
, setState
をそれぞれのコンポーネントに受け渡すことになり、コードの見通しが悪い」は、Recoilによって回避できていることがこれから分かります。
import Board from "@/components/Board";
import Form from "@/components/Form";
export default function Page() {
return (
<main>
<div className="flex justify-center items-center h-svh">
<div className="flex flex-col gap-10 w-[28rem]">
<h1 className="text-4xl p-2 border-b">Recoil Sandbox</h1>
<div>
<Board />
</div>
<div>
<Form />
</div>
</div>
</div>
</main>
);
}
使用するAtom: messageAtom
の定義です。こちらは非常に簡単で、messageAtom
をあたわす一意のkeyとデフォルト値を設定します。
import { atom } from "recoil";
export const messageAtom = atom<string | null>({
key: "messageAtom",
default: null,
});
続いて、Atomに設定した状態を読み込むBoard Componentについてです。
Atomを呼び出すためには、useRecoilState
フックを使用します。引数には呼び出したいAtomの設定(ここではmessageAtom
)を与えます。返り値は2つであり、状態の値と状態を更新するためのdispatch関数です。これを見ると、useState
に似ており、フックに慣れしたんでいるエンジニアにはすごく理解しやすい書き方になります。
"use client";
import React from "react";
import { useRecoilState } from "recoil";
import { messageAtom } from "@/store/atom";
import { useInitialMessage } from "@/libs/client";
export default function Board() {
const { data, isLoading, error } = useInitialMessage();
const [message, setMessage] = useRecoilState(messageAtom);
// 初期値の設定
React.useEffect(() => {
if (data?.message) setMessage(data.message);
}, [data?.message]);
return (
<div>
<h2>Echo Board</h2>
<p>Messages from Echo Form will appear here.</p>
<p>{isLoading ? "..." : message}</p>
</div>
);
}
最後に、Form Componentについてです。ここでは、FormのSendボタンが押された時にバックエンドAPIを呼び出し、その返り値をmessageAtom
に設定しています。
このComponentでは状態を設定するだけで、読み出しはありません。Recoilではそのような場合 useSetRecoilState
というフックが用意されています。こちらの引数は同じく書き込みたいAtomの設定です。違うのは返り値で、数は1つだけであり設定のためのdispatchのみです。
この返り値を用いて状態を別の値に設定することで、状態の現在の値が更新され、Boardで取得した値が変わり、Boardが再度レンダリングされます。
"use client";
import { apiClient, fetchInitialMessage } from "@/libs/client";
import { useSetRecoilState } from "recoil";
import { messageAtom } from "@/store/atom";
import { EchoType } from "@/schema/echo";
import Axios from "axios";
import React from "react";
export default function Form() {
const [formValue, setFormValue] = React.useState<string>("");
const setMessage = useSetRecoilState(messageAtom);
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = Object.fromEntries(
new FormData(event.currentTarget).entries()
);
try {
const response = await apiClient.post<EchoType>(
"/api/echo",
JSON.stringify(formData)
);
setMessage(response.data.message);
} catch (error) {
if (Axios.isAxiosError(error)) {
console.error(error.response?.data);
} else {
console.error(error);
}
}
};
const onReset = async () => {
const data = await fetchInitialMessage();
setMessage(data.message);
setFormValue("");
};
const onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setFormValue(event.target.value);
};
return (
<form onSubmit={onSubmit}>
<h2>Echo Form</h2>
<p>Type something and click submit.</p>
<div className="mt-3">
<label htmlFor="message">Input for echo board</label>
<div>
<input
type="text"
name="message"
id="message"
autoComplete="on"
value={formValue}
onChange={onChange}
placeholder="Type something"
/>
</div>
</div>
<div>
<button type="button" onClick={onReset}>Cancel</button>
<button type="submit">Send</button>
</div>
</form>
);
}
おわりに
この記事では以下のことを述べました。
- 兄弟コンポーネント間で同一の状態を持ちたいことがあるが、その場合は状態管理ツールを使用すると良いです。
- 状態管理ツールとしては様々なツールがあります。
- その中でもRecoilを採用し、Recoilの概念や実際の使い方を共有しました。