React の state hook で array を更新しても再描画がされない問題
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.)
かんたんに訳すと
- 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()
には type
と number
という 2 つの要素を持ったオブジェクト action
を渡して使用します。断然わかりやすいですね。
bookmarkDispatch({ type: 'add', number: '123' })
bookmarkDispatch({ type: 'remove', number: '123' })
以上です。 より詳しく知りたい方は公式のドキュメント↓をチェックしてみてください。
参考: