ひろろの思うがままに。

自由奔放。思ったことや感じたこと、そんなことを書き溜めて、レベル上げて。

React初心者がReact Hooksの機能についてまとめてみた

今回はほとんど自分用に公式のReact-Hooksを個人的にまとめた記事になってます。

※ ボリューム多め。読むのに推定15分。

解釈が違っていたり、そもそもそこ違うだろ。などあればご指摘ください。

f:id:tack13:20200610100442p:plain

公式: Reach-Hookの紹介

ちなみにreact初心者と書いていますが、小規模な開発なら1度だけ経験あります。

では、以下つらつらーっと書き殴っていきます。

はじめに

React-Hooks(以下、Hook)とは、React16.8で追加された新しい機能。

クラスを意識せずに、stateの状態やその他のHookの機能を使用可能。

Hookを利用するメリット

Reactではステートフルなロジックを再利用するのが難しく、コンポーネントを使用するときは再構築しないといけません。

そのため、抽象化のレイヤーに囲まれたコンポーネントの闇にはまってしまうことが多々。

再構築を重ねることでコードが扱いにくくなり、追跡・調査、修正などが難しくなるケースが多いです。

Hookを使用することで、コンポーネントからステートフルなロジックを抽象できるようになり、コンポーネント単体で再利用可能となります。

※ テストも単体で実行可能

また、コンポーネントの階層を変更せずに再利用できるため、多くのコンポーネント間で共有できます。

下記、Reactの挙動

  • ライフサイクルにはバグや不整合が発生しやすい落とし穴が存在する
  • 冗長化のためのクラスが混乱を生むかもしれない

だいぶ省略しましたが、Hookを利用することでこの問題を解決することができます。

そもそもHookってなに?

Hookとは関数コンポーネントReactの状態とライフサイクルを「フック」するための関数。

クラス内では機能しませんが、クラスなしでReactを使用できます。

独自のフックを作成して、異なるコンポーネント間でステートフルなロジックを再利用することもできます。

関数コンポーネントを定義し、必要なstateが増えた場合、それをクラスに変換する必要がありましたがHookでは既存の関数コンポーネント内で使用できます。

Hookと関数コンポーネント

Reactの関数コンポーネント

const Example = (props) => {
  // ここでHookを使用できる
  return <div />
}
function Example(props) {
  // ここでHookを使用できる
  return <div />
}

stateの宣言例

React

stateを0に初期化するために、コンストラクタでthisを定義しないといけません。

class Example extends React.Component {
  constructor(props) {
    super(props)
    this.state = {
      count: 0
    }
  }
}

Hook

thisを使用することなく、コンポーネント内でもuseStateを使用することで宣言できます。

import React, { useState } from 'react'

function Example() {
  const [count, setCount] = useState(0)
}

stateの呼び出し例

React

stateを表示させるために、thisを使用する必要があります。

<p>クリックした回数は {this.state.count} 回です。</p>

Hook

直接使用できます。

<p>クリックした回数は {count} 回です。</p>

stateの更新

React

更新するためにthis.setStateを呼び出す必要があります。

<button onClick={() => this.setState({ count: this.state.count + 1})}>
  クリック
</button>

Hook

thisを必要としないため、setCountのみで呼び出せます。

<button onClick={() => setCount(count + 1)}>
  クリック
</button>

おまけ

/**
 * ReactからuseStateのフックをインポートする
 *
 * これにより関数コンポーネントでstateを維持できる
 **/
import React, { useState } from 'react'

function Example() {
  /**
   * Exampleコンポーネント内でuseStateを呼び出し、新しいstateを宣言する
   *
   * 変数はペアで命名しその値を返す
   * useState(0)の「0」は変数の初期値として指定
   * 2つ目に宣言したものはそれ自体が関数となっており、
   * 「count」を更新できるようにするため「setCount」とする
   **/
  const [count, setCount] = useState(0)

  return (
    <div>
      <p>クリックした回数は {count} 回です。</p>
      <!-- 
       -- 新しいcountの値をコンポーネントに渡す
       --
       -- クリックした場合にsetCountが呼び出され、
       -- Exampleコンポーネント内を再レンダリングする
       -->
      <button onClick={() => setCount(count + 1)}>
        クリック
      </buttom>
    </div>
  )
}

