「コンテクスト」は、コンポーネントツリーの中でデータを共有する仕組みです(「コンテクストをつくって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
を使いたいということも稀でしょう。それもあってか、公式ドキュメントの記述もあっさりしています。今回は、同じ問題につまずいた方のために記事にしました。