LoginSignup
38
32

【React】ライブラリの《ステート系機能》と《手続き的取得機能》を区別しよう

Posted at

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 で注意するべきなのは、手続き的に取得するのには使用できないという点です。

以下のような、よくある《郵便番号を入力して、表示された候補の一つを選ぶと、それで入力欄を埋めてくれる》機能を考えてみましょう。

郵便番号6180000が入力されて、京都府乙訓郡大山崎町と大阪府三島郡島本町の2つの候補が表示されている

ソースコードの重要な部分の抜粋を以下に示します(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() に渡すセッター関数も似たようなものです

38
32
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
38
32