90
62

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Reactのstate更新におけるバッチ処理と「関数型のstate更新」がなぜ必要なのか?について

Last updated at Posted at 2020-09-04

はじめに

React で state を更新する際は以下のように更新用関数にオブジェクトやboolean等の値を直接渡すことが多い。

 const [state1, setState1] = useState(false)
 
 setState1(true)

一方これとは別に関数を渡す「関数型の更新」を行うこともできる。

 setState1(prevState => !prevState)

公式ドキュメントによるとこの機能は、「新しい state を前の state に基づいて計算する」場合に利用できるらしい。
(参考: 関数型の更新 - React)

しかしここで1つ疑問に思わないだろうか?
前の state って何だ? それはつまり更新前の状態であって、上のコード例なら state1 をそのまま利用すれば良いのではないか? 例えばこんな風に。。。

 setState1(!state1)

結論から言えばそれは正しくない。より正確には期待通り動かないケースがあるため、「関数型の更新」を使う必要がある。なぜ必要なのかを以降説明していく。

要点

最初に本記事の要点を記載する。
(これだけで理解できてしまう方は、恐らく以降を読む必要は無いだろう。)

  • React は state の更新をバッチ処理する。つまり複数の state 更新リクエストを一括で処理する
  • 通常このバッチ処理の中間の state を参照することは出来ない。「新しい state を前の state に基づいて計算する」場合はこの点が問題になる。
  • 「関数型の state 更新」を使うと、次の state を計算する際に、バッチ処理の中間の state も参照できる。「前の state」を利用したい場合には「関数型の state 更新」を使う方が安全。

state更新のバッチ処理

React では state の更新が行われるとコンポーネントが再レンダリングされるが、「state の更新リクエスト」(setStateや useState から取得する state 更新用関数の実行により発生)を受けた際、直ちに再レンダリングが行われるわけではない。
1回のレンダリング中に複数回「state の更新リクエスト」を受けた場合はそれらを一括で処理する1。これはバッチ処理と呼ばれているようだ。(参考: コンポーネントの state - React)

実際のコード & 動作から確認してみる。

import React, { useState, useEffect } from 'react'

export default function App1() {
  const [state1, setState1] = useState('')
  const [state2, setState2] = useState('')
  const [state3, setState3] = useState('')

  useEffect(() => {
    setState1('App1-1')
    setState2('App1-2')
  }, [])

  console.log(
    `App1 state1=${state1} App2 state2=${state2} App3 state3=${state3}`
  )

  return (
    <div>
      App1
      <ChildA setState3={setState3} />
    </div>
  )
}

const ChildA = (props) => {
  const { setState3 } = props

  useEffect(() => {
    setState3('ChildA')
  }, [])

  return <div> ChildA </div>
}

とても単純なコードだ。App1 (親コンポーネント)と ChildA (子コンポーネント)が存在し、App1 は state を3つ(state1, state2, state3)持っている。App1 と ChildA はそれぞれがマウント時に(useEffect 内で)全部で3回の state の更新リクエスト(setStateX)を実行し、自分のコンポーネント名をそれぞれの state に設定している。また、App1には console.log を置き、各 state の中身とレンダリング回数が分かるようにもしてある。

このコードを実行するとコンソールには何が表示されるだろうか? もし更新リクエストを送るたびにレンダリングが起きるなら、マウント時と3回の更新リクエストがあるので計4回 console.log の結果が表示されるだろう。

しかし実際にはそうならない。

# コンソール表示
App1 state1=, App2 state2=, App3 state3=
App1 state1=App1-1, App2 state2=App1-2, App3 state3=ChildA

表示されるのは2回だけ。1つ目は全ての state の値が空なので初期マウントの値だとすると、他の3回の更新リクエストは1つにまとめられて1度だけ再レンダリングが起きていることが分かる。このことからReactが複数の更新リクエストを一括で処理(= バッチ処理)していることが実動作上からも理解できる。

新しい state を前の state に基づいて計算する

少し state 更新処理を変えてみよう。コンポーネントの名前を持つだけなら state を3つに分ける必要はない。今度は親コンポーネントに配列の state を1つだけ持たせて、各 state の更新リクエストが配列に対して自分のコンポーネント名を追加するような処理にしてみる。

import React, { useState, useEffect } from 'react'

export default function App1() {
  const [state1, setState1] = useState([])

  useEffect(() => {
    setState1(['App1-1'])
    setState1(['App1-2'])
  }, [])

  console.log(`App1 state1=${state1}`)

  return (
    <div>
      App1
      <ChildA setState1={setState1} />
    </div>
  )
}

