gotohayato.com

moon indicating dark mode
sun indicating light mode

React の state hook で array を更新しても再描画がされない問題

2020/04/15JavaScriptReact

JavaScript のフレームワーク React に関する小ネタです。 掲題のとおり「 state hook で array を扱っているときに、 array を更新してもコンポーネントの再描画が起こらない」という問題についてです。

今回動作確認に使った React のバージョンは 16.13.1 です。

現象

state hook で管理している array (配列)を更新しても関連するコンポーネントの再描画が起こらない

コードのイメージは次のとおりです。 このサンプルでは記事をブックマークできる機能をページに持たせており、そのブックマーク一覧の管理に state hook を使っています。

// `onChange` が呼ばれても再描画が起こらない例
import { useState } from 'react'
const Page = props => {
let article = props.article
// bookmarks を管理する state hook
let [bookmarks, setBookmarks] = useState([])
return <Layout>
...
{bookmarks.includes(article.id) ? `ブックマークしています` : `ブックマークしていません`}
<input type="checkbox"
value={bookmarks.includes(article.id)}
onChange={event => {
if (bookmarks.includes(article.id)) {
let index = bookmarks.indexOf(article.id)
bookmarks.splice(index, 1)
} else {
bookmarks.push(article.id)
}
setBookmarks(bookmarks)
}}
/>
...
</Layout>
}

ポイントになるのは各チェックボックスの onChange コールバックの中身です。 チェックボックスのオン・オフが切り替わったら、 state を更新するべく setBookmarks() を呼び出しています。 しかしこのコードでは、チェックボックスをクリックしてもページの再描画が起こらず「ブックマークしています」「ブックマークしていません」のラベルが切り替わりません。

原因

問題の原因は setBookmarks() に渡された bookmarks が変更前と同じであることです。

何でも React の state hook は(旧来のクラスコンポーネントの state と同じく) Object.is() で変更の有無を判定しているらしく、現在の state の array (上の例では bookmarks )に splice()push() 等の破壊的なメソッドで変更を加えてから setState()setBookmarks() )に渡し直しても画面の再描画が起こりません。

問題の箇所:

let [bookmarks, setBookmarks] = useState([])
if (bookmarks.includes(article.id)) {
let index = bookmarks.indexOf(article.id)
bookmarks.splice(index, 1)
} else {
bookmarks.push(article.id)
}
// 変更前と同じ `bookmarks` なので再描画が起こらない
setBookmarks(bookmarks)

この点について React 16.13.1 時点の公式のドキュメントでは次のように説明されています。

Bailing out of a dispatch

If you return the same value from a Reducer Hook as the current state, React will bail out without rendering the children or firing effects. (React uses the Object.is comparison algorithm.)

  • Bailing out of a dispatch | Hooks API Reference – React

かんたんに訳すと

  • reducer で現在の state と同じ値を戻り値として返すと、 React は子要素の再描画や effect の発火を行いません
  • React は state の変更の有無の判定に Object.is() を使用しています

とのことです。

この公式の説明は useReducer() のセクションに書かれていますが useState() にもあてはまります。 つまり、 setState() に現在の state を渡しても画面の再描画は起こらないですよ、とのことです。

解決策

上述のとおり React は state の変更の有無の判定に Object.is() を使うとのことなので、現在の state を表す array を直接変更するのではなく別の array を新たに生成して setBookmarks() に渡せば OK です。

具体的には、上のコードは次のように書き直せば正しく再描画が起こるようになります。

// `onChange` が呼ばれたときに適切に再描画が起こる例
import { useState } from 'react'
const Page = props => {
let article = props.article
// bookmarks を管理する state hook
let [bookmarks, setBookmarks] = useState([])
return <Layout>
...
{bookmarks.includes(article.id) ? `ブックマークしています` : `ブックマークしていません`}
<input type="checkbox"
value={bookmarks.includes(article.id)}
onChange={event => {
let newBookmarks
if (bookmarks.includes(article.id)) {
newBookmarks = bookmarks.filter(item !== article.id)
} else {
newBookmarks = [...bookmarks, article.id]
}
setBookmarks(newBookmarks)
}}
/>
...
</Layout>
}

ポイントは以下の 2 行です。

newBookmarks = bookmarks.filter(item !== article.id)
newBookmarks = [...bookmarks, article.id]

要素を取り除く場合は filter() を、追加する場合は [..., newItem] を使っています。

もし useState() の使用にこだわらないのであれば、 useState() ではなく useReducer() を使った方が hook 利用側のコードをよりシンプルにわかりやすく書けてよい場合があります。 例えば上のブックマークのコードは useReducer() を使えば次のように書くことができます。

import { useReducer } from 'react'
const bookmarkReducer = (state, action) => {
if (action.type === 'remove') {
return state.filter(number => number !== action.number)
} else if (action.type === 'add') {
return [...state, action.number]
} else {
throw `Invalid type: ${action.type}`;
}
}
const Page = props => {
let article = props.article
// bookmarks を管理する state hook
let [bookmarks, bookmarkDispatch] = useReducer(bookmarkReducer, [])
return <Layout>
...
{bookmarks.includes(article.id) ? `ブックマークしています` : `ブックマークしていません`}
<input type="checkbox"
value={bookmarks.includes(article.id)}
onChange={event => {
bookmarkDispatch({
type: bookmarks.includes(article.id) ? 'remove' : 'add',
number: article.id,
})
}}
/>
...
</Layout>
}

bookmarkDispatch() には typenumber という 2 つの要素を持ったオブジェクト action を渡して使用します。断然わかりやすいですね。

bookmarkDispatch({ type: 'add', number: '123' })
bookmarkDispatch({ type: 'remove', number: '123' })

以上です。 より詳しく知りたい方は公式のドキュメント↓をチェックしてみてください。

参考:


後藤隼人
ウェブサイト制作・ウェブアプリ開発やマーケティングをしています。
GitHub
© 2020 gotohayato.com
サイトについてタグアーカイブメッセージを送る