React hooks + TypeScriptでコンテキストを使う (値の取得, 更新, 再renderされるタイミング)
コンテキスト+リデューサでReduxみたいなことばかりしてたら、コンテキストとリデューサ単体の使い方を忘れたので復讐する。まずはコンテキスト。
説明
Reactの公式ドキュメントで、コンテキストの使い道のひとつとして、認証済みユーザ情報を入れておくというのが挙げられている。実際、自分もそのような用途でコンテキストを使いたくなったことがあるので、そういうテイのサンプルコードを書いてみた。
サンプル
この記事のコードでできるもの。
See the Pen using-contexts-with-react-hooks-and-typescript-about-get-set-and-re-render by napoporitataso (@napoporitataso) on CodePen.
以下、作り方。
準備
以下コマンドで必要なパッケージをインストール。
npm install react react-dom typescript webpack webpack-cli ts-loader @types/react @types/react-dom
npm install -D webpack-dev-server
この記事を書いた時の各パッケージのバージョンはこんな感じ。
{
"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"
}
}
ほか、各種設定ファイルはこんな感じ。適当。
{
"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"
}
}
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>
として作成する。
ここらへんのファイルの分け方?は色々あると思うんだけど、今のところ自分はこうやることが多い。
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
。
<!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
。まだコンテキストは出てこない。
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
にアクセスできるようにする。
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秒後に、新しいユーザ情報をコンテキストにセットする。
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された回数をラベルと共に表示するだけのもの。
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されるタイミングを調べるために一応用意した。
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
結果は以下の通り。
この結果から、UserContext
が保持しているユーザ情報が変化したとき、useContext()
を行っている <UserViewer>
以下の全てのコンポーネントが再renderされることが確認できる。ただし <UserViewer>
にて useMemo()
でメモ化を行った『コンテキスト使用 (メモ化)』カウンタのみは再renderされず、数値が上昇していない。
コンテキストを使うとコンポーネントの上下の繋がりによらず値を受け渡しできて便利だけど、再renderされる範囲が広くなりすぎないように気を付けないとだ。たとえば、何も考えずに全部プロバイダで囲うとかはやめたほうがいい。