実践: React Hooks

hooks が発表されてから趣味でも現場でもずっと hooks を使っています。おかげでだいぶこなれてきて、だいたいなんのライフサイクルでも表現できるようになってきました。

最初は単に useState が state を、 useEffect が componentDidMount/componentDidUpdate を置き換えるもの、と説明を済ますつもりでしたが、 useEffect についてはライフサイクルのモデルがぜんぜん違うので、別の説明をする必要があるように感じていました。

で、その結果 React Hooks を理解するには、関数のメモ化を理解するのが最も簡単だと思ったので、その説明をしつつ、イディオムを解説していこうと思います。

最初に: React Hooks は何であり、何ではないか

関数コンポーネントが状態を持てるようにするもので、関数のメモ化のテクニックを多用します。

redux を置き換えるものではない。が… Redux の機能を一部吸収しています(useReducer)。後述する Context と組み合わせることで、middleware なし Redux が簡単に実現できます。

直接的には recompose や HOC といったテクニックを置き換えるものです。

メモ化されたライフサイクル

(メモ化関数についての基本的な説明をするので、わかってる人は読み飛ばしてください)

const fib = n => n < 2 ? n : fib(n - 1) + fib (n - 2);
fib(10) // 55

この fib(10) は、計算の定義自体は簡単ですが、実際には何度も同じ値への計算をすることになります。 これを効率よく計算するため、計算済みの値は保存してしまうこととしましょう。

この fib 関数をメモ化するとこんな風になります。

const memo = [0, 1]; // js の配列のインデックス外へのアクセスの雑な挙動を利用
const fib = n => {
  if (memo[n] != null) return memo[n];
  const r = fib(n - 1) + fib (n - 2);
  return memo[n] = r;
}

これで、 fib(n) が計算済みだったら、計算済みの値を返す、という挙動になります。

ちなみに、 lodash の _.memoize を使って const fib = _.memoize(n => n < 2 ? n : fib(n - 1) + fib (n - 2)); でもメモ化できます。

React Hooks でのメモ化

React Hooks を活用するには、このメモ化の仕組みを理解しておく必要があります。

React Hooks の、特に useEffectuseCallback の第二引数は、このメモ化の理解を必要とします。

function Foo({x}) {
  useEffect(() => console.log('x changed'), [x]);
  return <div>{x}</div>
}

これは Foo への props x が書き換わるごとに、useEffect が実行されます。 useEffect(fn, memoizedKeys)fn は、 memoizedKeys ごとにメモ化されている、と言えます。ただし、全ての状態をメモ化しているのではなく、直前の状態だけをメモしています。

ここではメモ化のヒントは 配列になっています。一致判定のロジック自体は配列の各要素の shallow equal、雑に言ってしまうと newKeys.every((k, i) => k === oldKeys[i]) です。

差分検知に失敗するケース

useCallback は memoizedKeys でメモ化された関数を返却します。もし、 memoizedKeys が一致する場合、新しい関数は生成されません。前のステップで生成された関数をそのまま返却します。

x+y が表示され、クリックすると、x+y を console.log する、というコンポーネントで考えてみましょう。

function Sum({x, y}) {
  const onClick = useCallback(() => console.log('x,y changed', x + y), [x]);
  return <div onClick={onClick}>{x + y}</div>
}

このとき、新しい関数が生成されるのは、 x が変更されるときだけなので、y の更新には反応しません。なので、y だけ更新した際は、表示は更新されても、新しい x と 古い y を足した値を console.log することでしょう。

正しく両方の値に反応したい場合、次のように [x, y]memoizedKeys を与える必要があるわけですね。

function Sum({x, y}) {
  const onClick = useCallback(() => console.log('x,y changed', x + y), [x, y]);
  return <div onClick={onClick}>{x + y}</div>
}

より一般化すると、「関数クロージャの中で参照する要素はすべて memoizedKeys に列挙する」というベストプラクティスになると思います。

ちょっとむずかしいですが、総じて、差分アルゴリズムに由来する React らしい、一貫した副作用の抽象化と言えるでしょう。

実行コンテキストの仕組み

見た目上は魔法のように見える Hooks の記法ですが、単に副作用が外出しされているだけです。最初は algeblaic effects が云々みたいな話がありましたが、その話は忘れてください。

中で何が起こっているかは、すごく雑な疑似コードを書くと、たぶんこんな感じだと思います。

// 実行コンテキスト
const hooksMap = {}

  // ReactDOM.render の中の差分更新のどこか
  const hooks = hooksMap[oldElement.id] || []
  const newElement = updateElement(oldElement, virtualElement, hooks);
  hooksMap[newElement.id] = newElement.hooks;

hooks に実行インスタンスを引っ掛けてコンテキストを生成しているだけです。

