3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

React + TypeScript: useContextで子コンポーネントが親からvalueを受け取るときの注意

Posted at

コンテクスト」は、コンポーネントツリーの中でデータを共有する仕組みです(「コンテクストをつくってuseContextフックで参照する」参照)。そして、useContextフックは「コンテクストオブジェクト(React.createContextからの戻り値)を受け取り、そのコンテクストの現在値を返します。」(「useContext」)。とくに便利なのは、データをまとめたうえで、コンポーネントからロジックを切り出して(別モジュールにして)しまえることです。

ところが、コンテクストのロジックがコンポーネントから呼び出せないという問題に遭遇してしまいました。useContextの使い方について、理解の浅い点があったためです。備忘録としてまとめます。

useContextを使わずルートモジュールにまとめたテスト用コード

つぎに掲げるのが、コンテクストを使う前のテスト用コードです(ルートモジュールsrc/App.tsx)。ご覧のとおりとても簡素で、ボタン(<button>要素)をクリックすると(onClickハンドラ)コールバック(test())が呼ばれて、コンソールにテキストが出力されます。今回のコードは、TypeScriptを用いるものの、まだ型づけされていません。型推論で済んでしまっているからです。

src/App.tsx
import React, { useCallback } from "react";

function App() {
	const test = useCallback(() => {
		console.log("test button clicked");
	}, []);
	return (
		<div>
			<button type="button" onClick={test}>
				test
			</button>
		</div>
	);
}

export default App;

コンテクストのモジュールをつくる

新たなコンテクストのモジュール(src/TestContext.tsx)にロジックを切り出します。コンテクストをつくるのは、createContext()です(「コンテクストをつくってuseContextフックで参照する」参照)。以下のコード001のとおり、引数にはコンテクストの初期値を与え、戻り値のコンテクスト(TestContext)はexportしてください(「Create React App 入門 06: アプリケーションのロジックをコンテクストに切り出す」参照)。

このとき、鍵になることはふたつあります。ひとつは、exportするコンポーネント(TestContextProvider)がコンテクストのProviderをJSXとして返すことです。ルートモジュール(src/App.tsx)は、後述のとおりimportしたProviderのコンポーネントでツリーを包みます。

もうひとつの鍵は、Providerのコンポーネント(TestContextProvider)がprops.childrenを引数に受け取り、JSXのProviderでラップすることです(「ラップしたコンポーネントツリーをprops.childrenで受け取る」参照)。

コード001■コンテクストのモジュール

src/TestContext.tsx
import { createContext, useCallback } from "react";

type TestContextType = {
	test: () => void;
};
type Props = {
	children: React.ReactNode;
};
export const TestContext = createContext<TestContextType>({
	test: () => undefined,
});
export const TestContextProvider: React.VFC<Props> = ({ children }) => {
	const test = useCallback(() => {
		console.log("test button clicked");
	}, []);
	return (
		<TestContext.Provider value={{ test }}>{children}</TestContext.Provider>
	);
};

useContexの誤った使い方

これで、ルートモジュール(src/App.tsx)はコンテクストのモジュール(src/TestContext.tsx)からTestContextTestContextProviderimportでき、Providerで包んだコンポーネントツリーは前掲コード001でvalueに与えられたデータ(test)が得られるようになりました。参照するには、コンテクストを引数にしたuseContextの戻り値からプロパティを取り出すだけです。

src/App.tsx
// import React, { useCallback } from "react";
import React, { useContext } from "react";
import { TestContext, TestContextProvider } from "./TestContext";

function App() {
	/* const test = useCallback(() => {
		console.log("test button clicked");
	}, []); */
	const { test } = useContext(TestContext);
	return (
		<TestContextProvider>
			<div>
				<button type="button" onClick={test}>
					test
				</button>
				<div>test: {String(test)}</div>
			</div>
		</TestContextProvider>
	);
}

Providerで包まれたツリー内のコンポーネントは、階層を上にたどってvalueに与えられたプロパティの参照が得られます。ところが、ボタン(<button>要素)をクリックしても、onClickに定めたコンテクスト(TestContext)のコールバック関数(test)が呼び出せません。

そこで上記コードは、確認のためボタンの下に加えた<div>要素に、取り出したはずのコールバック関数(test)を文字列で表示してみました。すると、関数としては認識されています。ただし、つぎのように値が初期値のまま、コンテクストに定めた関数に改められていないようです。

test: () => undefined

公式ドキュメントを読むと、つぎのように書かれていました。コンポーネントの「関数に渡される引数valueは、ツリー内の上位で一番近いこのコンテクスト用のプロバイダのvalueプロパティと等しくなります。このコンテクスト用のプロバイダが上位に存在しない場合、引数のvaluecreateContext()から渡されたdefaultValueと等しくなります」(「Context.Consumer」)。つまり、コンポーネントツリーの上層にProviderがないということのようです。

useContextは子コンポーネントの中から呼び出す

改めて公式ドキュメントの「useContext」を読むとつぎのように説明されています。見逃してならないのは、「ツリー内でこのフックを呼んだコンポーネント」というくだりです。もちろん、子コンポーネントはJSXでProviderに包まれていなければなりません。それだけでなく、useContextは子コンポーネントの中から呼び出さなければならないのです。

コンテクストの現在値は、ツリー内でこのフックを呼んだコンポーネントの直近にある<MyContext.Provider>valueの値によって決定されます。

直近の<MyContext.Provider>が更新された場合、このフックはそのMyContextプロバイダに渡された最新のvalueの値を使って再レンダーを発生させます。

前掲の誤ったコードでは、useContextを親のルートコンポーネント(App)から呼び出しました。すると、このコンポーネントから上層にProviderを探してしまうのです。ですから、見当たらず、初期値のままになったということでしょう。

解決するには、子コンポーネントを分けます。モジュールはそのままでも、つぎのように子コンポーネント(TestButton)を新たに定め、useContextはその中から呼び出せばよいのです。これで、コンテクストのコンポーネント(TestContextProvider)に定めた関数(test)がvalueのプロパティとして取り出せます。

src/App.tsx
const TestButton: React.VFC = () => {
	const { test } = useContext(TestContext);
	return (
		<div>
			<button type="button" onClick={test}>
				test
			</button>
		</div>
	);
};
function App() {
	// const { test } = useContext(TestContext);
	return (
		<TestContextProvider>
			{/* <div>
				<button type="button" onClick={test}>
					test
				</button>
				<div>test: {String(test)}</div>
			</div> */}
			<TestButton />
		</TestContextProvider>
	);
}

書き直したルートモジュール(src/App.tsx)の記述全体を、つぎのコード002にまとめます。

コード002■useContextは子コンポーネントの中から呼び出す

src/App.tsx
import React, { useContext } from "react";
import { TestContext, TestContextProvider } from "./TestContext";

const TestButton: React.VFC = () => {
	const { test } = useContext(TestContext);
	return (
		<div>
			<button type="button" onClick={test}>
				test
			</button>
		</div>
	);
};
function App() {
	return (
		<TestContextProvider>
			<TestButton />
		</TestContextProvider>
	);
}

export default App;

なにしろエラーが出ないので、問題の絞り込みに手間取りました。また、何が起こっているのかわからないままでは、検索のしようもありません。もっとも、ルートモジュールからuseContextを使いたいということも稀でしょう。それもあってか、公式ドキュメントの記述もあっさりしています。今回は、同じ問題につまずいた方のために記事にしました。

3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?