KDE BLOG

バイブス

React Context APIを理解する

ここしばらくフロントの勉強が疎かになっており、会社での立場に危機感(?)を感じたので、現実から逃げずにちゃんと勉強していくことにしました。
今回は 下記公式ドキュメントを元に Context API について理解したいと思います。

ja.reactjs.org

Context APIの使い所

コンポーネントにデータを渡したい時は props として渡すのが基本ですが、コンポーネントの階層が深くなるほど、props のバケツリレーが多くなり、コードの見通しが悪くなったり面テンスがしにくくなってしまいます。
それを回避するために Redux などの状態管理ライブラリ を使って storeを作りそこから必要なデータを props として任意のコンポーネントで受け取ることができるようにしていました。
そのような仕組みをReactの組み込みAPIでできるようにしたのが、この Context API です。

かといってContext API を使えば Redux を使わずに済む というわけでもなさそうです。
そのあたりについては下記記事が参考になります。

とはいえ簡単なアプリケーションやプロトタイプ実装には有用な機能だと思うので、簡単なサンプルを作って試してみることにします。

サンプル完成版

今回は下記のようなサンプルを作りました。 公式ドキュメントに則って、テーマ、ユーザー情報をグローバルな情報として持ち、それぞれ切り替える処理を入れています。

f:id:jinseirestart:20191027192409g:plain

コンポーネントの階層

f:id:jinseirestart:20191027194502p:plain

コード全体

import React from 'react';
import ReactDOM from 'react-dom';

// contextの作成(デフォルト値セット)
const RootContext = React.createContext({
  theme: 'light',
  user: {
    name: 'ゲスト'
  },
  toggleTheme: () => {},
  setUser: () => {}
});

class App extends React.Component {
  constructor(props) {
    super(props);
    // bind
    this.toggleTheme = this.toggleTheme.bind(this);
    this.setUser = this.setUser.bind(this);
    // state初期設定
    this.state = {
      theme: 'light',
      user: {
        name: 'ゲスト'
      },
      toggleTheme: this.toggleTheme,
      setUser: this.setUser
    };
  }

  /**
   * テーマ切り替え処理
   */
  toggleTheme() {
    this.setState(state => ({
      theme: state.theme === 'light'
        ? 'dark'
        : 'light'
    }));
  }

  /**
   * ユーザー情報設定処理
   * @param {object}} userData 
   */
  setUser(userData) {
    this.setState({
      user: userData
    });
  }

  render() {
    return (
      // Provider に値セット
      <RootContext.Provider value={this.state}>
        <Layout />
      </RootContext.Provider>
    );
  }
}

function Layout() {
  return (
    <RootContext.Consumer>
      {/* Provider から受け取ったテーマをもとにスタイリング */}
      {({theme}) => (
        <div style={{
          display: 'flex',
          flexDirection: 'row-reverse',
          justifyContent: 'flex-end',
          backgroundColor: theme === 'light'
            ? "#fff"
            : '#222',
          color: theme === 'light'
            ? "#222"
            : '#fff'
        }}>
          <Main />
          <Sidebar />
        </div>
      )}
    </RootContext.Consumer>
  );
}

function Main() {
  return (
    <main>
      <h2>メインコンテンツ</h2>
      <div>
        <ThemeToggleButton />
        <UserForm />
      </div>
    </main>
  );
}

function ThemeToggleButton() {
  return (
    <RootContext.Consumer>
      {/* Provider から受け取った関数をリスナ登録 */}
      {({toggleTheme}) => <button onClick={toggleTheme}>テーマチェンジ</button>}
    </RootContext.Consumer>
  );
}

function UserForm() {
  const handleSubmit = (setUser, e) => {
    e.preventDefault();
    setUser({
      name: e.target.name.value
    });
  }
  
  return (
    <RootContext.Consumer>
      {/* Provider から受け取った関数をリスナ登録 */}
      {({setUser}) => (
        <form onSubmit={handleSubmit.bind(this, setUser)}>
          <div>氏名:<input type="text" name="name" required/></div>
          <div><input type="submit" value="送信" /></div>
        </form>
      )}
    </RootContext.Consumer>
  )
}

function Sidebar() {
  return (
    <RootContext.Consumer>
      {({user}) => (
        <aside style={{
          paddingRight: 30, 
          marginRight: 30,
          borderRight: '1px solid #ccc' 
        }}>
          <h2>サイドバー</h2>
          <p>
            {/* Provider から受け取ったuser情報をもとに表示 */}
            こんにちは {user.name} さん!
          </p>
        </aside>
      )}
    </RootContext.Consumer>
  )
}

ReactDOM.render(<App />, document.getElementById('root'));

コンテキストの作成

まず、コンテキストオブジェクトを作成します。

// contextの作成(デフォルト値セット)
const RootContext = React.createContext({
  theme: 'light',
  user: {
    name: 'ゲスト'
  },
  toggleTheme: () => {},
  setUser: () => {}
});

こちらでは React.createContextメソッドの引数に デフォルト値を設定していますが、特になくても問題はありません。

