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

JavaScriptReact

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

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

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

参考:


アバター
後藤隼人 ( ごとうはやと )

ソフトウェア開発やマーケティング支援などをしています。詳しくはこちら