もっとおまけ

角括弧ってなに?

const [count, setCount] = useState(0)

これはjavaScriptの構文で、React APIの一部ではありません。

独自の変数名に宣言することができます。

上記ではcountとsetCountの2つの変数を宣言していることを意味しており、次のコードと同様。

let countStateVariable = useState(0)
let count = countStateVariable[0]
let setCount = countStateVariable[1]

最初の項目は現在の値となり、2つ目の項目はそれを更新できる関数です。

やっと本題

State Hook

ボタンをクリックした場合、値を増加させる例

import React, { useState } from 'react'

function Example() {
  const [count, setCount] = useState(0)

  return (
    <div>
      <p>クリックした回数は {count} 回です。</p>
      <button onClick={() => setCount(count + 1)}>
        クリック
      </buttom>
    </div>
  )
}

これはコンポーネント内で呼び出し、再レンダリング間でこの状態を保持し、現在の状態の値とそれを更新する関数はペアとなっています。

この関数はイベントハンドラーなどから呼出可能で、クラスと似ていますが、前の状態と新しい状態をマージしない点が異なります。

useStateの引数が初期状態となり、上記では0から始めるために引数に0を指定。

stateは最初のレンダリング時にのみ初期化されます。

複数宣言

1つのコンポーネント内で複数のHookを使用できます。

function ExampleWithManyState() {
  const [age, setAge] = useState(42)
  const [fruit, setFruit] = useState('banana')
  const [todos, setTodos] = useState([
    {text: 'React-Hookの学習'}
  ])
  //...
}

Effect Hook

データのfetchや手動でDOMをReactコンポーネントから更新する場合、他のコンポーネントに影響を与える可能性があります。

レンダリング中には実行できないため、このような操作は「副作用」と呼ばれます。

Effect Hook(以下、Effect)はReactからuseEffectをインポートし、コンポーネントからEffectを実行する機能を追加します。

これは、ReactのcomponentDidMount、componentDidUpdate、componentWillMountのような動作をしますが、単一のAPIとして統合します。

DOMの更新後にタイトルを設定する場合

import React, { useStae, useEffect } from 'react'

function Example() {
  const [count, setCount] = useState(0)

  // componentDidMountとcomponentDidUpdateに似ている処理
  useEffect(() => {
    document.title = `クリックした回数は ${count} 回です。`
  })

  return (
    <div>
      <p>クリックした回数は {count} 回です。</p>
      <button onClick={() => setCount(count + 1)}>
        クリック
      </buttom>
    </div>
  )
}

useEffectはDOMにフラッシュした後でEffect関数を実行するようにReactに指示します。

Effectはコンポーネント内で宣言されるため、stateにアクセスでき、デフォルトではReactは全てのリンダリング完了後にEffectを実行します。

ライフサイクルとの関係はこちら

Effectはオプションを返すことによって、その後の「クリーンアップ」方法を指定することもできます。

例えば、Effectを使用し、友人のオンラインステータスのサブスクライブを設定し、解除することでクリーンアップします。

import React { useState, useEffect } from 'react'

function FriendStatus(props) {
  const [inOnline, setIsOnline] = useState(null)

  function handleStatusChange(status) {
    setIsOnline(status.isOnline)
  }

  useEffect(() => {
    ChatAPI.subscribleToFriendStatus(props.friend.id, handleStatusChange)
    return () => {
      ChatAPI.unsubscribleFromFriendStatus(props.friend.id, handleStatusChaneg)
    }
  })

  if (isOnline === null) {
    return 'Loading...'
  }
  return isOnline ? 'Online' : 'Offline'
}

ChatAPIがコンポーネントのマウントから解除されたとき、レンダリングによる再実行を行う前にサブスクライブをスキップするようにReactに指示します。

また、useStateと同様に、複数のEffectを定義できます。

