サイトトップ

Director Flash 書籍 業務内容 プロフィール

HTML5テクニカルノート

Create React App フックによる状態管理 04: カスタムフックにUnstated Nextを組み合わせる


Create React App フックによる状態管理 03: コンテキストにuseReducerを組み合わせる」では、コンテキストから状態の保持をリデューサに分けました。状態の操作は、コンテキストだけでなく、カスタムフックでも可能です。本稿では、まず前回のサンプル002のコンテキストを、カスタムフックに差し替えます。つぎに試すのが、Unstated Nextというライブラリです。このライブラリを用いると、カスタムフックから改めてコンテキストがつくり出せます。

図001■カウンターのサンプル

図001

01 カスタムフックを使う

フックは、状態(state)をはじめとするReactの機能が使える関数です。コンポーネントのようにJSXテンプレートは返さなくて構いません。いわば、コンポーネントからロジックを抜き出したのがフックです(「独自フックの作成」参照)。コンテキスト(AppContext)は一旦外しますので、アプリケーションモジュールsrc/App.jsから、プロバイダコンポーネント(AppProvider)は除きます。

src/App.js

// import { AppProvider } from './AppContext';

function App() {
	return (
		// <AppProvider>
		<div className="App">
			<CounterDisplay />
		</div>
		/* </AppProvider> */
	);
}

コンテキストのモジュールは、カスタムフックに書き替えます。フックの名前はuseではじめるのがお約束です(src/useCounter.jsとしました)。カスタムフックを使うコンポーネントに渡す参照が戻り値となります。コンテキストではなくなりましたので、プロバイダコンポーネントももちません。

src/AppContext.js→src/useCounter.js

// import React, { createContext, useCallback, useReducer } from 'react';
import { useCallback, useReducer } from 'react';

// export const AppContext = createContext({ count: initialCount });
// export const AppProvider = ({ children }) => {
export const useCounter = () => {

	/* return (
		<AppContext.Provider value={{ count: state.count, reset, decrement, increment }}>
			{children}
		</AppContext.Provider>
	); */
	return { count: state.count, reset, decrement, increment };
};

src/CounterDisplay.js

// import React, { useContext } from 'react';
import React from 'react';
// import { AppContext } from './AppContext';
import { useCounter } from './useCounter';

const CounterDisplay = () => {
	// const { count, reset, decrement, increment } = useContext(AppContext);
	const { count, reset, decrement, increment } = useCounter();

};

これでコンテキストに切り分けられたロジックが、カスタムフックに移せました。書き替えた3つのモジュールの記述は、つぎのコード001にまとめたとおりです。他のモジュールのコードや、カウンターの動きについては、CodeSandboxに公開した以下のサンプル001でお確かめください。

コード001■ロジックをカスタムフックに切り出したカウンター

src/App.js

import React from 'react';
import CounterDisplay from './CounterDisplay';
import './App.css';

function App() {
	return (
		<div className="App">
			<CounterDisplay />
		</div>
	);
}

export default App;

src/useCounter.js

import { useCallback, useReducer } from 'react';
import reducer from './reducder';

const initialCount = 0;
export const useCounter = () => {
	const [state, dispatch] = useReducer(reducer, { count: initialCount });
	const reset = useCallback(() => dispatch({ type: 'reset' }), []);
	const decrement = useCallback(() => dispatch({ type: 'decrement' }), []);
	const increment = useCallback(() => dispatch({ type: 'increment' }), []);
	return { count: state.count, reset, decrement, increment };
};

src/CounterDisplay.js

import React from 'react';
import { useCounter } from './useCounter';

const CounterDisplay = () => {
	const { count, reset, decrement, increment } = useCounter();
	return (
		<>
			Count: {count}
			<button onClick={reset}>Reset</button>
			<button onClick={decrement}>-</button>
			<button onClick={increment}>+</button>
		</>
	);
};

export default CounterDisplay;

サンプル001■React state management 04-01: Using custom hook

サンプルはカウンターとしては動くものの、コンテキストが備えていた重要な機能が失われています。状態(count)を他のコンポーネントが参照できることです。コンテキストでは、プロバイダコンポーネントで包んだツリーが状態を共有できました。けれど、カスタムフックはそれを使ったコンポーネントの中でしか状態は参照できません。他のコンポーネントが同じフックは使えます。ただし、共有するのはロジックであって、状態はそれぞれ別なのです。

カスタムフックの状態はコンポーネントごとに分離される

React公式サイトの「カスタムフックを使う」には、つぎのように説明されています。

