【TypeScript】 React Context API を使って設計・開発してみる

React v16.3で元々ExperimentalなAPIとして存在していたContext APIが公式なAPIとして刷新されました。

Version 16.3 introduces a new context API that is more efficient and supports both static type checking and deep updates.

🍊 Context APIの背景

ReduxMobXも自前のFlux設計も使わない、プリミティブなReactアプリでは基本的にPropsは親コンポーネントから子コンポーネント...孫へとバケツリレーでもって、伝播されていきますが、この方法で渡していくのはViewの階層が深くなった時に継続するのが非常に面倒でもあります。そこでContext APIを使うことで公式的なやり方で上記のPropsのバケツリレーを回避して、コンポーネント間でデータを共有することができるというのが主な使い方になります。例えば、「ユーザーがログインしているかの状態」、「ユーザーの言語設定」、「テーマ設定」など多くのコンポーネントで共有されるデータはContext APIを使えば、逐一データをバケツリレーで渡さずに共有することができます。

🍒 Context APIの要素

幾つかありますがContext APIの主要な要素は下記2つです。

いずれもReact.createContext()を呼び出した時に生成されます。

Provider

Consumer

👋 Provider

ざっくり言ってしまうと、 Context のデータ変更を Consumer を介して購読させることができるReactコンポーネントです。この Provider で囲まれたReactコンポーネントはContextのデータ変更があるたびに Consumer を介してその変更されたデータを受け取ることができます。1つのProviderは1つ以上のConsumerに対応します。Providerの配下にある Consumer は対応する Provider の持つデータが変更された段階で再レンダリングされます。

    <MyContext.Provider value={/* some value */}>

🙏 Consumer

<MyContext.Consumer>
  {value => /* render something based on the context value */}
</MyContext.Consumer>

Contextの変更を購読するReactコンポーネントです。上記では value が購読しているContextの値になります。 関数の形をとっており、この関数は必ずReact Nodeを返す必要があります。 Provider が保持する Context を購読することを可能にします。

🦋 Context APIを使って設計・開発してみる

個人や仕事ではTypeScriptを使っていて、Redux周りのライブラリ群と型システムとの親和性で少し苦労をしていて、何か個人や小さいプロジェクトで使用できる他の代替はないかとここ最近、いくつか候補を探っていました。

TypeScript with Reduxだと型システムとの親和性のところで少し工夫をしないといけないのと、小さなプロジェクトや個人単位で使うにはは少し重装備かな (巨大で複雑なSPAをチーム開発する時向き) という気がしていて、今回はTypeScript + React + 上記で紹介した Context APIで独自の設計を試してみようと思います。

🌴 作成したContext APIのサンプル

サンプルは例のごとく、カウンターアプリです。

🥚 Create React AppでTypeScript製Reactアプリのひな形を作成する

まずはCreate React Appでアプリのひな形をTypeScriptで作ります。

# TypeScriptのReactアプリのひな形を作成

npx create-react-app example-app --scripts-version=react-scripts-ts

# ディレクトリ移動
cd example-app

# 起動することを確認。
yarn start

🍞 設計の基礎となるファイルを用意する

🍯 Store

src/lib/store.ts を作成します。このStoreはContext APIのProvider内部で購読するStoreです。

/* src/lib/store.ts*/

export class Store<T> {
  private state: T;
  private callbacks: Array<(val: T) => void> = [];

  constructor(state: T) {
    this.state = state;
  }

  public subscribe = (callback: (val: T) => void) => {
    this.callbacks.push(callback);
  };

  public unsubscribe = (callback: (val: T) => void) => {
    this.callbacks = this.callbacks.filter(cb => cb !== callback);
  };

  public getState = () => this.state;

  public reduce = (state: T) => {
    this.state = state;
    this.notify(this.state);
  };

  /* Private */

  private notify = (val: T) => {
    this.callbacks.forEach(cb => {
      cb(val);
    });
  };
}

🍙 Context

src/lib/context.ts を作成します。

buildContext() というメソッドをexportしてファイルの外で使えるようにします。この buildContext() では指定された値と型を持つ Contextを作成 します。また、genericsを使って型を指定することができ、例えば、buildContext<{count: number}>({count: 123}) というように指定して、Contextを作成すると、 countに例えばstringの値を入れてProviderの値の更新を行おうとした時にコンパイルエラーが発生します。

buildContext() を呼び出した時には下記の4つの値が返ってきます。

  • 1: Provider。Providerを祖先に持つReactコンポーネントは下記の withCounsmer() というメソッドを経由することでConsumerを経由しての値の購読が可能となります。
  • 2: withCounsumer() というContext APIのConsumerでコンポーネントをラッピングするメソッドです。
  • 3: getState 現在Providerにセットされている値を取得します。
  • 4: reduce Providerにセットする値を更新するメソッドです。

上記で定義した store.ts を利用してContext APIを隠蔽するための仕組みを作成します。


/* src/lib/context.ts */

import * as React from "react";
import { Store } from "src/lib/store";

