Web RecordWEB Record
React

【React】useStateでオブジェクトを更新しても再レンダリングされないときの対処法

2024.04.09
【React】useStateでオブジェクトを更新しても再レンダリングされないときの対処法

ReactでuseStateで更新をかけても再レンダリングされないことがありハマったので対処法を備忘録として残したいと思います。

何が起きたか

  • useStateを利用してオブジェクトを更新しようとしても再レンダリングされなかった。

useStateで管理していたのは以下のようなオブジェクトになります。

state.ts
1type HogeObject = {
2    property1: string
3    property2: string
4}
5
6const [hoge, setHoge] = useState<HogeObject>({property1: '', property2: ''})

setHogeを使用してhogeを更新すれば、stateが更新されて再レンダリングされるはずです

そこでボタンクリック時にhogeを更新して再レンダリングさせようと下記のようなコードを書きました。

sample.tsx
1export default function Sample() {
2  type HogeObject = {
3    property1: string
4    property2: string
5  }
6
7  const [hoge, setHoge] = useState<HogeObject>({ property1: '', property2: '' })
8
9  // 更新用のメソッド
10  // property1を更新してsetHogeで再レンダリングされるはず。
11  function update() {
12    hoge.property1 = 'update!'
13    setHoge(hoge)
14  }
15
16  return (
17    <>
18      <div>current: {hoge.property1}</div>
19      <button onClick={update}>update Hoge</button>
20    </>
21  )
22}

ボタンクリックでupdateメソッドの処理が走り、その中で、hoge.property1を更新。
更新後のオブジェクトをそのままsetHogeに突っ込んで再レンダリングさせようとしています。

しかし、ボタンをクリックしても再レンダリングされません。

更新されない

解決策

state更新時は新規のオブジェクトを設定することで再レンダリングが走るようになりました。

新規のオブジェクトを設定.tsx
1function update() {
2  // 更新後のオブジェクトを新規に作成してsetHogeに渡す
3  const newHoge = { property1: 'update!', property2: hoge.property2 }
4  setHoge(newHoge)
5}

新規のオブジェクト作成時には、上記のように更新しないプロパティも明示的に指定するのもいいですが、スプレッド構文も使用できます。
個人的にはスプレッド構文を使用する方が、更新するプロパティとしないプロパティが明確になり可読性が向上すると思っています。

スプレッド構文の例.tsx
1const [hoge, setHoge] = useState<HogeObject>({ property1: '', property2: '' })
2
3function update() {
4  setHoge({...hoge, property1: 'update!'})
5}

更新されなかった原因

更新時は新規のオブジェクトを渡せば再レンダリングされるのはわかりました。
ですが、なぜ新規のオブジェクトを渡す必要があるのでしょうか?

useStateのstateが更新される条件について、Reactのドキュメントでは以下のように記載されています。

React では、更新の前後で state の値が変化しない場合、その変更は無視されます。
state の値の変化は、Object.is によって判断されます。

つまりまとめると下記のようになります。

useStateで再レンダリングされる条件

Object.is(更新前のstate, 更新後のstate) の結果がfalseの場合にコンポーネントが再レンダリングされる

今回更新されなかったのはstateをオブジェクトとして管理している場合です。

なので、オブジェクトの場合にObject.isのtrue/falseがどのように判定されるかが分かれば
なぜオブジェクトの場合に再レンダリングされなかったのかが理解できそうです。

MDNによると下記のように記載されていました。

どちらも同じオブジェクト(すなわち両方の値がメモリー内の同じオブジェクトを参照)

「すなわち両方の値がメモリー内の同じオブジェクトを参照」の部分が重要で

同じオブジェクトかどうかの判定には内部の値が同じかは関係なく、比較するオブジェクトの参照先が同じメモリー上を向いているかどうかしか見ていないみたいです。

一旦先ほどの更新されなかった例を再度みてみましょう。更新されなかった(再レンダリングされなかった)コードは以下のようになっていました。

sample.tsx
1export default function Sample() {
2  type HogeObject = {
3    property1: string
4    property2: string
5  }
6
7  const [hoge, setHoge] = useState<HogeObject>({ property1: '', property2: '' })
8
9  function update() {
10    hoge.property1 = 'update!'
11    setHoge(hoge)
12  }
13
14  return (
15    <>
16      <div>current: {hoge.property1}</div>
17      <button onClick={update}>update Hoge</button>
18    </>
19  )
20}

更新処理は下記です。

sample(更新処理抜粋).tsx
1const [hoge, setHoge] = useState<HogeObject>({ property1: '', property2: '' })
2
3function update() {
4  hoge.property1 = 'update!'
5  setHoge(hoge)
6}

これを見てみると確かに、更新前のhogeを参照してproperty1を更新しているだけなので更新前と後は同じメモリを参照していそうです。

その結果、Object.isがオブジェクト比較時に同じオブジェクトとして判定しtrueを返すので、再レンダリングされなかった。ということのようでした。

色々なパターンでオブジェクト比較時のObject.isを試してみましょう。

  • 更新前のプロパティを直接更新

trueが返ってきます。

Object.is_1.ts
1const test = { hoge: '1', fuga: '2' }
2test.hoge = '3'
3const test2 = test
4
5// true
6console.log(Object.is(test, test2))
  • 別メモリを指しているが内容が同じオブジェクト

falseが返ってきます。

Object.is_2.ts
1const test = { hoge: '1', fuga: '2' }
2const test2 = { hoge: '1', fuga: '2' }
3
4// false
5console.log(Object.is(test, test2))
  • スプレッド構文を使用して新規オブジェクトを作成

falseが返ってきます。

Object.is_3.ts
1const test = { hoge: '1', fuga: '2' }
2
3// false
4console.log(Object.is(test, { ...test }))

結論

useStateでオブジェクトを更新する際は毎回新規オブジェクトを作りましょう。