同じフックを使うコンポーネントはstateを共有する?いいえ。カスタムフックはstateを使うロジック(データの購読を登録したり現在の値を覚えておいたり)を共有するためのものですが、カスタムフックを使う場所ごとで、内部のstateや副作用は完全に分離しています。

02 カスタムフックをUnstated Nextでコンテナにする

そこで、カスタムフックにコンテキストを組み込むのがUnstated Nextです。プロバイダが備われば、コンポーネントツリーの間で状態を共有できるでしょう。カスタムフックのロジックが含まれたコンテキストを、Unstated Nextは「コンテナ」と呼びます。まずは、npmまたはyarnでunstated-nextをインストールしてください。


npm install --save unstated-next


yarn add unstated-next

カスタムフック(src/useCounter.js)は、unstated-nextからCounterContainer関数をつぎのようにimportします。引数にカスタムフックの関数を渡して呼び出してつくられるのがコンテナ(CounterContainer)です。

src/useCounter.js

import { createContainer } from "unstated-next"

// export const useCounter = () => {
const useCounter = () => {

};

export const CounterContainer = createContainer(useCounter);

コンテナ(CounterContainer)はコンテキストですから、プロバイダコンポーネントが備わっています。アプリケーションモジュール(src/App.js)でコンポーネントツリーをプロバイダで包めば、状態は共有できるということです。

src/App.js

import { CounterContainer } from './useCounter';

function App() {
	return (
		<CounterContainer.Provider>
			<div className="App">

			</div>
		</CounterContainer.Provider>
	);
}

コンテナはカスタムフックでもあります。プロバイダの子コンポーネントは、コンテナ(CounterContainer)に対してuseContainer関数を呼び出せば、フックの戻り値が受け取れるのです。

src/CounterDisplay.js

// import { useCounter } from './useCounter';
import { CounterContainer } from './useCounter';

const CounterDisplay = () => {
	// const { count, reset, decrement, increment } = useCounter();
	const { count, reset, decrement, increment } = CounterContainer.useContainer();

};

