React Hooks: useReducer & Context API with TypeScript

最近React Hooksに入門してみていて、reduxを意識したuseReducerというAPIと
Context APIが気になったので素振りしてみました。

あとFunctionalに書けるHooksとTypeScriptは相性が良いのではないかと思ったので
すべてTypeScriptで書いていきます。

canvas animation framework

demo

素振りするにあたりお題があったほうが良いので、今回はcanvasでanimation framework的なものを作ることにしました。

※ 動いているアニメーションはそのframeworkに乱数と円軌道を使った描画関数を適当にのせたものです。

仕組み

menuでrequestAnimationFrameを使用してアニメーションを開始、
Context APIを利用してframe等のデータをcanvasに渡してアニメーションを描画します。

データはuseReducerとContext APIを使って管理、コンポーネントは
HooksのFunctional Componentで実装しています。

storeの実装

useReducerとContext APIを使ったデータ層の仕組みをstoreと名付けて実装してみました。

内部は大まかにuseReducerに使うReducer部分と、それらを束ねて提供するContext APIのProviderに分かれています。

Reducer

useReducerの第一引数にはreducerを渡します、reducerの型は以下の通りです。

type Reducer<S, A> = (prevState: S, action: A) => S;

この型に合わせたreducerを作成します。型定義はReactのDefinitelyTypedを
見るとわかりますので定義ジャンプ機能で逐一確認すると良いです。

interface IStore {
  startTime: number;
  frame: number;
  fps: number;
  spendTime: number;
  ctx?: CanvasRenderingContext2D;
}

const initialStore = {
  frame: 0,
  fps: 1,
  spendTime: 0,
  startTime: 0,
  ctx: null,
};

interface IAction {
  payload: IStore;
  error?: boolean;
}

const reducer: React.Reducer<IStore, IAction> = (state, action) => {
  return { ...action.payload };
};

見ての通りreducerはActionに入ってきたpayloadを素通しする手抜き実装です、
ActionTypeを定義してswitchする実装が正攻法です。Enumを使った実装パターンがあるのでそちらも以下に紹介します。

enum ActionType {
  UPDATE_CANVAS_CTX = "UPDATE_CANVAS_CTX",
  UPDATE_FRAME = "UPDATE_FRAME",
}

interface IAction {
  type: ActionType;
  payload: IStore;
}

const reducer: React.Reducer<IStore, IAction> = (state, action) => {
  switch (action.type) {
    case ActionType.UPDATE_CANVAS_CTX:
      return {
        ...state,
        ctx: action.payload.ctx,
      };
    case ActionType.UPDATE_FRAME:
      return {
        ...state,
        frame: action.payload.frame,
      };
    default:
      throw new Error();
  }
};

Provider HOC

Context APIのProviderをHOCとして実装するパターンが存在します。
これによってProviderの利用側がvalueを生成しないでよくなるので責務がきれいに分離されます。

TypeScriptで書く場合は型を指定する必要がります、今回はuseReducerの戻り値をまとめたStoreWithActionという
型を作成してContextに入れています。

interface StoreWithAction {
  state: IStore;
  dispatch: React.Dispatch<IAction>;
}
const Store = React.createContext<StoreWithAction>({
  state: initialStore,
  dispatch: () => {},
});

const StoreProvider: React.FC<React.Props<{}>> = props => {
  const [state, dispatch] = React.useReducer(reducer, initialStore);
  return (
    <Store.Provider value={{ state: state, dispatch: dispatch }}>
      {props.children}
    </Store.Provider>
  );
};

Context APIはHooksのAPIと組み合わせると大変有用です、
Contextを使用するコンポーネントの全てでリアクティブにデータを取得・更新することができるようになります。

メモ: Contextに入れるdispatchの初期値は空の関数を使うことでTypeScriptのエラーを回避できました、
型が一致していないように思うので少し不安ですがProviderとして使われる際には必ずdispatch関数が入るので問題ないと思います。

Componentの実装

アプリケーションのルートコンポーネントにあたるApp.tsxは以下のようになっています。

import * as React from "react";
import Canvas from "./canvas";
import Menu from "./menu";
import { StoreProvider } from "./store";

const App = () => {
  return (
    <StoreProvider>
      <Canvas />
      <Menu />
    </StoreProvider>
  );
};

export default App;

これでStoreProviderの子コンポーネントはContext APIを使ってstoreのデータにアクセスすることができます、非常にシンプル。

MenuコンポーネントではrequestAnimationFrameを発火してframeを+1していきます、
開始時間を記録しておくことでfpsも測定します(requestAnimationFrameは60fpsで動作しようとします)。

