要旨
React のuseState
は引数にstate
の初期値をとりますが、関数を渡す事もできます。
関数を渡した場合には、state
が初期化されるときにのみ関数が実行されて、初期値が渡されます。
レンダーのたびに初期化関数が呼び出されないため、計算量が大きい場合にはパフォーマンスの改善につながります。
この記事では、初期値の渡し方の異なる2つのコンポーネントの例をまじえながら動きを確認してみたいと思います。
useState とstateの初期値
React ではstate
と呼ばれる変数をコンポーネントに紐づけて状態を管理できます。
このstate
はuseState
というhooks によってstate
変更用の関数(set
関数)とともに返されます。
const [state, setState] = useState(initialValue)
useState
は引数として受け取った値をstate
の初期値とし、初回のレンダー時のみその値をstate
に格納します。
(state
が返された後にuseState
の引数の値を変更しても、元のstate
には変更が入りません。)
計算量の大きい初期値
useState
に渡す初期値として、ある程度計算をしてから渡すこともあるかと思います。
初期値の計算量が多い、以下のようなコンポーネントを考えてみます。
import { useState } from "react";
const initializeState = () => {
const expensivelyProcessedList: number[] = [];
for (let i = 0; i < 10000; i++) {
expensivelyProcessedList.push(i);
expensivelyProcessedList.sort();
expensivelyProcessedList.reverse();
}
console.log("非最適化コンポーネントでinitializeState が呼び出されました");
return [1, 2, 3];
};
export const NonOptimizedList = () => {
const [state] = useState(initializeState());
const [lang, setLang] = useState<"en" | "ja">("ja");
return (
<>
<button
onClick={() => {
setLang((prevLang) => {
return prevLang === "ja" ? "en" : "ja";
});
}}
>
{lang}
</button>
<ul>
{state.map((element) => {
return <li key={element}>{element}</li>;
})}
</ul>
</>
);
};
このコンポーネントでは、initializeState
という関数によって配列を無駄に何度も並び替える処理をしています。
計算量の大きい初期化用の関数initializeState
をuseState(initializeState())
のように実行する形でuseState
の初期値として渡しています。
useState
に渡した初期値はReact によって初期化時以外には呼び出されたあとで破棄されます。
そのため、重たい処理によって初期値を計算してから渡してしまうと、重い処理が何度も呼び出されることになってしまいます。
上のコンポーネントには言語表示を切り替えるボタン(en
やja
と書かれたボタン)がつけられています。
このボタンを押した際にもReact のレンダーによってこのコンポーネントが呼び出されるためinitializeState()
が実行されてしまっています。
(コンソール上には"非最適化コンポーネントでinitializeState が呼び出されました"
がボタンを押すたびに表示されます。)
そのため、ボタンの表示を変えるだけの処理にも時間がかかってしまっています。
初期化関数を初期値に渡す
useState
の初期値には初期化用の関数を渡すことができます。
関数を初期値として渡した場合には、React はuseState
が初期化を行うときにのみ関数を実行して、その結果を初期値として渡してくれるようになります。
先ほどのコンポーネント例を以下のように書き換えてみます。
import { useState } from "react";
const initializeState = () => {
const expensivelyProcessedList: number[] = [];
for (let i = 0; i < 10000; i++) {
expensivelyProcessedList.push(i);
expensivelyProcessedList.sort();
expensivelyProcessedList.reverse();
}
console.log("最適化コンポーネントでinitializeState が呼び出されました");
return [3, 2, 1];
};
export const OptimizedList = () => {
const [state] = useState(initializeState);
const [lang, setLang] = useState<"en" | "ja">("ja");
return (
<>
<button
onClick={() => {
setLang((prevLang) => {
return prevLang === "ja" ? "en" : "ja";
});
}}
>
{lang}
</button>
<ul>
{state.map((element) => {
return <li key={element}>{element}</li>;
})}
</ul>
</>
);
};
先の例では関数実行後の値をuseState(initializeState())
という形で渡していましたが、この例では関数を直接渡す形useState(initializeState)
となっています。
initializeState
は実行されると「最適化コンポーネントでinitializeState が呼び出されました」というメッセージがコンソール上に表示されるようになっています。
しかし、このコンポーネントの言語切り替えボタンを押してみてもコンソール上に新たにメッセージが表示されることはなく、画面への反映もスムーズに行われていることが確認できます。
パフォーマンス比較
React Developer Tools のProfile タブで言語切り替えボタンを押した際に起こるレンダーにかかった時間をそれぞれ計測してみました。
useState
に直接値を渡しているNonOptimizedList
の方ではRender Duration が約1,000ms かかっているのに対し、初期化関数を渡しているOptimizedList
の方では0.2 ms ~ 0.3ms という結果となりました。
初期値の計算にとても時間がかかるような場合には、初期値の渡し方一つでここまでパフォーマンスが変わることがあるわけですね。
今回扱ったコード例は以下に置いてあります。
さいごに
React で計算量を抑える方法としてはuseMemo
やuseCallback
の使用などもありますが、useState
への初期値の渡し方一つとってみてもパフォーマンスに大きな違いが出うるということは意識しておきたいですね。
単純に値を渡すのではなく関数自体を渡すようなコードとなるため、場合によっては可読性が低下することがあるかもしれません。
最適化によるパフォーマンス改善の効果とコードの可読性低下の度合いはトレードオフの関係となることも多いため、バランスを考えて採用するかどうかを検討したいですね。
余談: React Compiler
useMemo
やuseCallback
については、React 19 以降、React Compiler によって自動で最適化できるようになるみたいですね。
このコンパイラは、JavaScript と React のルールに関する知識を使用して、コンポーネントやフック内にある値や値のグループを、自動的にメモ化します。1
-
React Compiler のドキュメントは執筆時点では暫定版であり、内容が変わる場合があります。 ↩