function FriendStatusWithCounter(props) {
  const [count, setCount] = useState(0)
  useEffect(() => {
    document.title = `クリックした回数は ${count} 回です。`
  })

  const [inOnline, setIsOnline] = useState(null)
  useEffect(() => {
    ChatAPI.subscribleToFriendStatus(props.friend.id, handleStatusChange)
    return () => {
      ChatAPI.unsubscribleFromFriendStatus(props.friend.id, handleStatusChaneg)
    }
  })

  function handleStatusChange(status) {
    setIsOnline(status.isOnline)
  }
  // ...
}

Hookを使用することで、分割をライフサイクルメソッドに基づいて強制するのではなく、関連する部分(追加や削除)によってコンポーネントの副作用を整理できます。

Hookのルール

HookはjavaScript関数だが、2つのルールが追加されています。

  • Hookを呼び出すのはトップレベルのみ
    • ループや条件、ネストされた関数内で呼び出さない
  • React関数コンポーネントからのみHookを呼び出す
    • 通常のjavaScript関数から呼び出さない

独自のHookを作成する

独自Hookを用いることで、higer-order componentsとrender propsのようにステートフルなロジックを再利用することができます。

ツリーにコンポーネントを追加する必要はありません。

上記で紹介した、Friend StatusのuseStateとuseEffectを別のコンポーネントでも再利用したいとします。

まずは、useFriendStatusとして独自のカスタムHookに抽出します。

import React, { useState, useEffect } from 'react'

function useFriendStatus(friendID) {
  const [isOnline, setIsOnline] = useState(null)

  function handleStatusChange(status) {
    setIsOnline(status.isOnline)
  }

  useEffect(() => {
    ChatAPI.subscribleToFriendStatus(props.friend.id, handleStatusChange)
    return () => {
      ChatAPI.unsubscribleFromFriendStatus(props.friend.id, handleStatusChaneg)
    }
  })

  return isOnline
}

friendIDを引数として受け取り、友人がオンラインかどうかを返します。

これで両方のコンポーネントから使用できます。

function FriendStatus(props) {
  const isOnline = useFriendStatus(props.friend.id)

  if (isOnline === null) {
    return 'Loading...'
  }
  return isOnline ? 'Online' : 'Offline'
}
function FriendListItem(props) {
  const isOnline = useFriendStatus(props.friend.id)

  return (
    <li style={{ color: isOnline ? 'green' : 'black' }}>
      {props.friend.name}
    </li>
  )
}

これらのコンポーネントのstateは完全に独立しています。

ステートフルなロジックを再利用する方法であり、state自体を再利用する方法ではありません。

呼び出す度に完全に分離された状態になっているため、1つのコンポーネントで同じカスタムHookを複数回使用することもできます。

カスタムHookは機能というより慣例といいます。

関数名はuseで始まるのが特徴で、他のHookを呼び出す場合にカスタムHookと呼ばれます。

命名規則はuseSomethingとします。

その他のHook

あまり使用されませんが、その他にも組み込みHookがあります。

useContextを使用し、ネストを導入せずにReactコンテキストのサブスクライブを設定します。

function Example() {
  const locale = useContext(LocalContext)
  const theme = useContext(ThemeContext)
  // ...
}

useReducerを使用し、reducerを使用した複雑なコンポーネントの状態を管理できます。

function Todos() {
  const [todos, dispatch] = useReducer(todoReducer)
  // ...
}

Effect Hookの使い方

クリーンアップ効果なし

DOMを更新した後でコードを実行したい場合があります。

リクエスト、手動のDOMのmutation、loggingなどはクリーンアップを必要としない効果の一般例です。

クラスとフックでこのようなEffectを表現する方法を比較します。

クラスの使用例

Reactのクラスコンポーネントではrenderメソッド自体が効果を引き起こすことがあり、通常、DOMを更新した後に実行したいがそれだと早すぎる場合があります。

そのために、クラスではcomponentDidMountとcomponentDidUpdateを定義します。

DOMに変更を加えた直後にタイトルを更新するクラスコンポーネントの例です。

class Example extends React.Component {
  constructor(props) {
    super(props)
    this.state = {
      count: 0
    }
  }

  componentDidMount() {
    document.title = `クリックした回数は ${this.state.count} 回です。`
  }

  componentDidUpdate() {
    document.title = `クリックした回数は ${this.state.count} 回です。`
  }