より詳しい実装を知りたければ、こちらの翻訳記事を参照してください

(翻訳) React Hooks は魔法ではなく、ただの配列だ

イディオム

componentDidMount/componentWillUnmount 相当

import React, { useEffect } from 'react'
function Foo() {
  useEffect(() => {
    console.log('mounted')
    return () => {
      console.log('unmount')
    }
  }, [])
  return <></>
}

state を持つ

import React, { useState } from 'react'
function Foo() {
  const [state, setState] = useState([])
  return (
    <>
      {state.map(i => <div key={i}>i</div>)}
      <button onClick={
        ()=> setState([...state, Math.random().toString()])
      }>
        add
      </button>
    </>
  )
}

ref を使って DOM を触る。(textarea にフォーカスする例) DOMへ起きた副作用を検知する場合は useEffect ではなく useLayoutEffect になります。

import React, { useState } from 'react'
function Foo() {
  const textareaRef = useRef(null);
  useLayoutEffect(() => {
    if (textareaRef.current) {
      textareaRef.current.focus()
    }
  }, [])
  return <textarea ref={textareaRef}/>
}

外部の副作用を監視して、プログレスバーを進める、みたいな例

import React, { useState, useEffect } from 'react'
function Foo() {
  const [step, setStep] = useState(0);
  useEffect(() => {
     // 何か外部の副作用
     const unsubscribe = resouce.subscribe(() => {
        setStep(step + 1);
     });
     return () => unsubscribe() 
  }, [step])
  return <></>
}

useReducer + context = redux like

useReducer は react で公式に reducer の仕組みが取り込まれたものです。これは単に、state の reducer 版イディオムでしかありません。

これに、親子の間で暗黙に値を受け渡す Context API と、useContext の hooks を使うと、次のように redux 風の flux が再現できます。

import React, { useReducer, useContext, Dispatch, ReactElement } from "react";
import ReactDOM from "react-dom";

type CounterState = {
  count: number;
};

const initialState: CounterState = { count: 0 };

function reducer(state: CounterState, action: any) {
  switch (action.type) {
    case "reset": {
      return initialState;
    }
    case "increment": {
      return { count: state.count + 1 };
    }
    case "decrement": {
      return { count: state.count - 1 };
    }
    default: {
      return state;
    }
  }
}

// Container
const CounterContext = React.createContext<CounterState>(null as any);
const DispatchContext = React.createContext<Dispatch<any>>(null as any);

function App({ initialCount }: { initialCount: number }) {
  const [state, dispatch] = useReducer(reducer, { count: initialCount });
  return (
    <CounterContext.Provider value={state}>
      <DispatchContext.Provider value={dispatch}>
        <Counter />
      </DispatchContext.Provider>
    </CounterContext.Provider>
  );
}

// Connected component
function Counter() {
  const state = useContext(CounterContext);
  const dispatch = useContext(DispatchContext);
  return (
    <div>
      Count: {state.count}
      <button onClick={() => dispatch({ type: "reset" })}>Reset</button>
      <button onClick={() => dispatch({ type: "increment" })}>+</button>
      <button onClick={() => dispatch({ type: "decrement" })}>-</button>
    </div>
  );
}

ReactDOM.render(<App initialCount={2} />, document.querySelector(".root"));

ただし、useReducer はだいぶ redux のそれと比べて、store 層が簡略化されています。middleware の仕組みは一切ありません。

reducer 定義自体は、単に (state, action) => state を守っていればいいです。 redux.combineReducers をヘルパとして使ってもいいですし、redux で使っていた reudcer をそのまま置き換えることも可能です。

とりあえず useReducer + Context で作ってみて、複雑な Middleware が必要だったら Redux を使う、という感じでいいんじゃないでしょうか。

どう適応するか

現状、表現力という点で、関数コンポーネントとクラスコンポーネントはほぼ同等です。

class MyComponent extends React.Component といったクラス記法は、おそらく推奨されなくなるのではないでしょうか。消えることはないでしょうが、推奨されない、といった雰囲気です。

React は関数型プログラミングの雰囲気が強いコアチーム、出自(facebook)、コミュニティなので、おそらく今後見かけるコードは hooks がメインになる気がします。

これは勝手な憶測ですが、React は、頭がいいであろう Facebook のエンジニアがメインターゲットなので、頭が良い人しかターゲットにしていない、というのがたぶんあって、僕はそれが好きではあるんですが、 Vue に初心者層をかっさらわれたのはそういう姿勢に問題があったんじゃないか、という気持ちもあります。

何にせよ、シンプルな一貫したアルゴリズムを理解していれば、すべてがうまくいく、という世界観で、自分はそこが気に入っています。