これでカスタムフック(useCounter')はUnstated Nextでコンテナとなって、プロバイダによりコンポーネントツリー内で状態を共有させることができました。書き直した3つのモジュールの記述をつぎのコード002にまとめます。併せて、以下のサンプル002をCodeSandboxに公開しました。

コード002■カスタムフックをUnstated Nextでコンテナにする

src/useCounter.js

import { useCallback, useReducer } from 'react';
import { createContainer } from "unstated-next"
import reducer from './reducder';

const initialCount = 0;
const useCounter = () => {
	const [state, dispatch] = useReducer(reducer, { count: initialCount });
	const reset = useCallback(() => dispatch({ type: 'reset' }), []);
	const decrement = useCallback(() => dispatch({ type: 'decrement' }), []);
	const increment = useCallback(() => dispatch({ type: 'increment' }), []);
	return { count: state.count, reset, decrement, increment };
};

export const CounterContainer = createContainer(useCounter);

src/App.js

import React from 'react';
import { CounterContainer } from './useCounter';
import CounterDisplay from './CounterDisplay';
import './App.css';

function App() {
	return (
		<CounterContainer.Provider>
			<div className="App">
				<CounterDisplay />
			</div>
		</CounterContainer.Provider>
	);
}

export default App;

src/CounterDisplay.js

import React from 'react';
import { CounterContainer } from './useCounter';

const CounterDisplay = () => {
	const { count, reset, decrement, increment } = CounterContainer.useContainer();
	return (
		<>
			Count: {count}
			<button onClick={reset}>Reset</button>
			<button onClick={decrement}>-</button>
			<button onClick={increment}>+</button>
		</>
	);
};

export default CounterDisplay;

サンプル002■React state management 04-02: Using Unstated Next

できたことは前回のサンプル002と変わりません。けれど、「Create React App フックによる状態管理 03」のコード002(再掲)をもう一度見てみましょう。コンテキスト(AppContext)から取り出したプロバイダで、子コンポーネント(children)を包みました。 そのうえで、共有する参照を与えたのがvalueプロパティです。

Create React App フックによる状態管理 03コード002■アクション配信の関数にuseCallbackフックを使う

src/AppContext.js

import React, { createContext, useCallback, useReducer } from 'react';
import reducer from './reducder';

const initialCount = 0;
export const AppContext = createContext({ count: initialCount });
export const AppProvider = ({ children }) => {
	const [state, dispatch] = useReducer(reducer, { count: initialCount });
	const reset = useCallback(() => dispatch({ type: 'reset' }), []);
	const decrement = useCallback(() => dispatch({ type: 'decrement' }), []);
	const increment = useCallback(() => dispatch({ type: 'increment' }), []);
	return (
		<AppContext.Provider value={{ count: state.count, reset, decrement, increment }}>
			{children}
		</AppContext.Provider>
	);
};

前掲コード002でも、コンポーネントツリーはプロバイダで包みました。でもここで、共有する参照は与えません。子コンポーネントそれぞれが、必要な参照をuseContainerで受け取れるからです。Unstated Nextにより、状態(コンテキスト)とロジック(カスタムフック)の組み立てが、よりわかりやすく扱いやすくなったといえます。

03 リデューサをコンテナにする

さらに、リデューサ(src/reducder.js)もコンテナにしましょう。そうすることで、状態の操作と保持のロジックが、よりはっきりと分けられるからです。コンテナにするには、カスタムフックをつくらなければなりません。新たに加えるフックの中からcreateContainerを呼び出して、状態(state)とアクション配信関数(dispatch)を得ます。そのうえで、必要な参照を戻り値に含めればよいでしょう。createContainerでつくったコンテナをexportしてください。

src/reducder.js

import { useReducer } from 'react';
import { createContainer } from 'unstated-next';

// export default reducer;
const initialCount = 0;
export const CounterReducer = createContainer(() => {
	const [state, dispatch] = useReducer(reducer, { count: initialCount });
	return { count: state.count, dispatch }
})

アプリケーションモジュール(src/App.js)は、リデューサのコンテナ(CounterReducer)をimportします。リデューサは状態操作のコンテナ(CounterContainer)から参照しなければなりませんので、リデューサプロバイダの子として包むことが必要です。

src/App.js

import { CounterReducer } from './reducder';

function App() {
	return (
		<CounterReducer.Provider>
			<CounterContainer.Provider>

			</CounterContainer.Provider>
		</CounterReducer.Provider>
	);
}

状態を操作するコンテナモジュール(src/useCounter.js)は、親のリデューサコンテナ(CounterReducer)に対してuseContainerを呼び出せば、必要な参照が得られます。このとき、useReducerとは異なり、状態(state)を丸ごとは受け取っていないことにご注目ください。状態の内どのプロパティに触ってよいかは、リデューサコンテナが決められるのです。カスタムフック(useCounter)の戻り値は変わらないので、カウンター表示のコンポーネントはそのままで大丈夫です。

src/useCounter.js

// import { useCallback, useReducer } from 'react';
import { useCallback } from 'react';

// import reducer from './reducder';
import { CounterReducer } from './reducder';

// const initialCount = 0;
const useCounter = () => {
	// const [state, dispatch] = useReducer(reducer, { count: initialCount });
	const { count, dispatch } = CounterReducer.useContainer();

	// return { count: state.count, reset, decrement, increment };
	return { count, reset, decrement, increment };
};

リデューサをコンテナにするために書き替えた3つのモジュールの記述は、つぎのコード003にまとめたとおりです。他のモジュールのコードやカウンターの動きは、CodeSandboxに掲げた以下のサンプル003でお確かめください。

コード003■リデューサをUnstated Nextのコンテナにする

src/reducder.js

import { useReducer } from 'react';
import { createContainer } from 'unstated-next';

const reducer = (state, action) => {
	switch (action.type) {
		case 'reset':
			return { count: 0 };
		case 'decrement':
			return { count: state.count - 1 };
		case 'increment':
			return { count: state.count + 1 };
		default:
			console.error('error');
	}
};

const initialCount = 0;
export const CounterReducer = createContainer(() => {
	const [state, dispatch] = useReducer(reducer, { count: initialCount });
	return { count: state.count, dispatch }
})

src/App.js

import React from 'react';
import { CounterContainer } from './useCounter';
import { CounterReducer } from './reducder';
import CounterDisplay from './CounterDisplay';
import './App.css';

function App() {
	return (
		<CounterReducer.Provider>
			<CounterContainer.Provider>
				<div className="App">
					<CounterDisplay />
				</div>
			</CounterContainer.Provider>
		</CounterReducer.Provider>
	);
}

export default App;

src/useCounter.js

import { useCallback } from 'react';
import { createContainer } from "unstated-next"
import { CounterReducer } from './reducder';

const useCounter = () => {
	const { count, dispatch } = CounterReducer.useContainer();
	const reset = useCallback(() => dispatch({ type: 'reset' }), []);
	const decrement = useCallback(() => dispatch({ type: 'decrement' }), []);
	const increment = useCallback(() => dispatch({ type: 'increment' }), []);
	return { count, reset, decrement, increment };
};

export const CounterContainer = createContainer(useCounter);

サンプル002■React state management 04-03: Making reducer into container


作成者: 野中文雄
作成日: 2020年12月7日


Copyright © 2001-2020 Fumio Nonaka.  All rights reserved.