  render() {
    return (
      <div>
        <p>クリックした回数は {this.state.count} 回です。</p>
        <button onClick={() => this.setState({ count: this.state.count + 1 })}>
          クリック
        </buttom>
      </div>
    )
  }
}

クラス内に2つのライフサイクルメソッドがありますが、これは描画されたばかりなのか、更新されたかに関係なく同じ処理を実行したいためとなります。

概念的には全てのレンダリング後に実行をしたいのですが、Reactクラスコンポーネントにはそのようなメソッドはありません。

別のメソッドを抽出することもできますが、それでも2箇所で呼び出す必要があります。

Hookの使用例

上記のコードをHookで表現すると下記のようになります。

import React, { useStae, useEffect } from 'react'

function Example() {
  const [count, setCount] = useState(0)

  useEffect(() => {
    document.title = `クリックした回数は ${count} 回です。`
  })

  return (
    <div>
      <p>クリックした回数は {count} 回です。</p>
      <button onClick={() => setCount(count + 1)}>
        クリック
      </buttom>
    </div>
  )
}

useEffectの役割

このHookを使用することで、レンダリング後に実行する必要があるとReactに指示します。

Reactは渡された関数を記憶し(これを「Effect」と呼ぶ)、DOMが更新された後に呼び出します。

useEffectをコンポーネント内で定義するのはなぜか

コンポーネント内に定義することで、Effectからstateのcount(または任意の値)のような変数に直接アクセスできます。

useEffectは全てのレンダリング後に実行される?

デフォルトでは最初のレンダリング時に実行し、全ての更新後にも実行されます。

ReactはDOMがEffectから実行するまでに更新されることを保証しています。

もっと詳しく説明

こちらのステップについて説明します。

function Example() {
  const [count, setCount] = useState(0)

  useEffect(() => {
    document.title = `クリックした回数は ${count} 回です。`
  })
}

stateを宣言し、Effectを使用する必要があることをReactに伝えます。

スコープ内にあるので内部で最新のものを呼び出すことができ、Reactがコンポーネントレンダリングする際に記憶しているので、DOMの更新後にも実行されます。これは最初のレンダリングも含め全てのレンダリングで発生します。

クリーンアップ効果あり

クリーンアップにもついくかの効果があり、例えば、一部のソースへのサブスクライブを設定することができます。

その場合、メモリリークが発生しないようにクリーンアップすることが重要になります。

クラスとフックでこのような効果を表現する方法を比較します。

クラスの使用例

クラスでは通常、componentDidMountでサブスクライブを設定し、componentDidWillUnmountでクリーンアップします。

class FriendStatus extends React.Component {
  constructor(props) {
    super(props)
    this.state = { isOnline: null }
    this.handleStatusChange = this.handleStatusChange.bind(this)
  }

  componentDidMount() {
    ChatAPI.subscribeToFriendStatus(
      this.props.friend.id,
      this.handleStatusChange
    )
  }
  componentWillUnmount() {
    ChatAPI.unsubscribeFromFriendStatus(
      this.props.friend.id,
      this.handleStatusChange
    )
  }
  handleStatusChange(status) {
    this.setState({
      isOnline: status.isOnline
    })
  }

  render() {
    if (this.state.isOnline === null) {
      return 'Loading...'
    }
    return this.state.isOnline ? 'Online' : 'Offline'
  }
}

componentDidMountとcomponentWillUnmountではソースが同じ場合でもロジックを分割する必要があります。

Hookの使用例

useEffectでは、サブスクライブの追加・削除するためのソースは密接に関連しており、一緒に維持するように設計されています。

Effectが関数を返す場合、Reactはクリーンアップする時にそれを実行します。

import React, { useState, useEffect } from 'react'

function FriendStatus(props) {
  const [isOnline, setIsOnline] = useState(null)

  useEffect(() => {
    function handleStatusChange(status) {
      setIsOnline(status.isOnline);
    }
    ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange)
    return function cleanup() {
      ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange)
    }
  })

  if (isOnline === null) {
    return 'Loading...'
  }
  return isOnline ? 'Online' : 'Offline'
}

Effectから効果を返すのはなぜか

