「コンテクスト」は、コンポーネントツリーの中でデータを共有する仕組みです(「コンテクストをつくってuseContextフックで参照する」参照)。そして、useContextフックは「コンテクストオブジェクト(React.createContextからの戻り値)を受け取り、そのコンテクストの現在値を返します。」(「useContext」)。とくに便利なのは、データをまとめたうえで、コンポーネントからロジックを切り出して(別モジュールにして)しまえることです。
ところが、コンテクストのロジックがコンポーネントから呼び出せないという問題に遭遇してしまいました。useContextの使い方について、理解の浅い点があったためです。備忘録としてまとめます。
useContextを使わずルートモジュールにまとめたテスト用コード
つぎに掲げるのが、コンテクストを使う前のテスト用コードです(ルートモジュールsrc/App.tsx)。ご覧のとおりとても簡素で、ボタン(<button>要素)をクリックすると(onClickハンドラ)コールバック(test())が呼ばれて、コンソールにテキストが出力されます。今回のコードは、TypeScriptを用いるものの、まだ型づけされていません。型推論で済んでしまっているからです。
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■コンテクストのモジュール
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)からTestContextとTestContextProviderがimportでき、Providerで包んだコンポーネントツリーは前掲コード001でvalueに与えられたデータ(test)が得られるようになりました。参照するには、コンテクストを引数にしたuseContextの戻り値からプロパティを取り出すだけです。
// 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プロパティと等しくなります。このコンテクスト用のプロバイダが上位に存在しない場合、引数のvalueはcreateContext()から渡されたdefaultValueと等しくなります」(「Context.Consumer」)。つまり、コンポーネントツリーの上層にProviderがないということのようです。
useContextは子コンポーネントの中から呼び出す
改めて公式ドキュメントの「useContext」を読むとつぎのように説明されています。見逃してならないのは、「ツリー内でこのフックを呼んだコンポーネント」というくだりです。もちろん、子コンポーネントはJSXでProviderに包まれていなければなりません。それだけでなく、useContextは子コンポーネントの中から呼び出さなければならないのです。
コンテクストの現在値は、ツリー内でこのフックを呼んだコンポーネントの直近にある
<MyContext.Provider>のvalueの値によって決定されます。
直近の
<MyContext.Provider>が更新された場合、このフックはそのMyContextプロバイダに渡された最新のvalueの値を使って再レンダーを発生させます。
前掲の誤ったコードでは、useContextを親のルートコンポーネント(App)から呼び出しました。すると、このコンポーネントから上層にProviderを探してしまうのです。ですから、見当たらず、初期値のままになったということでしょう。
解決するには、子コンポーネントを分けます。モジュールはそのままでも、つぎのように子コンポーネント(TestButton)を新たに定め、useContextはその中から呼び出せばよいのです。これで、コンテクストのコンポーネント(TestContextProvider)に定めた関数(test)がvalueのプロパティとして取り出せます。
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は子コンポーネントの中から呼び出す
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を使いたいということも稀でしょう。それもあってか、公式ドキュメントの記述もあっさりしています。今回は、同じ問題につまずいた方のために記事にしました。