const Menu = () => {
  const { state, dispatch } = React.useContext(Store);

  const [requestID, setRequestID] = React.useState(0);

  const animation = () => {
    const now = new Date().getTime();
    const spendTime = (now - state.startTime) / 1000;
    state.frame += 1;
    state.fps = state.frame / spendTime;
    state.spendTime = spendTime;
    dispatch({ payload: state });
    const requestID = window.requestAnimationFrame(animation);
    setRequestID(requestID);
  };

  const start = () => {
    state.start = true;
    state.startTime = new Date().getTime();
    dispatch({ payload: state });
    const requestID = window.requestAnimationFrame(animation);
    setRequestID(requestID);
  };

  const stop = () => {
    window.cancelAnimationFrame(requestID);
  };

  return (
    <div>
      <div>
        <h3>Menu</h3>
        <button onClick={() => start()}>start</button>
        <button onClick={() => stop()}>stop</button>
        <p>TIME: {float2str(state.spendTime, 0)} sec</p>
        <p>{float2str(state.fps)} FPS</p>
      </div>
    </div>
  );
};

Canvas

Menuで更新されるframeを監視してアニメーションをします。
状態の更新を監視するにはuseEffectを使います。

const Canvas = () => {
  const { state, dispatch } = React.useContext(Store);

  const canvasRef = React.useRef<HTMLCanvasElement>(null);
  React.useEffect(() => {
    if (canvasRef.current) {
      state.ctx = canvasRef.current.getContext("2d");
      dispatch({ payload: state });
    }
  }, [canvasRef]);

  React.useEffect(() => {
    draw(state);
  }, [state.frame]);

  return (
    <div>
      <canvas
        ref={canvasRef}
        width={state.canvasWidth}
        height={state.canvasHeight}
      />
    </div>
  );
};

draw関数がcanvas描画の本体です。ここにframeによって変化するような描画関数を
仕込めばアニメーションが完成します。最初にあげたgifの例は以下のようなコードです。

const drawParticles = (state: IStore) => {
  const ctx = state.ctx!;
  state.ctx.clearRect(0, 0, 600, 400);
  state.particles.map(particle => {
    const vframe = state.frame * 10; // velocity = 10 px/sec
    ctx.beginPath();
    particle.x +=
      particle.r * Math.cos((2 * vframe * Math.PI) / 180 + particle.arg);
    particle.y +=
      particle.r * Math.sin((vframe * Math.PI) / 180 + particle.arg);
    const rad =
      particle.size *
      Math.abs(Math.cos((5 * vframe * Math.PI) / 180 + particle.size_arg));
    const startAng = 0;
    const endAng = Math.PI * 2;
    ctx.arc(particle.x, particle.y, rad, startAng, endAng);
    ctx.fill();
  });
};

Canvasコンポーネントのコードを見ると、useRefでCanvasのrefを取得しています。
HooksからCanvasを扱う場合はこの処理が必要になります。

refから取得できるctxをstoreに入れるとdraw関数がctxを参照できて、
draw関数とCanvasコンポーネントの依存が分離されるので便利です。

まとめと感想

  • React単体でここまでreduxライクに書けるのはすごい
  • HooksのAPIはシンプルで書きやすい
  • APIはシンプルで柔軟に設計されているので実装者の設計力が試されている気がする
  • HooksとTypeScriptの相性は良さそう
  • ライブラリの型定義は参考になるのでgoto definitionしよう
  • TypeScriptの型推論強い

Context APIの使いどころ

あまり深く考えずにバケツリレーを回避する目的でContext APIを使う発想は微妙で
本当に広くアクセスする必要のあるデータが必要なのか考えたほうが良いと思いました。

グローバルに使えるデータは便利ですが、その分コードの見通しを悪くする可能性があります。
useReducerのみで事足りるケースかどうか見極めると良いと考えます。

今回のようにコンポーネント間での役割分担を明確にする名目で使うパターンはありかもしれないと感じました、
他に用途があるとするとrouterを超えて共有する必要のあるデータ(Login情報やSession情報など)は
Contextに入れると良さそうな感じがします。

Reduxもそうですが、銀の弾丸ではないのでしっかりと設計を考えて便利さやかっこよさに
囚われてオーバーな技術選定にならないようにしていきたさみをあらためて感じました。

若干ポエムってしまいましたが、React単体でここまでできるのは肥大化しがちなbundleファイルを小さく保つ上でも良さを
感じたので使っていこうと思っています、あとTypeScriptは一度使うとJSに戻るのをためらいたくなる効果がまじである...すごい...

参考URL

Show comments

Adsense

Share

  • このエントリーをはてなブックマークに追加

About

どこにでもいる平凡なプログラムを書く人間のログ。

Recently

Tags

Pages

-->