const ChildA = (props) => {
  const { setState1 } = props

  useEffect(() => {
    setState1(['ChildA'])
  }, [])

  return <div> ChildA </div>
}

これでいいだろうか? いやもちろん上手くいかない。

# コンソール表示
App1 state1=
App1 state1=App1-2

これでは毎回の state 更新リクエスト毎に配列を上書きしているだけだ。そのため最後に実行された(と思しき)setState1(['App1-2'])の結果のみが state に反映されたようだ。やりたいのは配列に名前を追加していくことなので、必然的に前の state が必要になる。では以下の処理ならいけるだろうか?何か引っかかるが...(バッチ処理...?)

(なお以降は修正箇所を強調するためにdiffのみを示す)


diff --git a/src/App1.js b/src/App1.js
index 7591199..bc5bb65 100644
--- a/src/App1.js
+++ b/src/App1.js
@@ -4,8 +4,8 @@ export default function App1() {
   const [state1, setState1] = useState([])
 
   useEffect(() => {
-    setState1(['App1-1'])
-    setState1(['App1-2'])
+    setState1([...state1, 'App1-1'])
+    setState1([...state1, 'App1-2'])
   }, [])
 
   console.log(`App1 state1=${state1}`)
@@ -13,16 +13,16 @@ export default function App1() {
   return (
     <div>
       App1
-      <ChildA setState1={setState1} />
+      <ChildA state1={state1} setState1={setState1} />
     </div>
   )
 }
 
 const ChildA = (props) => {
-  const { setState1 } = props
+  const { state1, setState1 } = props
 
   useEffect(() => {
-    setState1(['ChildA'])
+    setState1([...state1, 'ChildA'])
   }, [])
 
   return <div> ChildA </div>

結果を見てみよう

# コンソール表示
App1 state1=
App1 state1=App1-2

だめだ! 結果は変わらない。前と変わらずsetState1(['App1-2'])の結果のみが反映されてしまっている。なぜだ!そしてどうすればいいんだ!?

バッチ処理の影響と関数型のstate更新

なぜこのような結果になってしまったのか? その理由は state 更新のバッチ処理にある。
前述の通り React は一度に複数の state 更新リクエストを受け取った際に、一括でバッチ処理をして1度のレンダリングにまとめてしまう。コンポーネントが自分の state として認識できるのは、「バッチ処理が始まる前の状態」と、「バッチ処理が完了した後」の状態のみであり、その間の中間状態を参照することはできない。その結果どの更新リクエスト(setState1)でも...state1の中身は初期状態である[](空配列)になってしまう。他の state 更新リクエストが何であったかを考慮することはできないのだ。これでは上手くいかない。

ではどうすれば良いのか? ここで登場するのが「関数型のstate更新」である。「関数型の state 更新」を用いると、state 更新処理を行う際に、渡した関数の引数に前の state が渡される。これにはバッチ処理の中間状態も含まれるため、期待通りの処理を実現できる。コードと実行結果を見ると直ぐに理解できるだろう。

diff --git a/src/App1.js b/src/App1.js
index bc5bb65..8ae1bac 100644
--- a/src/App1.js
+++ b/src/App1.js
@@ -4,8 +4,8 @@ export default function App1() {
   const [state1, setState1] = useState([])
 
   useEffect(() => {
-    setState1([...state1, 'App1-1'])
-    setState1([...state1, 'App1-2'])
+    setState1((prevState) => [...prevState, 'App1-1'])
+    setState1((prevState) => [...prevState, 'App1-2'])
   }, [])
 
   console.log(`App1 state1=${state1}`)
@@ -13,16 +13,16 @@ export default function App1() {
   return (
     <div>
       App1
-      <ChildA state1={state1} setState1={setState1} />
+      <ChildA setState1={setState1} />
     </div>
   )
 }
 
 const ChildA = (props) => {
-  const { state1, setState1 } = props
+  const { setState1 } = props
 
   useEffect(() => {
-    setState1([...state1, 'ChildA'])
+    setState1((prevState) => [...prevState, 'ChildA'])
   }, [])
 
   return <div> ChildA </div>

結果を見てみよう。

# コンソール表示
App1 state1=
App1 state1=ChildA,App1-1,App1-2

やった!期待通りの値だ。これまで反映されていなかった ChildA, App1-1 の state 更新リクエストもきちんと反映されていることが分かる。これで目的通り、state に保持された配列に値を追加する処理が実現できた。

ここから更に「関数型の更新」にconsole.logを仕込んで見ると、バッチ処理内の中間の状態がどうなっているかもよく分かる。

diff --git a/src/App1.js b/src/App1.js
index 8ae1bac..63214e7 100644
--- a/src/App1.js
+++ b/src/App1.js
@@ -4,8 +4,14 @@ export default function App1() {
   const [state1, setState1] = useState([])
 
   useEffect(() => {
-    setState1((prevState) => [...prevState, 'App1-1'])
-    setState1((prevState) => [...prevState, 'App1-2'])
+    setState1((prevState) => {
+      console.log('App1 prevState: ', prevState)
+      return [...prevState, 'App1-1']
+    })
+    setState1((prevState) => {
+      console.log('App1 prevState: ', prevState)
+      return [...prevState, 'App1-2']
+    })
   }, [])
 
   console.log(`App1 state1=${state1}`)
@@ -22,7 +28,10 @@ const ChildA = (props) => {
   const { setState1 } = props
 
   useEffect(() => {
-    setState1((prevState) => [...prevState, 'ChildA'])
+    setState1((prevState) => {
+      console.log('App1 prevState: ', prevState)
+      return [...prevState, 'ChildA']
+    })
   }, [])
 
   return <div> ChildA </div>

結果は以下の通りだ。

# コンソール表示
App1 state1=
App1 prevState:  []
App1 prevState:  ["ChildA"]
App1 prevState:  (2) ["ChildA", "App1-1"]
App1 state1=ChildA,App1-1,App1-2

今度は中間の状態に関しても見て取れるようになった。この結果から state更新 のバッチ処理は、ChildA, App1, App1-2 の順番に行われ、App1, App1-2 の「関数型の更新」ではバッチ処理中の中間状態を取得していることが見て取れる。

このように「関数型の更新」を用いることで、同じ state への「複数の state 更新リクエスト」が、バッチ処理で一括で更新されても、バッチ処理の中間の状態を参照しながら次の state を計算することが可能になる。

この「関数型の更新」は、1度に発生する state 更新リクエストが1つと断言できるのなら必要ないかもしれない。しかし将来の修正を考慮した上でもそう断言するのは困難だろう。1つ state 更新リクエストを追加しただけでもバッチ処理は起こり得るため、必然的に中間の状態が参照できなくなる可能性も出てくる。「新しい state を前の state に基づいて計算する」場合には「関数型の更新」を用いる方が安全だ。

また少し観点は変わるが、どのコンポーネントから発生した state 更新リクエストであっても、「関数型の state 更新」の中できちんと順番に処理している、という点にも注目してほしい。
親コンポーネントの関数で、"連続で"実施された更新リクエストも、"子コンポーネントの中"で実施された更新リクエストも、バッチ処理の中では順番に処理されていることが見て取れる。React の state 更新の内部処理の様子が何となくイメージできるのではないだろうか?

最後に

いかがだっただろうか? 一見基礎的な内容ではあるが、言われてみるとイマイチイメージが出来ていない内容だった人も多いのではないかと思う(私がそうだった)。Reactの state 更新はある程度一括で行われ、Reactの内部で1つずつ順番に処理されているということは覚えておくと、何かの役に立つかもしれない。

参考サイト

Functional setState is the future of React
https://www.freecodecamp.org/news/functional-setstate-is-the-future-of-react-374f30401b6b/
React の setState() の引数が関数の場合とオブジェクトの場合の違いについて整理する
https://qiita.com/im36-123/items/857c96ff60024c0d8a1c
state の更新は非同期に行われる可能性がある
https://ja.reactjs.org/docs/state-and-lifecycle.html#state-updates-may-be-asynchronous
Does React keep the order for state updates?
https://stackoverflow.com/questions/48563650/does-react-keep-the-order-for-state-updates/48610973#48610973
React batch updates for multiple setState() calls inside useEffect hook
https://stackoverflow.com/questions/56885037/react-batch-updates-for-multiple-setstate-calls-inside-useeffect-hook
What happens when using this.setState multiple times in React component?
https://stackoverflow.com/questions/33613728/what-happens-when-using-this-setstate-multiple-times-in-react-component

  1. 実際には全ての state 更新リクエストが一括で処理されるわけではない。例えばPromis内の state 更新リクエストは直ちに再レンダリングが行われる。詳細は上記参考サイトの「React batch updates for multiple setState() calls inside useEffect hook」参照のこと

90
62
1

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
90
62

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?