🎣

React Hooksで this.setState に相当する処理を行う

2020/11/03に公開

Reactのコンポーネントを関数で書くと記述がシンプルになります。

const Component = props => <ChildComponent {...props} />

しかし関数型コンポーネントはpropsを引数で受け取りElementを返すだけの関数なので、Reactの外から状態をセットして再レンダリングを行うことができません。

クラスコンポーネントならコンストラクタやcomponentDidMountでsetStateを外部のStoreなどに渡して、コールバックで状態を変更することができます。
また、setStateで変更したstateはrenderをトリガーするので状態の変更に応じてビューを更新することができます。

setStateの機能は関数型コンポーネントでは実現できないのでしょうか?
実はReactHooksを使えばsetStateと似たような処理が実現できます。

// !! 間違いの例 !!
const store = new EventEmitter()

const Component = () => {
  const [state, setState] = useState({/* 初期状態 */})
  store.on('change', newState => setState(newState))
  return <ChildComponent {...state} />
}

setStateはuseState Hookを使って作成できます。
しかし上記の実装には問題があります。
コンポーネントがレンダリングされるたびにuseStateは呼び出され、
そのたびにコールバックを登録してしまうのです。

最初の一回だけ実行されるcomponentDidMountのような処理は関数コンポーネントでは実現できないのでしょうか?

HooksのuseEffectを使えばそのような処理も実現することができます。

const store = new EventEmitter()

const Component = () => {
  const [state, setState] = useState({/* 初期状態 */})
  
  useEffect(() => {
    // Storeからstateをセットするハンドラ
    const onChange = newState => setState({...newState})
    // ハンドラをStoreに登録
    store.on('change', onChange)
    // ComponentがDOMから削除されたときの後処理としてハンドラも削除
    return () => store.removeListener('change', onChange)
  }, [])
  
  return <ChildComponent {...state} />
}

useEffectはレンダリングが起きたときにコールバックを実行してくれるHookです。
クラスコンポーネントのcomponentDidUpdateに相当します。
基本的にはレンダリング毎に実行されますが、オプション引数を渡すことでコールバックの実行条件を設定することができます。
useEffectの2番目の引数に配列を渡すとuseEffectはその配列の要素をチェックするようになります。
レンダリング毎に、この配列の要素をそれぞれ前回のレンダリング時に配列に入っていた要素と比較して、もし変更があればそのたびにuseEffectのコールバックを実行します。

// a, bの変更をレンダリング毎にチェックして変更があれば"処理"を実行する
useEffect(() => { /* 処理 */ }, [a, b])

この仕組を利用して初回のみ実行するEffectを書くことができます。
要素の値に変更があれば再実行する、ということは空の配列を渡してしまえば二度と再実行はされないことになります。

// 空の配列の要素は存在しないので、要素の値の変更もないことになる
useEffect(() => { /* 処理 */ }, [])

最初の一回は実行されるので、この処理はcomponentDidMount相当として扱うことができます。

このトリックはReactのドキュメントにも書いてあります。
https://ja.reactjs.org/docs/hooks-reference.html#conditionally-firing-an-effect

また、useEffectのコールバックの返り値として関数を返すと、レンダリング毎にその処理を実行してから次のコールバックを実行するようになります。一般的には新しい処理を実行する前に前回の処理の後処理を行うための機能として用意されています。

useEffect(() => {
  /* 処理 */
  return () => { /* 後処理 */ }
})

この機能も第2引数に空配列を渡すことで、componentWillUnmount相当として扱うことが出来ます。なぜなら後処理のコールバックも最後にコンポーネントがDOMから削除されるときに一度は呼ばれるからです。関数コンポーネントがレンダリングしている間はずっと再実行されないため、最後に一度だけ実行されるコールバックと見なすことが出来るのです。

componentDidMount相当の処理が実行できれば後はuseStateで作成したsetStateをuseEffectに渡したコールバック内で外部のStoreなどに登録するだけです。

ちなみにsetStateを外に渡していいの?と思うかもしれませんが、setState関数は不変であることが保証されているので持ち出して大丈夫です。Hook本体ではないのでHooksのルールも破りません。
https://ja.reactjs.org/docs/hooks-reference.html#usestate

一つはまりやすい点としてsetStateの引数に渡す値が配列やオブジェクトの場合、中身だけ変更して次回もそのまま渡すと、配列やオブジェクトそのものは同一と判定されてしまい、更新チェックがされずレンダリングが発生しません。そのような場合は例のようにスプレッド記法などを使いshallow copyを作って渡しましょう。

Hooksを使うことでクラスコンポーネントでないと実装しづらかった様々な処理を関数コンポーネントで実現することができます。ひとつのHookでは再現しづらかった処理も複数のHookを組み合わせることで可能になります。皆さんも是非Hooksを活用してみてください。

Discussion