これはEffectのオプションのクリーンアップによるものです。

全てのEffectはその後にクリーンアップする関数を返す場合があります。

これによりサブスクライブを追加・削除するロジックを互いに近づけることができます。

ReactはいつEffectをクリーンアップするのか

マウントが解除されると実行されます。

ただ、1度だけではなく全てのリンダリングに対して実行されます。

おまけ

useEffectはレンダリング後にさまざまな種類の表現をできます。

一部は関数を返すようにクリーンアップが必要になったり、クリーンアップフェーズがなく何も返されない場合もあります。

useEffect(() => {
  function handleStatusChange(status) {
    setIsOnline(status.isOnline)
  }

  ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange)
  return (() => {
    ChatAPI.unsubscribeFromFriendStatus(props.friend.is, handleStatusChange)
  }
  )
})

//  or

useEffect(() => {
  document.title = `クリックした回数は ${count} 回です。`
})

Effect Hookはどちらのユースケースも単一のAPIで統合します。

複数のEffectを使用して問題を分離する

クラスのライフサイクルメソッドには関連のないロジックが含まれていることが多いですが、問題は関連するロジックがいくつかのメソッドに分割されることです。

以下は、前の例のカウンターと友人のオンラインステータスのインジケーターロジックを組み合わせたものです。

class FriendStatusWithCounter extends React.Component {
  constructor(props) {
    super(props)
    this.state = { count: 0, isOnline: null }
    this.handleStatusChange = this.handleStatusChange.bind(this)
  }

  componentDidMount() {
    document.title = `クリックした回数は ${count} 回です。`
    ChatAPI.subscribeToFriendStatus(
      this.props.friend.id,
      this.handleStatusChange
    )
  }

  componentDidUpdate() {
    document.title = `クリックした回数は ${count} 回です。`
  }

  componentWillUnmount() {
    ChatAPI.unsubscribeFromFriendStatus(
      this.props.friend.id,
      this.handleStatusChange
    )
  }

  handleStatusChange(status) {
    this.setState({
      isOnline: status.isOnline
    })
  }
  // ...

document.titleを設定するロジックがcomponentDidMountとcomponentDidUpdateに分割されています。

サブスクライブもcomponentDidMountとcomponentWillUnmountに分散されており、componentDidMountには両方のロジックのためのコードが含まれています。

Hookの場合、この問題を下記のように解決できます。

同じように複数回使用できるHookの状態と効果を使用でき、これにより関連性のないロジック毎に分離できます。

function FriendStatusWithCounter(props) {
  const [count, setCount] = useState(0)
  useEffect(() => {
    document.title = `クリックした回数は ${count} 回です。`
  })

  const [isOnline, setIsOnline] = useState(null)
  useEffect(() => {
    function handleStatusChange(status) {
      setIsOnline(status.isOnline)
    }

    ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange)
    return () => {
      ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange)
    }
  })
  // ...
}

Hookを使用するとライフサイクルのメソッド名ではなく、実行内容に基づいてコードを分割できます。

Reactはコンポーネントが使用する全てのEffectを指定された順序で適用します。

更新毎にEffectが実行されるわけ

クラスの例

クラスに慣れている場合、マウントしていない状態で1度だけではなく、再レンダリングのたびにEffectのクリーンアップフェーズが発生する理由に疑問があるかもしれません。

例より、この設計がバグの少ないコンポーネントの作成に役立つ理由を見てみます。

友人のオンラインステータスの例では、クラスはthis.propsからfriend.idを読み取り、コンポーネントのマウント後に友人のオンラインステータスにサブスクライブし、アンマウント中にサブスクライブを解除します。

componentDidMount() {
  ChatAPI.subscribeToFriendStatus(
    this.props.friend.id,
    this.handleStatusChange
  )
}

componentWillUnmount() {
  ChatAPI.unsubscribeFromFriendStatus(
    this.props.friend.id,
    this.handleStatusChange
  )
}

しかし、コンポーネントが画面に表示されている間に値が変更されるとどうなるでしょうか。

コンポーネントが別の友人のオンラインステータスを引き続き表示してしまうバグが発生します。

