React Hooks での状態管理と副作用の表現

React Hooks は Stateless Functional Component でも setState 的な状態操作や componentDidMount のような操作を可能にするための仕様提案です。

既に開発ブランチに入っていますが、 現時点で公式に採用されたものではないです。リリース時にはAPIが変わる可能性があります。

React のメイン開発者の一人である sebmarkbage の出してる RFC https://github.com/reactjs/rfcs/pull/68

試してみる

react@16.7.0-alpha.0 に既に実装されており、公式のブログでも解説が出ています。

自分は以下のように動作確認をしました。

yarn add react@16.7.0-alpha.0 react-dom@16.7.0-alpha.0 -D
import React from "react";
import ReactDOM from "react-dom";

const useState: <T>(
  t: T
) => [T, (prev: T | ((t: T) => T)) => void] = (React as any).useState;

const useEffect: (f: () => void) => void = (React as any).useEffect;

function Example() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    const tid = setInterval(() => {
      setCount(s => s + 1);
    }, 1000);
    return () => {
      clearInterval(tid);
    };
  });
  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
    </div>
  );
}

ReactDOM.render(<Example />, document.querySelector("#root"));

TypeScript で書いていたので、無理矢理、勉強がてら型をつけていて、その定義がこう。

const useState: <T>(
  t: T
) => [T, (prev: T | ((t: T) => T)) => void] = (React as any).useState;

const useEffect: (f: () => void) => void = (React as any).useEffect;

setState

単純な更新部分から見ていきましょう。

function Example() {
  const [count, setCount] = useState(0);
  // ...
  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
    </div>
  );
}

ボタンをクリックするとカウントが一つ増えます。

挙動を見る限り、setCount が実行されると、この SFC が再実行されています。

内部的な話ですが、今までだと HoC で関数をラップして状態管理を外出しし、その setState としてラップされた SFC を再実行していたのが、HoC の助けを足りずに状態を持てるようになっています。これはライブラリ本体に手を入れないと実現できない挙動です。おそらく、実行コンテキストで登録されたリスナーとインスタンスの関係を結びつけているんでしょう。

uesEffect

useEffect は render とは関係ない副作用を記述するための機能です。

宣言的に開始処理、終了処理が書けます。

例1

setInterval 用のタイマーを登録する例

function Example() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    console.log("start timer");
    const tid = setInterval(() => {
      setCount(s => s + 1);
    }, 5000);
    return () => {
      console.log("stop timer");
      clearInterval(tid);
    };
  });
  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
    </div>
  );
}

このコードでは、5秒に1回、値をインクリメントします。

これで気をつけるべきは setCount を呼ぶことで、useEffect の終了処理が呼ばれ、再実行されることでタイマー登録処理が再登録されます。

なので、この Click Me をクリックすると、setCount が呼ばれ、タイマーがリセットされます。なんでもいいから最後に更新されてから5秒後にインクリメント、が再定義されているわけです。

ここまで書いて気付いたんですが、一回実行されるたびに setiInterval は必ず、 clearInterval され、かつ再登録されるので、setTimeout でも全く同じ挙動になりますね。

  useEffect(() => {
    const tid = setTimeout(() => {
      setCount(s => s + 1);
    }, 2000);
    return () => {
      clearTimeout(tid);
    };
  });

この、「最後に実行されてからの effect だけ意識する」というのは、ちゃんと使う限りは副作用を起こすコードで意識すべきスコープを狭く出来て、とても良さそう。

例2

カウンタが奇数のときだけ useEffect を持つ Foo を表示してみます。

function Foo() {
  useEffect(() => {
    console.log("foo start");
    return () => {
      console.log("foo end");
    };
  });
  return <div>foo</div>;
}

function Example() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
      {count % 2 === 1 && <Foo />}
    </div>
  );
}

ボタンを何度かクリックしてみたときの Console

foo start
foo end
foo start
foo end
foo start
foo end

再更新だけではなく、外からアンマウントされる際のデストラクタでもある、という感じですね。 componentWillUnmount のように使うことができるという感じ。

useReducer

setState の reducer 版

型はこんな感じ

const useReducer: <T, A>(
  reducer: (s: T, a: A) => T,
  t: T
) => [T, (action: A) => void] = (React as any).useReducer;

コード

type State = {
  count: number;
};

type Action =
  | {
      type: "reset";
    }
  | {
      type: "increment";
    }
  | {
      type: "decrement";
    };

const initialState: State = { count: 0 };

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

function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState);
  return (
    <>
      Count: {state.count}
      <button onClick={() => dispatch({ type: "reset" })}>Reset</button>
      <button onClick={() => dispatch({ type: "increment" })}>+</button>
      <button onClick={() => dispatch({ type: "decrement" })}>-</button>
    </>
  );
}

ReactDOM.render(<Counter />, document.querySelector("#root"));

第三引数を渡すと、それを元に初期化する。

  const [state, dispatch] = useReducer(
    reducer,
    initialState,
    {type: 'reset', payload: initialCount},
  );

これは reducer の function reducer(state = initialState, action) {...} の initialState の部分を外出しできるって感じっぽいですね。

全体的に、initialState を受け付けるが、 state 自体は外部にメモ化されていて状態を持ってる、って感じですかね。個人的には常に initialState を受け付けてるように見えて、あまり直感的ではないような気も…。

他のヘルパ

  • useMemo
  • useCallback
  • useRef
  • useImperativeMethods
  • useMutationEffect

あとでちゃんと勉強する

感想

ライブラリでは表現できない、React 本体だから実装できる感じの API だと感じます。 class extensds React.Component の API あんまりつかってほしくなくて、SFC で全部が表現できるようにしようってのを感じますね。React コアチームは HOC と redux 嫌いそう。

記述の自由度、奔放さが上がる代償に、行儀が悪いコードも沢山かけてしまうよなーという印象もあります。 メモリリークなく useEffect を正しく使うには RAII ちゃんとやるみたいなのを徹底しないといけないので。

useEffect は宣言的な React から抜け道を用意する感があって、ちょっと怖いですね。

Issue 見る限りは、 hot reload どうすんの?みたいな質問とかがあって、確かに既存のは動かなさそう。