コンテキスト+リデューサでReduxみたいなことばかりしてたら、コンテキストとリデューサ単体の使い方を忘れたので復讐する。まずはコンテキスト。

[ react@17.0.2 / react-dom@17.0.9 ]

  1. 説明
  2. サンプル
  3. 準備
  4. ソースコード
  5. 実行結果

説明

Reactの公式ドキュメントで、コンテキストの使い道のひとつとして、認証済みユーザ情報を入れておくというのが挙げられている。実際、自分もそのような用途でコンテキストを使いたくなったことがあるので、そういうテイのサンプルコードを書いてみた。

サンプル

先にこのページのサンプルコードをビルドしたものを置いておく。開いてから2秒後に内容が変化する。

以下、これの作り方。

準備

以下コマンドで必要なパッケージをインストール。

npm install react react-dom typescript webpack webpack-cli ts-loader @types/react @types/react-dom
npm install -D webpack-dev-server

この記事を書いた時の各パッケージのバージョンはこんな感じ。

package.json (抜粋)
{
  "dependencies": {
    "@types/react": "^17.0.14",
    "@types/react-dom": "^17.0.9",
    "react": "^17.0.2",
    "react-dom": "^17.0.2",
    "ts-loader": "^9.2.3",
    "typescript": "^4.3.5",
    "webpack": "^5.46.0",
    "webpack-cli": "^4.7.2"
  },
  "devDependencies": {
    "webpack-dev-server": "^3.11.2"
  }
}

ほか、各種設定ファイルはこんな感じ。適当。

tsconfig.json
{
  "include": ["src"],
  "compilerOptions": {
    "module": "amd",
    "target": "es5",
    "outDir": "dist",
    "allowJs": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "noImplicitAny": true,
    "noImplicitThis": true,
    "strictNullChecks": true,
    "forceConsistentCasingInFileNames": true,
    "noFallthroughCasesInSwitch": true,
    "downlevelIteration": true,
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "jsx": "react"
  }
}
webpack.config.js
const path = require('path');

module.exports = {
  mode: 'production',  // ※サンプルページのビルド用に production にしてあるけどローカルなら development でいい
  entry: './src/index.tsx',
  output: {
    path: path.join(__dirname, 'public'),
    filename: 'bundle.js',
  },
  module: {
    rules: [
      {
        test: /\.(tsx?|jsx?)$/,
        include: [path.resolve(__dirname, 'src')],
        use: {
          loader: 'ts-loader',
          options: {
            transpileOnly: true,
            configFile: 'tsconfig.json',
          },
        },
      },
    ],
  },
  resolve: {
    extensions: ['.ts', '.tsx', '.js', '.jsx'],
  },
  devServer: {
    contentBase: path.join(__dirname, 'public'),
    host: 'localhost',
    port: 3000,
    open: true,
    watchContentBase: true,
  },
};

ソースコード

ディレクトリ構成

サンプルコードのディレクトリ構成は以下の通り。

public/
  +-- index.html

src/
  +-- components/
  |    +-- App.tsx
  |    +-- Child.tsx
  |    +-- RenderCounter.tsx
  |    +-- UserViewer.tsx
  |
  +-- contexts/
  |    +-- UserContext.tsx
  |
  +-- index.tsx

コンテキスト

先に書いたように、ここではログイン中のユーザ情報を共有するためのコンテキストを作る。

コンテキストの値は state として保持しておき、更新は state の setter で行う。そして値と setter をセットで提供するプロバイダを <UserProvider> として作成する。

ここらへんのファイルの分け方?は色々あると思うんだけど、今のところ自分はこうやることが多い。

src/contexts/UserContext.tsx
import React, { createContext, useState } from 'react';

type UserData = {
  id: string | null;
  name: string | null;
};

type UserContextType = {
  user: UserData | null;
  setUser: (user: UserData) => void;
};

/**
 * 初期値をセットしてコンテキスト作成
 * (ここでセットした値はどこにも使われないから適当)
 */
export const UserContext = createContext<UserContextType>({
  user: null,
  setUser: (user) => {},
});

/**
 * プロバイダ (これ以下の要素で UserContext を使える)
 */
export const UserProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  // ユーザ情報の初期値
  const [user, setUser] = useState<UserData | null>(null);

  return <UserContext.Provider value={{ user, setUser }}>{children}</UserContext.Provider>;
};

それ以外のコード

何の変哲もない index.html

