useStateの落とし穴を避ける
コンポーネントに値を紐づけるuseState
React でコンポーネントに値を保持しておきたい時にuseStateというhooks がよく使われます。
このuseStateから渡されるstateを通常の変数のように扱うと、思ったように値が更新されなかったり、なぜか値のリセットがかかってしまったりと落とし穴に嵌ってしまうことがしばしばあります。
そんなuseStateがどのような仕組みで値の保持、更新を行っているのかを見ていきたいと思います。
useStateとは
useStateはコンポーネントが保持する値であるstateと、stateに変更をかけるためのset関数を提供します。
const [num setNum] = useState(0)
上のコード例のようにuseStateの引数にstateの初期値を渡して(省略した場合のデフォルト値はundefined)呼び出すことで、stateとset関数がタプル型で返されます。([state, setState]部分では配列の分割代入によって値を受け取っています。)
stateの値を更新するにはsetStateに更新したい値を渡します。
setStateには更新用の関数を渡すこともでき、更新用関数は引数としてその時点でのstateの値を(React側から)受け取ります。
setNum(((n) => n + 1))
なぜuseStateが必要なのか
useStateを使用せず、letやconstなどによってローカル変数を用意して管理すれば良いようにも思えますが、Reactでは画面内容を更新する際にレンダーとよばれるプロセスを経るため、ローカル変数がレンダー間で保持されずリセットされてしまい、ローカル変数をコンポーネントにうまく紐づけることができません。レンダー間でコンポーネントの値を保持するためにuseStateが必要になるわけです。
そもそもレンダーとは
Reactにおけるレンダーとは、React がコンポーネントを呼び出すことです。
(コンポーネントを呼び出して仮想のDOMツリーを作成し、現在のDOMツリーとの差分を検証、差分のあった箇所のみDOMツリーを変更します。)
親コンポーネントからのpropsが変化したときや、forceUpdate()がよびだされたとき、setStateによってstateが更新されるときなどにレンダーの実行命令がキューに追加されます。
コンポーネントに値を紐づけるためにコンポーネント内部でローカル変数を宣言しても、レンダー時にコンポーネントが呼び出され、ローカル変数も新たに作成されるためリセットがかかってしまうわけですね。
落とし穴1:useStateで値をセットしてもすぐにstateが更新されない?
上のコード例では、set関数を2回実行しています。
set関数をstate変数への値の代入と捉えると、1度目の実行箇所setNum(num + 1)でnumの値は1となり、2度目の実行箇所setNum(num + 2)でnumの値は3となるように思えますが、実際にボタンを押してみると2ずつ加算されていきます。
これは、set関数はstateの値を変更しているのではなく、次のレンダー時のstateの値を変更する命令をキューに追加していることに起因しています。
キューに追加されたstate値の変更命令をすべて処理して次のレンダーでのstate値を決定します。
set関数は次回レンダー時のstate値を設定する
useStateが返すstate値は、初回レンダー時はuseStateに渡した引数となり、それ以降のレンダー時は(前回のレンダー時に)set関数によって設定された値が返されます。
set関数は次回レンダー時のstate値を設定する関数であり、state値はそのレンダーの間は変化しない値なのです。
複数のset関数によって値が設定される場合には、それらの設定処理をすべて行った後の値が次回レンダー時にstate値として返されます。
先ほどのコード例では以下のように2回set関数を実行していました。
const clickHandler = () => {
setNum(num + 1);
setNum(num + 2);
};
これは「このレンダー時点でのstate値numに1を足した値を設定」した後に「このレンダー時点でのstate値numに2を足した値を設定」するため、最終的には次のレンダー時のstate値としてnum + 2が設定されることとなります。
このように処理がされるため、ボタンを押した後に画面に表示される値がnum + 3にならなかったわけですね。
更新関数を渡すことで落とし穴を回避
レンダー時点のstate値を使うのではなく、その処理を行っている時点での値を使用したい場合には、変数を別で用意しておいて、処理をしてからset関数に渡すことで解決できます。
これをより簡潔に行う方法として、set関数に更新用の関数を渡す方法があります。
setNum((n)=> n + 1)のようにすることで、次のレンダー時に渡される予定のstate値を引数nで受け取ることができ、返り値n+1が新たに次のレンダー時のstate値として設定されます。
この方法を用いることで、先のコード例は次のように書き換えられます。
ボタンをクリックすることで、3ずつ値が加算されて表示されるようになったと思います。
setStateは次回レンダー時のstate値を決めるキューに更新命令を追加している
stateの変更が複数回発生した場合には、一つずつレンダーを行うのではなく、一度にまとめて行われます(クリックのような意図的なイベントが複数回発生した場合など例外あり)。
先のコード例では1つ目のキューsetNum((n)=> n + 1)と2つ目のキューsetNum((n)=> n + 2)による処理が、現時点のレンダー時のstateの値(useStateから返されている値)に対して行われます。
setNum(num + 1)などもsetNum((n) => num + 1)というように更新関数が渡されていると捉えることができ、setNum(num + 1)の後にsetNum(num + 2)を実行すると、1つ目のキューsetNum(num + 1)でどんな値を設定しても次のキューでその値が使われていないため、あたかもなかったかのように処理が進んでしまうわけですね。
落とし穴2:set関数を使用したのに画面が更新されない?
通常、set関数によって次回レンダー時のstate値を設定することで再レンダーが起こります。
しかし、以下のような場合には再レンダーが起こりません。
ボタンをクリックしてみると、console上では確かにinitialObjとstateのnumプロパティの値は共に変化していますが、画面上の値は123のままです。
原因は、set関数の引数として渡した値initialObjが前回のレンダー時の値から変化しておらず、レンダーの実行命令が追加されなかったことです。
set関数はobject.is()によって、stateの変化を検知する
set関数はstateが前回のレンダー時から変化しているかどうかを、Object.is()によって判別しています。
Object.is()は厳密等価演算子===とほとんど同じ判定をしますが、0と-0を一致と判定しない点と、NaN同士を一致判定する点が異なります。
先の例ではinitialObjのプロパティの値は変化していましたが、stateが変化しているとは検知されず、再レンダーされていませんでした。
変数initialObjに格納されているのはオブジェクトへの参照(オブジェクトが格納されているメモリのアドレスのこと)であり、プロパティ値が変化したとしても参照自体は変化しないため、stateが変化したと検知されなかったわけですね。
新しいオブジェクトを作成して落とし穴を回避
受け取ったstateと同じオブジェクトのプロパティ値を変更しても変化が検知されませんので、新しいオブジェクトを作成することで検知できるようにします。
スプレッド構文とプロパティ値の上書きを利用することで、stateの変更を読み手にも見やすい形で記述することができます。
余談:useStateはなぜループや条件式、ネストされた関数で使えないのか?
useStateなどのhookはループや条件分岐、ネストされた関数で呼び出すとエラーが発生します。なぜこのような制約が課されているのでしょうか?
理由は、コンポーネント内のhookを呼び出されている順番によって識別しているためです。
ループや条件分岐があるとhookが呼び出される順番が、コンポーネントのレンダー間で異なってしまう可能性があるため、これらの機能でのhook呼び出しが制限されているわけです。
Discussion