/* このファイルでエクスポートする唯一の関数 */
export function buildContext<T>(initialVal: T) {

  /* 内部でStoreを隠蔽する */
  const store = new Store<T>(initialVal);

    /* Contextを内部で隠蔽する */
  const Context = React.createContext(initialVal);

  return {
    withConsumer: <OuterProps extends {}>(
      WrappedComponent: React.ComponentType<OuterProps>
    ) => {
      return class Consumer extends React.Component<any, {}> {
        public render() {
          return (
            <Context.Consumer>
              {context => <WrappedComponent {...this.props} {...context} />}
            </Context.Consumer>
          );
        }
      };
    },

    // tslint:disable-next-line:max-classes-per-file
    Provider: class Provider extends React.Component<{}, T> {
      constructor(props: {}) {
        super(props);
        this.state = initialVal;
      }

      public componentWillMount() {
        store.subscribe(this.subscribeCounter);
      }

      /* end the subscription when the container is unmounted. */
      public componentWillUnmount() {
        store.unsubscribe(this.subscribeCounter);
      }

      public render() {
        return (
          <Context.Provider value={this.state}>
            {this.props.children}
          </Context.Provider>
        );
      }

      private subscribeCounter = (val: T) => {
        this.setState(val);
      };
    },
    getState: store.getState,
    reduce: store.reduce
  };
}

🎈 カウンターをつくる

上記ファイルで作った buildContext() 関数を使用して、カウンターアプリを作ってみます。一ファイルのみで仕上げています。


import * as React from "react";

import { buildContext } from "src/lib/context";

interface IFirstCounterState {
  count1: number;
}

/* 
    buildContext()を使ってContextを生成します。この関数を呼び出すと下記の4つの戻り値を持つオブジェクトが返ります。
    1: Provider           : このProviderの子孫コンポーネントでConsumerによるデータの購読を可能にするReactコンポーネント。
    2: getState           : メソッド。Providerにセットされている値を取得することができます。
    3: reduce             : メソッド。Providerにセットする値を更新することができます。
    4: withConsumer : メソッド。HoC形式でConsumerと接続したReactコンポーネントを返します。
 */
const FirstCounterContext = buildContext<IFirstCounterState>({ count1: 1 });

interface ISecondCounterState {
  count2: number;
}

/* 
2つめのContextを作成。上記一つ目のContextとは独立した値が保持されます。
初期値は {count1: 100}
*/
const SecondCounterContext = buildContext<ISecondCounterState>({ count2: 100 });

/* 
SecondBaseComponentを定義します。
機能としてはFirstCounterContextのcount2というデータを表示するとともに
それぞれのProviderが提供する値をreduce()メソッドを呼び出すことによって
更新する機能を持っています。
SecondCounterContextのデータがPropsとして流されてきます。
対応するProviderの持つデータが更新された時に再レンダリングされます。
*/ 
const SecondBaseComponent = (props: ISecondCounterState) => (
  <div>
    <h2>Second: {props.count2}</h2>
    <button
      // tslint:disable-next-line:jsx-no-lambda
      onClick={() => {
        FirstCounterContext.reduce({
          count1: (FirstCounterContext.getState().count1 += 1)
        });
      }}
    >
      Increment First
    </button>
    <button
      // tslint:disable-next-line:jsx-no-lambda
      onClick={() => {
        SecondCounterContext.reduce({
          count2: (SecondCounterContext.getState().count2 += 1)
        });
      }}
    >
      Increment Second
    </button>
  </div>
);


/* 
withConsumer() というReact Context APIのConsumerを結合する関数を使用して
SecondBaseComponentをSecondCounterContextのConsumerとつなぎます。
SecondCounterContextのProviderにセットされた値を購読することが可能となり
Providerの値が更新された時にSecondBaseComponentは再レンダリングされます。
*/ 
const ConnectedWithCounter2 = SecondCounterContext.withConsumer(
  SecondBaseComponent
);

/* 型定義: 親から渡すappNameというPropsとConsumer経由で流れてくる値のPropsを定義*/
interface IFirstCounterConnected extends IFirstCounterState {
  appName: string;
}

/* 
FirstBaseComponentを定義します。
機能としてはFirstCounterContextのcount1というデータを表示するだけです。
この直後のコードのwithConsumer()で内部的にConsumerと接続していることによって
FirstCounterContextのデータがPropsとして流されてきます。
対応するProviderの持つデータが更新された時に再レンダリングされます。
*/
const FirstBaseComponent = (props: IFirstCounterConnected) => (
  <div>
    <h1>{props.appName}</h1>
    <h2>First: {props.count1}</h2>
    <ConnectedWithCounter2 />
  </div>
);

/* 
withConsumer() というReact Context APIのConsumerを結合する関数を使用して
FirstCounterContextのConsumerとFirstBaseComponentとをつなぎます。
FirstCounterContextのProviderにセットされた値を購読することが可能となり
Providerの値が更新された時にFirstBaseComponentは再レンダリングされます。
*/
const ConnectedWithCounter1 = FirstCounterContext.withConsumer(
  FirstBaseComponent
);

/* 
Providerで囲うことによって
子孫コンポーネント群がConsumerを経由して
Providerにセットされた値を購読することを可能にします。 
*/
const App = () => (
  <FirstCounterContext.Provider>
    <SecondCounterContext.Provider>
      <ConnectedWithCounter1 appName={"Simple Counter App"} />
    </SecondCounterContext.Provider>
  </FirstCounterContext.Provider>
);

ReactDOM.render(<App />, document.getElementById("root") as HTMLElement);

上記でContext APIを隠蔽しつつ、データを毎回Propsとして渡さずに直接, Consumerを挟んで購読することが可能になります。

Github

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