public/index.html
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0" />
  <meta name="landscape" content="width=device-width, initial-scale=1.0, minimum-scale=1.0" />
  <meta name="format-detection" content="telephone=no" />
  <title>サンプル</title>
</head>
<body>
  <div id="app"></div>
  <script src="./bundle.js"></script>
</body>
</html>

何の変哲もない index.tsx。まだコンテキストは出てこない。

src/index.tsx
import React from 'react';
import ReactDOM from 'react-dom';

import App from './components/App';

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById('app')
);

<App> コンポーネントでは子要素を <UserProvider> ~ </UserProvider> で挟んでおき、ここ以下で UserContext にアクセスできるようにする。

src/components/App.tsx
import React from 'react';
import { UserProvider } from '../contexts/UserContext';
import RenderCounter from './RenderCounter';
import UserViewer from './UserViewer';

const App: React.FC = (): React.ReactElement => {
  return (
    <>
      <RenderCounter label="プロバイダ外" />
      <UserProvider>
        <RenderCounter label="コンテキスト未使用" />
        <UserViewer />
      </UserProvider>
    </>
  );
};

export default App;

コンテキストにアクセスする <UserViewer> コンポーネントの中身は以下の通り。

useContext(UserContext) の戻りで UserContext が提供するユーザ情報とその setter を受け取ることができる。ここではコンテキストから受け取ったユーザ情報を表示するだけでなく、マウントから2秒後に、新しいユーザ情報をコンテキストにセットする。

src/components/UserViewer.tsx
import React, { useContext, useEffect, useMemo } from 'react';
import { UserContext } from '../contexts/UserContext';
import RenderCounter from './RenderCounter';
import Child from './Child';

const UserViewer: React.FC = (): React.ReactElement => {
  const { user, setUser } = useContext(UserContext);

  // マウント時にWebAPIで取ってくるというテイ
  useEffect(() => {
    setTimeout(() => {
      setUser({
        id: 'kiriukun',
        name: 'キリウ君',
      });
    }, 2000);
  }, []);

  // メモ化したカウンタ
  const memorizedRenderCounter = useMemo(() => <RenderCounter label="コンテキスト使用 (メモ化)" />, []);

  return (
    <div>
      {memorizedRenderCounter}
      <RenderCounter label="コンテキスト使用 (非メモ化)" />
      <Child />
      <div>id: {user === null ? '(なし)' : user.id}</div>
      <div>name: {user === null ? '(なし)' : user.name}</div>
    </div>
  );
};

export default UserViewer;

ところでここまで出てきた怪しげな <RenderCounter> コンポーネントは……

これは、コンテキストを使ったことで再renderされる範囲を調べるために用意してみた。以下の通り、renderされた回数をラベルと共に表示するだけのもの。

src/components/RenderCounter.tsx
import React, { useEffect, useRef } from 'react';

type RenderCounterProps = {
  label: string;
};

const RenderCounter: React.FC<RenderCounterProps> = ({ label }): React.ReactElement => {
  // このコンポーネントが再renderされるたびカウントアップする
  const countRef = useRef(1);

  useEffect(() => {
    countRef.current += 1;
  });

  return (
    <div>
      [{label}] {countRef.current}
    </div>
  );
};

export default RenderCounter;

最後に <UserViewer> に出てきた <Child> コンポーネント。その名の通りというか、useContext() を行うコンポーネントの子コンポーネントが再renderされるタイミングを調べるために一応用意した。

src/components/Child.tsx
import React from 'react';
import RenderCounter from './RenderCounter';

const Child: React.FC = (): React.ReactElement => {
  return (
    <div>
      <RenderCounter label="コンテキスト使用コンポーネントの子" />
    </div>
  );
};

export default Child;

実行結果

上記のサンプルコードを全部用意して以下コマンドを実行すると、ブラウザ上で動作確認できる。

npx webpack-cli serve

結果は以下の通り。

表示直後の状態
表示直後の状態
2秒後の状態
2秒後の状態

この結果から、UserContext が保持しているユーザ情報が変化したとき、useContext() を行っている <UserViewer> 以下の全てのコンポーネントが再renderされることが確認できる。ただし <UserViewer> にて useMemo() でメモ化を行った『コンテキスト使用 (メモ化)』カウンタのみは再renderされず、数値が上昇していない。

コンテキストを使うとコンポーネントの上下の繋がりによらず値を受け渡しできて便利だけど、再renderされる範囲が広くなりすぎないように気を付けないとだ。たとえば、何も考えずに全部プロバイダで囲うとかはやめたほうがいい。