defaultValue 引数は、コンポーネントがツリー内の上位に一致するプロバイダを持っていない場合のみ使用されます。これは、ラップしない単独でのコンポーネントのテストにて役に立ちます。補足: undefined をプロバイダの値として渡しても、コンシューマコンポーネントが defaultValue を使用することはありません。

https://ja.reactjs.org/docs/context.html#reactcreatecontext より

たとえば、App.render() 内の provider の記述を削除してみます。

-     <RootContext.Provider value={this.state}>
         <Layout />
-     </RootContext.Provider>

これで再読み込みしてもアプリケーションの表示は問題ありません。デフォルト値が設定されているからです。テーマチェンジ等は空の関数が渡されているのでボタンをクリックしても何も起きません。
この状態でデフォルト値を削除してしまうともちろんエラーとなります。

Provider コンポーネントでラップする

次に Provider コンポーネントコンポーネントをラップします。
これによりラップされた子孫コンポーネント(正確にはコンシューマコンポーネント)が Provider の value に渡された値をいつでも参照できるようになります。

class App extends React.Component {
  // 略

  render() {
    return (
      // Provider に値セット
      <RootContext.Provider value={this.state}>
        <Layout />
      </RootContext.Provider>
    );
  }
value に直接オブエジェクトを渡すと問題がある

Provider の子孫のコンシューマコンポーネントは、 value の値が変更するたびに再レンダーされます(souldComponentUpdate メソッドの影響を受けずに更新される)。
value に渡された値は Object.is と同じアルゴリズムで前後の値が比較されるため、オブジェクトを直接 value に渡すと毎回再レンダーされる問題があります。

console.log(Object.is({a:1}, {a:1})); // false

const a = { a: 1 };
console.log(Object.is(a, a)); // true
console.log(Object.is(a, {a:1})); // false

このような背景から、value には this.state を渡しています。

Provider の value は上書きできる

公式ドキュメントでは下記のような説明があります。

プロバイダは値を上書きするために、ツリー内のより深い位置でネストできます。

これを使って、ユーザー氏名を登録した値を大文字に変換して上書きしてみます。

function Layout() {
  return (
    <RootContext.Consumer>
      {/* Provider から受け取ったテーマをもとにスタイリング */}
-      {({theme}) => (
+      {({theme, user}) => (
        <div style={{
          // 略
        }}>
          <Main />
-          <Sidebar />
+          <RootContext.Provider value={{ user: { name: user.name.toUpperCase()}}}>
+            <Sidebar />
+          </RootContext.Provider>
        </div>
      )}
    </RootContext.Consumer>
  );
}

直近の Provider コンポーネントvalue を見るので、Sideber コンポーネントはこの大文字に変換された user.name を表示します。

f:id:jinseirestart:20191027203557p:plain

Consumer コンポーネントで Provider の value を参照する

Consumer コンポーネントとは Provider コンポーネントの子孫コンポーネントにあたり、コンテキストの変更を購読できます。
Consumer コンポーネントでは、value をもとに React.Node を返す関数を記述します。

<MyContext.Consumer>
  {value => /* コンテクストの値に基づいて何かをレンダーします */}
</MyContext.Consumer>

このコンポーネントを使うことで関数コンポーネントでコンテキストを購読することができます
逆にこのコンポーネントを使わない場合は、クラスコンポーネントにして、Class.contextType を使う必要があります。

- function Sidebar() {
+ class Sidebar extends React.Component {
+   static contextType = RootContext;
+   render() {
    return (
-     <RootContext.Consumer>
-       {({user}) => (
        <aside style={{
          // 略
        }}>
          <h2>サイドバー</h2>
          <p>
            {/* Provider から受け取ったuser情報をもとに表示 */}
-            こんにちは {user.name} さん!
+            こんにちは {this.context.user.name} さん!
          </p>
        </aside>
-      )}
-    </RootContext.Consumer>
-  )
+     );
+  }
}

クラスコンポーネント内でも、Consumer コンポーネントは使えます。
そのためコンテキストを使いたいから、関数 or クラスコンポーネントにする、という考えは不要です。

Class.contextType は どんなライフサイクルメソッド内でも使えます。詳細は下記参照。 https://ja.reactjs.org/docs/context.html#classcontexttype

Context.displayName を使って見やすくできる

サンプルでは使っていませんが、作成したコンテキストオブジェクトのdisplayNameプロパティに任意の文字列をセットすると、React DevTools でその文字列が表示され見やすくなります。

RootContext.displayName = 'RootContext';

f:id:jinseirestart:20191027210928p:plain

コンテキストは複数作成することも可能なので、複数作成するときは displayName を設定しておいたほうがデバッグがしやすそうです。

まとめ、感想

実際に使ってみると、思っていたよりも簡単に使うことができて素直に良いなと感じました。
シンプルな分たくさん Provider が乱立すると大変なことになりそうなので、そのあたりは注意が必要そうです。
すでに redux を取り入れている場合や併用する場合はきちんとそれぞれの役割や責任を明確化しておくことが必要かもしれません。

Hooks と組み合わせるとよりスケールアップできるとのことなので、そのあたりはまた Hooks を学ぶ際に試したいと思います。