はじめに
皆さん、こんにちは!
毎週末10キロ近くあてもなく散歩することにハマっているエンジニアの@kazukiiiiです。
今まで利用してこなかったのですが、最近開発でReact Contextを利用する機会があったので今回は開発時に得た知見を復習がてら記事にまとめていきたいと思います!
概要
React Contextはコンポーネント間でPropsを利用することなく、任意のコンポーネントツリー内で情報共有を可能にする状態管理機能です。
通常Reactにおいてコンポーネント間でデータを共有する際にはPropsを経由してデータを渡す必要があります。この方式はコンポーネント構造が単純な場合は問題ないのですが、構造が複雑になりデータをさまざまな箇所で参照したい場合にPropsの受け渡しが冗長になり、結果的にコードの可読性の低下につながってしまいます。
こうしたコンポーネント構造が複雑化した際に発生するProps Drillingを解消するための手段の1つとして有効なのがReact Contextです。
使い方
では実際にコードを書いてReact Contextを導入してみましょう。
今回はカウントボタンを押下することで数値が増加していく簡単なアプリにContext APIを導入してみます。
以下が完成したコードです。
import { createContext, useContext, useState } from "react";
const CountContext = createContext();
const ComponentA = () => {
const { countA, setCountA } = useContext(CountContext);
console.log("Component A");
return (
<>
<Button value="カウントA" onClick={setCountA} />
<p>ComponentA:カウント「{countA}」</p>
</>
);
};
const ComponentB = () => {
const { countB, setCountB } = useContext(CountContext);
console.log("Component B");
return (
<>
<Button value="カウントB" onClick={setCountB} />
<p>ComponentB:カウント「{countB}」</p>
</>
);
};
const ComponentC = () => {
const { countA, countB } = useContext(CountContext);
console.log("Component C");
return <p>合計:{countA + countB}</p>;
};
const Button = ({ value, onClick }) => {
return (
<input
type="button"
value={value}
onClick={() => onClick((prev) => prev + 1)}
/>
);
};
const App = () => {
const [countA, setCountA] = useState(0);
const [countB, setCountB] = useState(0);
return (
<CountContext.Provider value={{ countA, countB, setCountA, setCountB }}>
<ComponentA />
<ComponentB />
<ComponentC />
</CountContext.Provider>
);
};
export default App;
アプリでは「カウント」ボタンを押下することでボタンに対応する値が+1され、それぞれのカウントを加算した結果を合計値として表示しています。
通常であれば合計値を出すためにComponentCに対してPropsを利用して各カウントの値を渡す必要があるのですが、Contextを利用していることで、Propsを経由せずに値を受け渡すことが可能となっています。
Contextを利用すれば、code1のようにPropsを経由せず簡単にコンポーネント間でデータ共有を行うことができるのですが、code1の状態では無駄なレンダリングが発生しており、パフォーマンスの低下が懸念される状態になっています。
code1では「カウントA」ボタンを押下した際にはComponentAとComponentCのみ再レンダリングして欲しいのですが、下記画像のコンソールを確認すると「ComponentB」も再レンダリングされていることがわかります。
このように、Contextは簡単に実装できる反面、気付かぬうちに意図しないレンダリングが行われている可能性があります。
こうした事象も踏まえて、ここからはContextを利用する上で確認して欲しいポイントについてまとめていきたいと思います。
ポイント1 : Providerに子コンポーネントを記述しない
code1のように、Providerに子コンポーネントを記述してしまうと、Provider内で定義しているStateの更新が入るたびに子コンポーネントが全て再レンダリングされてしまいます。
なので、Contextを利用するときはコンポジションを使って子コンポーネントを渡すようにしましょう。
const CountProvider = ({ children }) => {
const [countA, setCountA] = useState(0);
const [countB, setCountB] = useState(0);
return (
<CountContext.Provider value={{ countA, countB, setCountA, setCountB }}>
{ children }
</CountContext.Provider>
);
};
const App = () => {
return (
<CountProvider>
<ComponentA />
<ComponentB />
<ComponentC />
</CountProvider>
);
};
Reactでは上記のようにProps経由でコンポーネントを渡すことで親コンポーネントのレンダリングに伴う、子コンポーネントのレンダリングを抑制することができます。(詳しい説明についてはこちらの記事が参考になるので、気になる方はチェックしてみてください)
ポイント2 : Contextの再レンダリング条件を理解する
ContextはProviderのvalueに渡している値が更新された際に、Providerに対応したuseContextを利用しているコンポーネントが全て再レンダリングされます。
再レンダリングが行われると、useContextを読み込んでいるコンポーネントに加え、その子コンポーネントも全て再レンダリングされます。
上記を踏まえた上でもう一度カウントアプリを確認してみましょう。
カウントアプリでは、1つのProviderに対してカウントA、カウントB両方の値及びセット関数を渡しています。
const CountContext = createContext();
const CountProvider = ({ children }) => {
const [countA, setCountA] = useState(0);
const [countB, setCountB] = useState(0);
return (
<CountContext.Provider value={{ countA, countB, setCountA, setCountB }}>
{ children }
</CountContext.Provider>
);
};
上記のコードには主に2つの問題点があります。
問題点1 : 値を1つのProviderで管理してしまっている
code3のような記述を行うと、カウントAの値を更新しただけなのにカウントBのコンポーネントも再レンダリングされてしまいます。
この問題を解消するにはProviderを分けて記述する必要があります。
import { createContext, useContext, useState } from "react";
const CountContextA = createContext();
const CountContextB = createContext();
const CountProvider = ({ children }) => {
const [countA, setCountA] = useState(0);
const [countB, setCountB] = useState(0);
return (
<CountContextA.Provider value={{ countA, setCountA }}>
<CountContextB.Provider value={{ countB, setCountB }}>
{children}
</CountContextB.Provider>
</CountContextA.Provider>
);
};
const ComponentA = () => {
const { countA, setCountA } = useContext(CountContextA);
console.log("Component A");
return (
<>
<Button value="カウントA" onClick={setCountA} />
<p>ComponentA:カウント「{countA}」</p>
</>
);
};
const ComponentB = () => {
const { countB, setCountB } = useContext(CountContextB);
console.log("Component B");
return (
<>
<Button value="カウントB" onClick={setCountB} />
<p>ComponentB:カウント「{countB}」</p>
</>
);
};
const ComponentC = () => {
const { countA } = useContext(CountContextA);
const { countB } = useContext(CountContextB);
console.log("Component C");
return <p>合計:{countA + countB}</p>;
};
const Button = ({ value, onClick }) => {
return (
<input
type="button"
value={value}
onClick={() => onClick((prev) => prev + 1)}
/>
);
};
const App = () => {
console.log('APP');
return (
<CountProvider>
<ComponentA />
<ComponentB />
<ComponentC />
</CountProvider>
);
};
export default App;
上記のようにProviderを分けることで、カウントAボタンが押された際にComponentBのuseContextでは変更されるデータを持っていないため、再レンダリングが行われずに済みます。
しかし、useContextによる再レンダリングは抑制できたものの、もう1つ問題点が残っており、それが原因でProviderを分けても尚全てのコンポーネントで再レンダリングが発生する状態になっています。
問題点2 : Providerへのオブジェクトの渡し方
Providerを分けても尚発生している再レンダリングの原因は、Providerに渡しているオブジェクトがレンダリングの度に再生成されている点にあります。
Providerのvalueに渡しているデータは内部でObject.isを利用し前回と今回で値を比較し、変化している場合に再レンダリングが実行される仕組みになっています。
valueにオブジェクトを渡している場合、中身が仮に変更されなくともProviderが再レンダリングされるたびにオブジェクト自体が新たに生成されているので、再レンダリングされてしまっています。
この現象はオブジェクトをメモ化することで防ぐことが可能です。
import { createContext, useContext, useState, useMemo } from "react";
const CountContextA = createContext();
const CountContextB = createContext();
const CountProvider = ({ children }) => {
const [countA, setCountA] = useState(0);
const [countB, setCountB] = useState(0);
const countContextAValue = useMemo(
() => ({
countA,
setCountA
}),
[countA]
);
const countContextBValue = useMemo(
() => ({
countB,
setCountB
}),
[countB]
);
return (
<CountContextA.Provider value={countContextAValue}>
<CountContextB.Provider value={countContextBValue}>
{children}
</CountContextB.Provider>
</CountContextA.Provider>
);
};
const Button = ({ value, onClick }) => {
return (
<input
type="button"
value={value}
onClick={() => onClick((prev) => prev + 1)}
/>
);
};
const ComponentA = () => {
const { countA, setCountA } = useContext(CountContextA);
console.log("Component A");
return (
<>
<Button value="カウントA" onClick={setCountA} />
<p>ComponentA:カウント「{countA}」</p>
</>
);
};
const ComponentB = () => {
const { countB, setCountB } = useContext(CountContextB);
console.log("Component B");
return (
<>
<Button value="カウントB" onClick={setCountB} />
<p>ComponentB:カウント「{countB}」</p>
</>
);
};
const ComponentC = () => {
const { countA } = useContext(CountContextA);
const { countB } = useContext(CountContextB);
console.log("Component C");
return <p>合計:{countA + countB}</p>;
};
const App = () => {
return (
<CountProvider>
<ComponentA />
<ComponentB />
<ComponentC />
</CountProvider>
);
};
export default App;
上記のようにvalueにオブジェクトを単に渡すのではなく、useMemoを利用し、依存配列で指定した値が変わらない限りオブジェクトを再生成しない実装をすることで、オブジェクトが原因で発生していた再レンダリングを抑制することができます。
最後に
React Contextは導入ハードルが低く、簡単に状態管理を行えますが、その反面ポイントを押さえて導入を行わないとパフォーマンスに大きく影響が出る機能でもあります。
私自身も最近使い始めたばかりでまだまだわからないことだらけですが、開発していく中で知見が溜まりましたらまた記事の投稿を行いますので、よかったらHabitat Hubのフォローよろしくお願いします!