Reactにおけるグローバルステートのデメリットを分かりやすく解説
はじめに
みなさんはReactでグローバルステートを使ってますでしょうか?
私は、Recoilを使っているプロジェクトと、何も使っていないプロジェクトの両方に参加しています。
ここ1~2年のネット上での意見を見ていて、「グローバルステートは使わない方がいい」という意見が増えている印象です。
この記事は、グローバルステートあり、なし、どちらも経験して考える、グローバルステートのデメリットを分かりやすく言語化する試みです。
Reactの単一方向データフロー
グローバルステートの話に入る前に、Propsの話をします。
Reactではコンポーネントを定義し、コンポーネントを複数組み合わせてUIを作成します。
このとき、コンポーネント間でデータをやり取りしたいとき、「親から子へ引数で渡す」方法しか許していません。
これは「単一方向データフロー」と呼ばれる原則です。
単一方向データフローの例
例としてボタンがクリックされたときにカウントを増やすような実装を考えます。
// Parent.tsx
const Parent = () => {
const [count, setCount] = useState(0);
const increment = () => {
setCount((prev) => prev + 1);
};
return (
<>
<Status count={count} />
<Button onClick={increment} />
</>
);
}
// Button.tsx
const Button = ({ onClick }: { onClick: () => void }) => {
return <button onClick={onClick}>クリック!</button>;
}
// Status.tsx
const Status = ({ count }: { count: number }) => {
return <div>{count}</div>;
};
グローバルステートの例
次にグローバルステートの疑似コードを利用したReactを考えてみます。
// Parent.tsx
// コンポーネント外でexportされた値や関数は、どのコンポーネントからも参照できるとする
export const [incrementCount, setIncrementCount] = useState(0);
export const increment = () => {
setIncrementCount((prev) => prev + 1);
};
const Parent = () => {
return (
<>
<Status />
<Button />
</>
);
}
// Button.tsx
import { increment } from "./Parent";
const Button = () => {
return <button onClick={increment}>クリック!</button>;
}
// Status.tsx
import { incrementCount } from "./Parent";
const Status = () => {
return <div>{incrementCount}</div>;
}
Propsによる受け渡しが不要になってスッキリしましたね!
でも、大きなデメリットがあります。
Propsを使わない場合のデメリット
<Status />
コンポーネントはincrementCount
に依存(=結合が強い)しているため、使いまわすことが難しいです。
例えば、incrementCount
以外の値を表示したいという要件が発生したとします。仮にotherCount
としましょう。
// global.ts
export const [incrementCount, setIncrementCount] = useState(0);
export const increment = () => {
setIncrementCount((prev) => prev + 1);
};
export const [otherCount, setOtherCount] = useState(0);
カウントを表示する、という目的が同じなので、共通のコンポーネントを使いたいと思っても、
<Status />
コンポーネントはincrementCount
専用のコンポーネントになっているため、otherCount
を表示することができません。
対して、Propsを使った場合はどうでしょうか。
// Parent.tsx
const Parent = () => {
const [count, setCount] = useState(0);
const increment = () => {
setCount((prev) => prev + 1);
};
return (
<>
<Status count={count} />
<Button onClick={increment} />
</>
);
}
const OtherParent = () => {
const [otherCount, setOtherCount] = useState(0);
const increment = () => {
setOtherCount((prev) => prev + 1);
};
return (
<>
<Status count={otherCount} />
<Button onClick={increment} />
</>
);
}
// Status.tsx
const Status = ({ count }: { count: number }) => {
return <div>{count}</div>;
};
Parent
コンポーネントとOtherParent
コンポーネントは、どちらもStatus
コンポーネントを簡単に利用できます。これはStatus
コンポーネントがPropsで受け取った値を表示するだけのコンポーネントであるためです。
(引数で依存を渡す、という手法はDI(Dependency Injection)と言われます)
ここまでのまとめ
グローバルステートを使った場合
- Good: コンポーネントからuseStateの記述などが消えてスッキリした
- Bad: 特定の値と依存の強いコンポーネントになり、再利用が難しくなった
Propsを使った場合
- Good: 依存する値は親からもらうことで、コンポーネントの再利用がしやすくなった
- Bad: コードが冗長になることがある(Props Drilling)
安易なグローバルステートの導入は避ける
グローバルステートを導入すると、依存が絡み合ったコンポーネントになりやくなります。
これは大きなデメリットで、使いまわしが難しく、変更に耐えられず、開発速度がどんどん低下していきます。
対して、単一方向データフローに従う場合、「依存は親から渡される」という原則が守られ続けることになり、疎結合で使いまわしやすく、変化に強いコンポーネントを作りやすくなります。
厄介な制約に見えるかもしれない単一方向データフローですが、その制約を守ることで、変化に強い構造を生み出すことができます。
実務で書くコードはもっともっと複雑でコンポーネントも多いので、コードの設計の良し悪しが製品やビジネスに影響してきます。
個人の意見としては、グローバルステートを導入するかは慎重になるべきで、多くのケースでは単一方向データフローを使った方が良いと思います。
不自由であることはデメリットではなく
グローバルステートを使っても良い場合って?
- 設計スキルがある開発チームで、グローバルステートの使い方を見極めてコーディングできる場合
- プロトタイプや小規模な開発
これらの場合はグローバルステートを導入するメリットがデメリットを上回る可能性があると思います。
まとめ
今回は「依存性が高くなりがちなのがデメリットだよ」という話しかできていないのですが、付随するデメリットはいろいろあります。
- グローバルステートでいつでも値が利用できるので、コンポーネントの階層が深くなっていく
- 「Propsリレーがしんどくならないようにリファクタリングしよう」といった力が働きにくい
- テストが難しくなる
- デバッグが難しくなる
私が業務で使っているのがRecoilで、便利だし、特に非同期データの扱いに長けたところが最高なのですが、利用するルールの検討や認識合わせができなかったため、あらゆる方向に結合しまくりのコードになっています。
メンテ終わったのもヤバい
そんなわけで、この記事を読んで、グローバルステートを使うかどうかを検討するきっかけになれば嬉しいです。
Discussion