useState の初期値 ―― 変化する Props の値を固定する
この記事は、以下の記事で述べたライフタイムのうち、 2. レンダー
, 3. コンポーネント
に関連する内容です。
両方を合わせて読むと、より理解が深められると思います。
はじめに: 止まらないデータの流れを意図的に止めることが可能
React の、特に Function Component の良いところといえば「ステートを更新したのに表示が更新されない」ようなバグを防ぐという思想が強く押し出されていることですよね?(圧)
React の元開発者である Dan Abramov 氏のブログの「壊れにくいコンポーネントを書く」ことについての投稿を見れば、そのような側面がよく分かると思います。
const SomeComponent: FC = () => {
const [count, setCount] = useState<number>(0);
// 再レンダリングのたびに、下の式が実行されるので、
// isEven 変数には、常に count が偶数かどうかの真偽値が入る
const isEven = count % 2 === 0;
React において、コンポーネントの関数内に直に書いた式は再レンダリングのたびに実行されるので、「とにかく変更に反応する」コードが書きやすく 、状態の変更によってコンポーネントが壊れないような作りになっています。
しかし、その裏側として、「変更しても再計算しない」コード については、イディオムを知らなければ 記述するのが困難 です。
特に、参照型である配列やオブジェクト (例: { name: "John Smith" }
) を意図せぬタイミングで新しく作成してしまうことで、状態がリセットされて意図せぬ挙動が起きる危険性があります。
そこで役立つのが useState
です。 このフックは、第一印象に反して、この「変更しても再計算しない」ようなコードを書くのに役立つ重要な機能の一つです。
もしかすると、あなたが書いている React を使った状態管理ロジックの「欠けたピース」が見つかるかもしれません。
1. フォームの初期値の例で考える
よく見るサンプルコードでは
const [age, setAge] = useState(0);
のように、初期値が固定値で決められていることが多いですが、ステートの使い方はそれだけではありません。
忘れられがちですが、実は親から受け取った Props も初期値として使うことができるのです。
export type TaskEditFormValues = {
title: string;
};
type Props = {
defaultValues: TaskEditFormValues;
};
const TaskEditForm: FC<Props> = ({ defaultValues }) => {
const [values, setValues] =
useState<TaskEditFormValues>(defaultValues);
このように書くと、以下のようになり、
-
初回レンダー:
親から渡されたdefaultValues
の値を初期値として、values
ステートが初期化される -
それ以降の再レンダー:
親から渡されたdefaultValues
の値が変わったとしても、values
ステートには影響しない
「ステートの変更が、派生した状態 / 子に渡される Props に伝播していく」というのが React に特有の一貫した挙動ですが、 ステートの初期化は例外で、伝播が止まります。 useState(initialValue) のところで、 initialValue の変更がステートに伝播せずに食い止められるのです。
ちなみに、このように使用される Props の名前は、 initial~
や default~
のような名前にすることで、「初期値に使う」という特別な目的があることを一目瞭然にする慣習になっています。
// defaultValue が初期値に使われる Prop であることが一目瞭然
<TaskEditForm defaultValues={{ title: "パンを買いに行く" }} />
useForm の defaultValues プロパティも(フックの引数ですが)同様に扱われています。
余談: useMemo じゃダメなんです
「値の再生成を防ぎたいなら、useMemo
を使えば良いんじゃないか?」 と思われたかも知れませんが、 useMemo の意味はあくまでも「パフォーマンスのために再生成を防ぐ」ものであり、「再生成されてしまうとロジックが誤作動する」ような使い方は非推奨です。
現在のところは再計算が起こってしまうことはありませんが、 React は将来のアップデートで必要になれば、メモ化した値を破棄して再計算するようになる可能性を示唆しています。
あくまでも「メモ化した値を忘却せずにいてくれる」現状の挙動に依存するのではなく、「忘却されうる」 という useMemo
の本質的な意味に従って書くべきだと私は考えています。 ステートか Ref を使いましょう。
This should be fine if you rely on useMemo solely as a performance optimization. Otherwise, a state variable or a ref may be more appropriate.
https://react.dev/reference/react/useMemo#caveats
(以下は筆者による訳)
useMemo のパフォーマンス最適化としての面だけを当てにしているなら大丈夫です。そうでないなら、ステート変数または Ref のほうが適しているかもしれません。
余談: useState の初期値は関数で指定できる
useState には、 initializer function (初期化関数) という機能もあります。
今回のように、 initialValues
オブジェクトの参照値そのまま利用するだけの場合には、生成したオブジェクトを捨てるだけなので問題になりませんが、 初期値を計算するコストが大きくて 無視できない場合もあると思います。そのような場合には、値そのものではなく、値を返す関数を引数に渡すことで、 初回レンダリングの時にしか初期値が計算されない ようにすることができます。
ただし、この関数は純粋である必要があります。(開発モードでは2回呼び出すことでチェックされることがあるようです。) initializer function が乱数を返すことは推奨されません。
const [values, setValues] = useState(() => computeInitialValues(defaultSeed));
2. defaultValue の変化に反応できない
クライアント側でデータをフェッチする場合には、ステートが変わることがあります。
たとえば、編集画面の仕様を考えると
- 編集中にバックエンドのデータが更新されて、 その変更がステートに反映された場合は無視したい
-
undefined
(読み込み中) →{ title: "パンを買う" }
のように、値が変わったから反映したい
のようなケースが現れます。(どちらの仕様に寄せるべきか個人的に決めかねているので、そこには触れず一応両方のやりかたを提示します)
前者の場合については、ステートが更新されないので、そのままで大丈夫ですが、後者のように、値の変更をフォームコンポーネントに反映したい場合はどうするべきでしょうか?
2-a. useEffect で手動リセット
とりあえず、useEffect を使えば、変更を検知してステートを変更できます。
const [values, setValues] =
useState<TaskEditFormValues>(defaultValues);
useEffect(() => {
setValue({ title: defaultValue.title })
}, [defaultValue.title]);
// }, [defaultValue]); と書いてしまうと、
// 親の再レンダリングで意図せず新しく計算されて、暴発しそうで怖い
// (下のように親で指定された場合に起きる)
// <TaskEditForm
// defaultvalues={{ title: "Calculated on Every Re-render" }}
// />
次の key を使うパターンと異なってコンポーネント自体のリセットではないので、values 以外の状態を保ったままにできるのが有利です。
しかし、依存配列の書き方や、親からの指定のしかた等でリセット処理が暴発しやすいのが欠点です。
2-b. 条件付きレンダリングでアンマウントする
useEffect を使わない方法を取れば、「再レンダリングのたびに式が評価されて前回と同一ではないオブジェクトが再生成されてしまう」問題にも対処しやすくなります。
最もシンプルなのは、 条件付きレンダリング等で、一度コンポーネントを破棄する 方法です。
この方法は、使える場面が限られます。(例: 読み込み未完了ならフォールバックを表示する)
// data: TaskEditFormValues | undefined
// 読み込みが未完了なら、フォールバック表示
if (!data) return <div>読み込み中...</div>;
return (
<TaskEditForm defaultValues={data} />
);
2-c. key を使ってリセットする
ここが本番です。 key
Prop を使うことで「この値/オブジェクトが変わったら、 コンポーネントの破棄(アンマウント)→再マウント によってコンポーネントそのものをリセットする」ことができます。
コンポーネントの内側ではなく、親側で管理することになりますが、「デフォルト値の扱いかた(TaskEditForm 側)」と「リセットする条件(親側)」を分離することができます。
key
としては string | number | null | undefined
が使用できます。
オブジェクトの変更を検知するためには、ハッシュ関数を使用すれば良いのかと思います。とりあえず、 SWR の内部で使われている stable-hash というライブラリを使うのが無難…?
// hash 関数は、JSON.stringify などで string (あるいは number)型に変換します。
// ライブラリを使用したほうが良いかな...?
return (
<TaskEditForm
// key を書かないと、 データを取得しても title が空のままで更新されない
key={hash(data)}
defaultValues={data ?? { title: "" }}
/>
);
クリック等のユーザーイベントを契機にコンポーネントをリセットしたい場合は、以下のようにリセットする瞬間の時刻を親ステートに保持する方法が使えると思います。
(実際に使うシーンはあるのかな…?)
const [timestamp, setTimestamp] = useState(0);
const handleHogehoge = () => {
// 中略
// TaskEditForm をリセットするために、
// key に入れる値を更新する
setTimestamp(Date.now())
}
return (
<TaskEditForm
key={timestamp}
defaultValues={data ?? { title: "" }}
/>
);
2. レンダー
の値を 3. コンポーネント
で固定することができる
まとめ
上の記事のライフタイムの視点から見ると、このテクニックを使うことで、〈ライフタイムの長短(スコープの大小)の関係に逆らって、 (マウントする瞬間に限り) 3. コンポーネント
から 2. レンダー
の値を参照する〉ことを実現していることが分かります。
また、子コンポーネントの中にステートを持つパターンは Uncontrolled Component (非制御コンポーネント)と一般的に呼ばれており、
- 子コンポーネント内に再レンダリングの発生を閉じ込める
- 親子間のやりとりを制限することで、コードの複雑さを一定領域に閉じ込める
効果があり、 Container Component パターン(下の記事における「分類パターン」のほう)とも通ずる部分があるように思えます。
Container Component は、 App Router / React Server Component での開発に非常に役に立つはずなので、そのあたりをうまく整理したいと思っています。
記事の内容は以上です。あとは今回の記事に使った実際の画面のコードを紹介します。
付録: ページとフォームのコンポーネント全文
2-c のパターンで、取得したデータに差異があるたびにフォームがリセットされる(ちょっと危険な)ように作ったコードになります。
SSR は行っておらず、データ取得中は中身が空のフォームが表示され、取得が完了するとサーバーから取得した(モック)データでフォームが書き換えられます。
ライブラリ | バージョン |
---|---|
next (App Router) | 13.4.13 |
react | 18.2.0 |
@radix-ui/themes | 1.0.0 |
@tanstack/react-query | 4.32.6 |
フォームコンポーネント `app/tasks/[taskId]/edit/TaskEditForm.tsx`
"use client";
import { FC, useCallback, useState } from "react";
import { useRouter } from "next/navigation";
import {
Flex,
TextField,
Text,
Button,
Container,
Heading,
} from "@radix-ui/themes";
export type TaskEditFormValues = {
title: string;
};
type Props = {
defaultValues: TaskEditFormValues;
};
const TaskEditForm: FC<Props> = ({ defaultValues }) => {
const router = useRouter();
const goBack = useCallback(() => {
router.back();
}, [router]);
// 各フィールドの値を一つのオブジェクトとして管理するステート
const [values, setValues] =
useState<TaskEditFormValues>(defaultValues);
type OnChange = Required<
JSX.IntrinsicElements["input"]
>["onChange"];
const handleChange: OnChange = useCallback((e) => {
const { name, value } = e.target;
setValues((prev) => ({ ...prev, [name]: value }));
}, []);
const handleSubmit = useCallback(() => {
alert(JSON.stringify(values, null, 4));
router.push("/tasks");
}, [router, values]);
return (
<Container size="2" p="4">
<Heading>タスクの編集</Heading>
<Flex direction="column" mt="4" gap="4" asChild>
<form onSubmit={handleSubmit}>
<label>
<Text as="span" weight="bold" mb="1">
タイトル
</Text>
<TextField.Input
name="title"
required
value={values.title}
onChange={handleChange}
/>
</label>
<Flex mt="4" gap="4" justify="end">
<Button
type="button"
variant="soft"
size="3"
onClick={goBack}
>
キャンセル
</Button>
<Button size="3">タスクを更新する</Button>
</Flex>
</form>
</Flex>
</Container>
);
};
export default TaskEditForm;
ページ本体のコード `app/tasks/[taskId]/edit/page.tsx`
"use client";
import { FC } from "react";
import TaskEditForm, {
TaskEditFormValues,
} from "./TaskEditForm";
import { useQuery } from "@tanstack/react-query";
import { useParams } from "next/navigation";
import { mockTasks } from "../../tasks";
/* モック。タスクの詳細データを 1s 待ってから取得する非同期関数 */
const mock_GetTaskDetail = async (params: {
taskId: string;
}) => {
await new Promise((resolve) => setTimeout(resolve, 1000));
return mockTasks.find(
(task) => task.id === params.taskId
);
};
// 仮。他に適当なハッシュ化ライブラリを使ったほうが良いかも
const hash = (data: unknown): string =>
JSON.stringify(data);
const Page: FC = () => {
const params = useParams() as { taskId: string };
// tanstack query を使ってデータを取得する。
const { data } = useQuery({
queryKey: ["tasks", params.taskId],
queryFn: async () =>
await mock_GetTaskDetail({ taskId: params.taskId }),
// select を使って、フォームの初期値として渡せる形に変換する
select: (data): TaskEditFormValues | undefined =>
data && { title: data.title },
});
return (
<TaskEditForm
// key を書かないと、 データを取得しても title が空のままで更新されない
key={hash(data)}
defaultValues={data ?? { title: "" }}
/>
);
};
export default Page;
共通のファイル群
import "./globals.css";
import { Theme } from "@radix-ui/themes";
import "@radix-ui/themes/styles.css";
import { QueryClientProvider } from "./_libs/tanstack-query/Provider";
import "./themes-config.css";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ja">
<body>
<Theme>
<QueryClientProvider>
{children}
</QueryClientProvider>
</Theme>
</body>
</html>
);
}
"use client";
import { FC, ReactNode, useState } from "react";
import {
QueryClient,
QueryClientProvider as Provider,
} from "@tanstack/react-query";
type Props = {
children: ReactNode;
};
export const QueryClientProvider: FC<Props> = ({
children,
}) => {
const [client] = useState(() => new QueryClient());
return <Provider client={client}>{children}</Provider>;
};
.radix-themes {
--default-font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI (Custom)', Roboto,
'Helvetica Neue', 'Open Sans (Custom)', sans-serif, 'Apple Color Emoji',
'Segoe UI Emoji';
--em-font-family: var(--default-font-family);
--quote-font-family: var(--default-font-family);
}
Discussion