2020-1-30

[React] 過去の状態を参照しないために気をつけること

技術

関数型コンポーネントで過去の状態を参照してしまってバグを出すのを数回してしまったのでまとめておく。

問題のあるコード

const [count, setCount] = useState(0)

const inc = () => setCount(count + 1)

const syncInc = () => inc()
const asyncInc = async () => {
  await delay(1000)
  inc()
}

上の処理に関して、 asyncInc が呼ばれた直後に syncInc が呼ばれると、 asyncInc が実行されるタイミングでは過去の状態が参照されてしまいバグが起きる。

これをどのように防ぐか、が今回の本題。

非同期的に実行される関数はuseRefを用いて常に最新の状態を保つ

非同期的に実行される関数は、関数を生成したタイミングと実行するタイミングが異なる。そのため、その間に使用している変数・関数が更新されてしまい、結果過去状態をベースとして処理が実行されバグが起きる。

例ではasyncを挙げたが、主に気をつけるべきは以下の関数(他にも見つけたら随時追記)。

  • Promise.then/catch(async関数)
  • setTimeout
  • setInterval
  • addEventListener

見出しにも書いたが、useRefを使って常に最新の関数が実行されるようにする。

const [count, setCount] = useState(0)

const inc = () => setCount(count + 1)
const refInc = useRef(inc)
refInc.current = inc

const syncInc = () => inc()
const asyncInc = async () => {
  await delay(1000)
  refInc.current()
}

最新の状態の取得は、react-useuseLatest を使うと一行省略出来る。

const [count, setCount] = useState(0)

const inc = () => setCount(count + 1)
const latestInc = useLatest(inc)

const syncInc = () => inc()
const asyncInc = async () => {
  await delay(1000)
  latestInc()
}

react-use は以下のようなフックも用意しているので、それを使ってもOK。

  • useAsyncFn
  • useTimeoutFn
  • useInterval
  • useEvent

useStateのsetterに関して、自身の状態を使用する場合は setState(state => ~) を使用する

const inc = () => setCount(count + 1)

今回のバグは、setterにて自身の過去の状態を参照してしまったので発生していた。

これを、以下のように書き換えることで常に最新の状態を参照できる。

const inc = () => setCount(count => count + 1)

前述の対処だけでは対処漏れ等でバグが防ぎきれないことも考えられるので、自身の状態を参照しているsetterに関してはこの書き方を徹底しておくほうがいい。