また、unsubscribeの呼び出しで誤ったフレンドIDが使用されるため、アンマウント時にメモリリーク、もしくはクラッシュが発生します。

クラスコンポーネントでは、このケースを処理するためにcomponentDidUpdateを追加する必要があります。

componentDidMount() {
  ChatAPI.subscribeToFriendStatus(
    this.props.friend.id,
    this.handleStatusChange
  )
}

componentDidUpdate(prevProps) {
  ChatAPI.unsubscribeFromFriendStatus(
    prepProps.friend.id,
    this.handleStatusChange
  )
  ChatAPI.subscribeToFriendStatus(
    this.props.friend.id,
    this.handleStatusChange
  )
}

componentWillUnmount() {
  ChatAPI.unsubscribeFromFriendStatus(
    this.props.friend.id,
    this.handleStatusChange
  )
}

componentDidUpdateで適切に処理することを忘れてしまうことは、Reactのバグの一般的な原因です。

Hookの例

このコンポーネントをHookを使用する場合で考えます。

function FriendStatus(props) {
  useEffect(() => {
    ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange)
    return () => {
      ChatAPI.unsubscribeFromFrinedStatus(props.friend.id, handleStatusChange)
    }
  })
}

これはバグの影響は受けませんが、変更も加えていません。

useEffectはデフォルトで更新するため、更新を処理するための特別なコードはありません。

Effectを適用する前に、前のEffectをクリーンアップしコンポーネントが時間の経過に伴って生成する可能性がある一連のサブスクライブとサブスクライブの解除の呼び出しを次に示します。

// Mount with { friend: { id: 100 } } props
ChatAPI.subscribeToFriendStatus(100, handleStatusChange)     // Run first effect

// Update with { friend: { id: 200 } } props
ChatAPI.unsubscribeFromFriendStatus(100, handleStatusChange) // Clean up previous effect
ChatAPI.subscribeToFriendStatus(200, handleStatusChange)     // Run next effect

// Update with { friend: { id: 300 } } props
ChatAPI.unsubscribeFromFriendStatus(200, handleStatusChange) // Clean up previous effect
ChatAPI.subscribeToFriendStatus(300, handleStatusChange)     // Run next effect

// Unmount
ChatAPI.unsubscribeFromFriendStatus(300, handleStatusChange) // Clean up last effect

この動作により一貫性が確保され、更新ロジックがないためにクラスコンポーネントに共通するバグが防止されます。

Effectをスキップしてパフォーマンスを最適化する

レンダリングのたびにEffectの適用やクリーンアップをすると、パフォーマンスの問題が発生することがあります。

クラスコンポーネントでは、prevProps・prevStateで追加時の比較をcomponentDidUpdateに記述することでこれを解決できます。

componentDidUpdate(prevProps, prevState) {
  if (prevState.count !== this.state.count) {
    document.title = `クリックした回数は ${this.state.count} 回です。`
  }
}

これはuseEffectのフックに組み込まれるのに一般的です。

レンダリング間で特定の値が変更されていない場合は、Effectの適用をスキップするように指示できます。

オプションの2番目の引数としてuseEffectに配列を渡します。

useEffect(() => {
  document.title = `クリックした回数は ${count} 回です。`
}, [count]) // 再レンダリング時にcountの値が変更していた場合のみ実行される

上記では[count]を2番目の引数として渡しています。

countの値が5だったとして、再レンダリング時にcountの値を比較し、値が全て同じであればこれをスキップします。

countを6に更新してレンダリングすると、Reactは前のレンダーの5と6を比較し、5 !== 6となるため、Effectが実行されます。

配列に複数の値が存在する場合、そのうちのいずれかが異なっていればReactはEffectを実行します。

これはクリーンアップフェーズがあるEffectでも機能します。

useEffect(() => {
  function handleStatusChange(status) {
    setIsOnline(status.isOnline)
  }

  ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange)
  return () => {
    ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange)
  }
}, [props.friend.id]) // フレンドIDの値が変更していた場合のみサブスクライブされる

おわり

ということで、基本の動作や特徴から、副作用の効果や使用方法、ちょっとした使用例まで書いてみました。

もっと細かにhookの機能について紹介とかできたらいいな。

サブスクライブってなに?