React Hook Form の場合
いきなりですが、下のコードを見てください。
「姓」と「名」の入力フィールドがあって、その下にある「ちゃんと再計算される氏名」に続いて姓と名をつなげた文字列が出力されます。
「ちゃんと再計算される氏名」は、姓および名を入力するたびにキチンと更新されます。
"use client";
import { type FC } from "react";
import { useForm } from "react-hook-form";
type ProfileFormValues = {
familyName: string;
personalName: string;
};
const ProfileEditPage: FC = () => {
const {
watch, //
// getValues,
register,
handleSubmit,
} = useForm<ProfileFormValues>({
defaultValues: { familyName: "", personalName: "" },
});
const onSubmit = handleSubmit((values) => {
window.alert(JSON.stringify(values));
});
return (
<form onSubmit={onSubmit}>
<div>
<label>
姓
<input {...register("familyName")} />
</label>
<label>
名
<input {...register("personalName")} />
</label>
</div>
<div>
<span>ちゃんと再計算される氏名:</span>
<span>{watch("familyName") + " " + watch("personalName")}</span>
</div>
<button type="submit">送信</button>
</form>
);
};
export default ProfileEditPage;
もし watch()
を getValues()
に置き換えたらどうなるでしょうか?
フォームが思い通りに動作しなくなります。 getValues()
は「手続き的」に現在のフォームの値を取得するための機能であり、《コンポーネントのステートっぽく》は動作してくれないからです。
watch()
および useWatch()
は、フォーム上のフィールドの値が更新されると、それをコンポーネントの状態に反映させる機能を備えていますが、getValues()
はそのような機能を備えていません。
逆に、イベントハンドラの関数の中では getValues()
を使うべきで、 watch()
を呼び出しても、正しく動作する保証がありません。
-
watch()
,useWatch()
はステート的な機能 -
getValues()
は手続き的に値を取得する機能
- <div>
- <span>ちゃんと再計算される氏名:</span>
- <span>{watch("familyName") + " " + watch("personalName")}</span>
- </div>
+ <div>
+ <span>再計算されない氏名:</span>
+ <span>{getValues("familyName") + " " + getValues("personalName")}</span>
+ </div>
React の基本機能に立ち戻ってみよう
さきほどのコードを、React Hook Form を使わず useState と controlled component で実装すると、以下のようになります。
- ステート (familyName, personalName)
- レンダリングの途中で計算される変数 (fullname)
- これ自体は再レンダリングの発生と関係なく、再レンダリングに追従するだけですが
- サンプルには登場しないが
useSyncExternalStore() から取得した値
が、ちょうど watch()
と同様にステート的な機能だと考えてよいと思います。
イベントハンドラの中では、〈現在の値〉ではなく、〈State as Snapshot としての値〉を取得することになります。
いっぽう、Ref については、current
プロパティを更新しても再レンダリングを発生させず、レンダリング結果として使用不可能。イベントハンドラの中では〈現在の値を取得〉することが可能なので、 getValues()
と同様に手続き的に取得する機能と考えてよいと思います。
以下のコードの familyName
, personalName
をそのまま useRef
に置き換えても意図通りに状態更新がUIに反映されませんが、これは言うまでもありませんね。
"use client";
import { type FC, useState } from "react";
const ComputedExamplePage: FC = () => {
/** 姓および名の入力欄の値を保持するステート */
const [familyName, setFamilyName] = useState("");
const [personalName, setPersonalName] = useState("");
/** フルネーム、姓または名が変更されるたびに、両者を結合して値を更新する */
const fullName = familyName + " " + personalName;
return (
<form>
<label>
姓
<input
name="familyName"
value={familyName}
onChange={(e) => setFamilyName(e.target.value)}
/>
</label>
<label>
名
<input
name="personalName"
value={personalName}
onChange={(e) => setPersonalName(e.target.value)}
/>
</label>
<div>
<span>氏名</span>
<span>{fullName}</span>
</div>
<div>
<button type="submit">送信</button>
</div>
</form>
);
};
export default ComputedExamplePage;
SWR (および TanStack Query) の場合
まず、SWR の useSWR
を利用すると、以下のように、data を《ステート的に》扱うことができます。
"use client";
import { FC } from "react";
import useSWR from "swr";
type PostData = {
userId: string;
id: string;
title: string;
body: string;
};
const QueryPage: FC = () => {
const { data } = useSWR<PostData[]>(
"https://jsonplaceholder.typicode.com/posts",
(url: string) => fetch(url).then((res) => res.json())
);
return (
<div>
<h1>Posts</h1>
<hr />
{data?.map((item) => (
<div key={item.id}>
<h2>{item.title}</h2>
<div>{item.body}</div>
<hr />
</div>
))}
</div>
);
};
export default QueryPage;
useSWR
で注意するべきなのは、手続き的に取得するのには使用できないという点です。
以下のような、よくある《郵便番号を入力して、表示された候補の一つを選ぶと、それで入力欄を埋めてくれる》機能を考えてみましょう。
ソースコードの重要な部分の抜粋を以下に示します(JSXやimport等は、この下の「ソースコード全文はこちら」を開くと確認できます。)
const AddressCompletionPage: FC = () => {
// 各フィールドに入力中の文字列
const [postalCode, setPostalCode] = useState("");
const [addressPrefecture, setAddressPrefecture] = useState("");
const [addressArea, setAddressArea] = useState("");
const [addressDetail, setAddressDetail] = useState("");
// 郵便番号に対する候補一覧に関する状態、処理
const [candidates, setCandidates] = useState<Candidate[]>([]);
const { trigger, isMutating: isFetchingCandidates } = useSWRMutation(
"https://zipcloud.ibsnet.co.jp/api/search",
fetcher,
{
onSuccess: (data) => setCandidates(data.results ?? []),
}
);
const fetchAndListupCandidates = () => {
trigger({ postalCode });
};
// 選択された候補で、住所を入力補完する
const completeAddress = (candidate: Candidate) => {
setAddressPrefecture(candidate.address1);
setAddressArea(candidate.address2 + " " + candidate.address3);
setCandidates([]);
};
// JSX は略
ソースコード全文はこちら
注意: フォームのマークアップは適当なので、ブラウザのオートコンプリート機能に対応していません。
"use client";
import { FC, useState } from "react";
import useSWRMutation from "swr/mutation";
type ZipAddressCandidatesResponse = {
message: string | null;
results: Candidate[] | null;
status: number;
};
type Candidate = {
address1: string;
address2: string;
address3: string;
};
const fetcher = (
s: string,
{ arg: { postalCode } }: { arg: { postalCode: string } }
): Promise<ZipAddressCandidatesResponse> =>
fetch(`${s}?zipcode=${postalCode}`).then((res) => res.json());
const AddressCompletionPage: FC = () => {
// 各フィールドに入力中の文字列
const [postalCode, setPostalCode] = useState("");
const [addressPrefecture, setAddressPrefecture] = useState("");
const [addressArea, setAddressArea] = useState("");
const [addressDetail, setAddressDetail] = useState("");
// 郵便番号に対する候補一覧に関する状態、処理
const [candidates, setCandidates] = useState<Candidate[]>([]);
const { trigger, isMutating: isFetchingCandidates } = useSWRMutation(
"https://zipcloud.ibsnet.co.jp/api/search",
fetcher,
{
onSuccess: (data) => setCandidates(data.results ?? []),
}
);
const fetchAndListupCandidates = () => {
trigger({ postalCode });
};
// 選択された候補で、住所を入力補完する
const completeAddress = (candidate: Candidate) => {
setAddressPrefecture(candidate.address1);
setAddressArea(candidate.address2 + " " + candidate.address3);
setCandidates([]);
};
return (
<form>
<h2>住所入力</h2>
<div>
<input
name="postalCode"
placeholder="郵便番号"
value={postalCode}
onChange={(ev) => setPostalCode(ev.target.value)}
/>
<button
type="button"
onClick={fetchAndListupCandidates}
disabled={isFetchingCandidates}
>
住所を検索
</button>
</div>
{candidates.length > 0 && (
<div>
{candidates
.map((candidate) => ({
...candidate,
full:
candidate.address1 + candidate.address2 + candidate.address3,
}))
.map((candidate) => (
<div key={candidate.full}>
{candidate.full}
<button
type="button"
onClick={() => completeAddress(candidate)}
>
これ
</button>
</div>
))}
<hr />
</div>
)}
<input
name="addressPref"
placeholder="都道府県"
value={addressPrefecture}
onChange={(e) => setAddressPrefecture(e.target.value)}
/>
<input
name="addressArea"
placeholder="市町村区"
value={addressArea}
onChange={(e) => setAddressArea(e.target.value)}
/>
<input
name="addressDetail"
placeholder="番地・建物名等"
value={addressDetail}
onChange={(e) => setAddressDetail(e.target.value)}
/>
</form>
);
};
export default AddressCompletionPage;
データの取得に useSWR
ではなく useMutation
を使用していることに注目してください。
「住所を検索」ボタンを押下したときに呼び出される関数 fetchAndListupCandidates
の中で、fetch
が直接呼び出されていることが分かると思います。
今回の例において、「郵便番号にもとづいて住所の候補を取得してくる」という処理のキッカケが《ページを表示したこと》ではなく、《ユーザーからの入力を受け付けたこと》なので、このように書くのが正解です。
もし、このようなケースで useSWR
でデータを取得しようとすると、「《ボタンを押したかどうか》のようなBooleanステートが必要になり、何か遠回りなロジックになる」とか「候補が表示されてるときに郵便番号を書き換えると、候補も再取得されてしまう」みたいに、おかしな挙動と、それを抑えるために書かれたおかしなコードが襲ってくることになります。
「GET リクエストによるデータ取得だから useSWR
(または TanStack Query の useQuery
など)を使おう」と短絡的に考えるのはダメです。データ取得が何に起因して実行されるのかに注目しましょう。 公式にも書かれている通りです。(「エフェクト」を「useSWR
」と読み替えましょう。)
あるコードがエフェクトにあるべきか、イベントハンドラにあるべきかわからない場合は、そのコードが実行される 理由 を自問してください。コンポーネントがユーザに表示されたために実行されるべきコードにのみエフェクトを使用してください。 この例では、通知はユーザがボタンを押したために表示されるのであって、ページが表示されたためではありません!
https://ja.react.dev/learn/you-might-not-need-an-effect#sharing-logic-between-event-handlers
SWR の useSWR
、 TanStsack Query の useQuery
は 取得したデータをステート的に扱う ための機能です。そうでないデータ取得には適しません。
手続き的な取得には、(たとえ GET であっても) SWR なら useSWRMutation
、 TanStack Query なら useMutation
を使いましょう。
Recoil の場合は
Recoil については、react-hook-form, SWR に比べて、間違えたときのダメージがさほど大きくなさそうなので、軽く流しますが、以下のように分類できます。
-
useRecoilValue
,useRecoilState
はステート系の機能です -
useRecoilCallback
の中では、現在の値を取得できます
Jotai の場合
Recoil とコンセプトが似ている Jotai についても、同様です。
-
useAtom
,useAtomValue
はステート系の機能です -
useAtomCallback
(およびatom()
のセッター関数) の中では、現在の値を取得できます-
atom()
に渡すセッター関数も似たようなものです
-