[React]ContextAPIのアンチパターン
ContextAPI と useState は本来組み合わせてはいけない
ContextAPI の機能は、コンポーネントの階層を飛び越えてデータを配信することにあります。しかしここで気をつけなければならないのが、Provider に設定する値を useState で管理すると、Provider を持っているツリーが全て再レンダリングされることです。この書き方は最悪のアンチパターンです。
アンチパターン
Provider に渡す値を更新をするのに上位のコンポーネントの useState のディスパッチャーが使用しています。どれか一つでも値を更新すると、全てのコンポーネントが再レンダリングされます。ContextAPI での解説などでこの方法をよく見かけます。しかし値一つの変更で全てを再レンダリングするなら、状態管理の必要性そのものが無くなってしまいます。これはやってはならない書き方です。
import { createContext, useContext, useState } from "react";
type ValueType = { [key: string]: number | undefined };
const context = createContext<ValueType>(undefined as never);
const Component = ({ name }: { name: string }) => {
const value = useContext(context);
console.log(name);
return (
<div>
{name}:{value[name]}
</div>
);
};
const Page = () => {
const [value, setValue] = useState<ValueType>({});
console.log("Main");
return (
<>
<button onClick={() => setValue((v) => ({ ...v, A: (v["A"] || 0) + 1 }))}>
A
</button>
<button onClick={() => setValue((v) => ({ ...v, B: (v["B"] || 0) + 1 }))}>
B
</button>
<button onClick={() => setValue((v) => ({ ...v, C: (v["C"] || 0) + 1 }))}>
C
</button>
<context.Provider value={value}>
<Component name="A" />
<Component name="B" />
<Component name="C" />
</context.Provider>
</>
);
};
export default Page;
- A を押した場合に発生する出力結果
Main
A
B
C
無駄な再レンダリングを起こさないパターン
Provider には useRef で作成したオブジェクトを渡します。Context の更新はミュータブルに行うので、書き換えても再レンダリングは発生しません。再レンダリングを必要とするコンポーネントは、それぞれの state の書き換えで再レンダリングを通知します。Context が持っているデータは、子コンポーネント内のディスパッチャーとなります。
この書き方によって、ボタン A を押した場合は<Component name="A" />
のみの再レンダリングとなります。
import {
createContext,
Dispatch,
SetStateAction,
useContext,
useEffect,
useRef,
useState,
} from "react";
type ValueType = {
[key: string]: Dispatch<SetStateAction<number | undefined>>;
};
const context = createContext<ValueType>(undefined as never);
const Component = ({ name }: { name: string }) => {
console.log(name);
const [value, setValue] = useState<number>();
const dispatches = useContext(context);
useEffect(() => {
dispatches[name] = setValue;
return () => {
delete dispatches[name];
};
}, [name]);
return (
<div>
{name}:{value}
</div>
);
};
const Page = () => {
console.log("Main");
const dispatches = useRef<ValueType>({}).current;
return (
<>
<button onClick={() => dispatches["A"]?.((v) => (v || 0) + 1)}>A</button>
<button onClick={() => dispatches["B"]?.((v) => (v || 0) + 1)}>B</button>
<button onClick={() => dispatches["C"]?.((v) => (v || 0) + 1)}>C</button>
<context.Provider value={dispatches}>
<Component name="A" />
<Component name="B" />
<Component name="C" />
</context.Provider>
</>
);
};
export default Page;
- A を押した場合に発生する出力結果
A
データを Context 側に持たせつつ、無駄な再レンダリングを防ぐパターン
構造が少々複雑になりますが、同じ名前のデータを複数のコンポーネントが使用した場合も耐えられる書き方です。データを Context 側に保存しつつ、再レンダリングを促すディスパッチャーも管理します。
これによって、対応する名前のデータを使用しているコンポーネントのみが再レンダリングの対象になります。
import {
createContext,
Dispatch,
SetStateAction,
useContext,
useEffect,
useRef,
useState,
} from "react";
type ValueType = {
dispatches: { [key: string]: Set<Dispatch<SetStateAction<{}>>> };
values: { [key: string]: number | undefined };
};
const context = createContext<ValueType>(undefined as never);
const Component = ({ name }: { name: string }) => {
console.log(name);
const [_, setValue] = useState<{}>();
const { values, dispatches } = useContext(context);
useEffect(() => {
if (dispatches[name]) dispatches[name].add(setValue);
else dispatches[name] = new Set([setValue]);
return () => {
dispatches[name].delete(setValue);
};
}, [name]);
return (
<div>
{name}:{values[name]}
</div>
);
};
const Page = () => {
console.log("Main");
const manager = useRef<ValueType>({ dispatches: {}, values: {} }).current;
const { dispatches, values } = manager;
return (
<>
<button
onClick={() => {
values["A"] = (values["A"] || 0) + 1;
dispatches["A"].forEach((v) => v({}));
}}
>
A
</button>
<button
onClick={() => {
values["B"] = (values["B"] || 0) + 1;
dispatches["B"].forEach((v) => v({}));
}}
>
B
</button>
<button
onClick={() => {
values["C"] = (values["C"] || 0) + 1;
dispatches["C"].forEach((v) => v({}));
}}
>
C
</button>
<context.Provider value={manager}>
<Component name="A" />
<Component name="B" />
<Component name="C" />
<Component name="A" />
<Component name="B" />
<Component name="C" />
</context.Provider>
</>
);
};
export default Page;
- A を押した場合に発生する出力結果
A
A
まとめ
ContextAPI は Provider 内でデータを配るための機能です。一部で変な誤解が生み出されていますが、けっして無駄な再レンダリングを発生させる機能ではありません。特定のコンポーネントに対して再レンダリングを促したい場合は、各コンポーネント内で setState しディスパッチャーを使います。また、setState は再レンダリングイベントを発生させるためのディスパッチャーの役割が主要機能であって、データを保存するのが主目的ではありません。データを保存したいのであれば useRef が正しい選択です。
今回、解説用にディスパッチャーの呼び出しを剥き出し書いていますが、きちんとラッピングしてやればもっと使いやすくなります。
React を使うとき重要なのはディスパッチャーを適切なタイミングで呼ぶことです。それさえ出来ていればデータをミュータブルで扱っても全く問題ありません。タイミングを適切に管理して、再レンダリングの滝修行をアプリケーションに組み込むのは回避してください。
Discussion
「ContextAPI と setState は本来組み合わせてはいけない」という主張は過激すぎるし一般に成り立たないと思います。
は同意ですが(※)、
この辺に気をつけて使おう、以上。くらいではないでしょうか。
(※)
には非同意です。データ保存と再レンダリング実行でどちらが主目的というものではないと思います。
データ更新に伴って再レンダリングを実行するモデルこそがReactの根幹であって、そこは切り分けられないでしょう。むしろ副作用を伴う操作やそれに連動したデータ保存がReactのピュアな世界からの逸脱で、それらのためにuseEffectやuseRefが追加で用意されているくらいに考えます。
例えば、トップレベルでログイン中のユーザ情報をAPIから取得して、それをいろいろな子コンポーネントで使うのはよくあるケースだと思います。ContextAPIの出番だと思いますが、↓のようになって自然とuseStateとContextAPIを組み合わせることになると思います。
(例なのでnullチェックなどは一部省いています)
ここで
user
の保持にuseRef
を使おうとは思いません。user
が更新されたのなら、それをsubscribeしている(useUser()
を呼んでいる)コンポーネントは全て再レンダリングされて然るべきです。そのデータを元にviewを生成していて、元になったデータが変わったのですから。データからviewを生成するというReactのアプローチに至極忠実です。翻って、本稿の例はContextAPIとsetStateの組み合わせで再レンダリングを「無駄に」引き起こす恣意的な例になっているだけという印象です。
親が管理するのが1個の大きなobjectで、子がそれをsubscribeして一部フィールドだけ使うというデザインそのものが、ContextAPIと相性が悪い(ように見える)というだけではないでしょうか。
1個のstateを管理する際の関心がContextAPIによって広範囲にばら撒かれてしまっているだけ、というか…
処方箋はuseStateとContextAPIを一緒に使わないことではなく、objectを使ったstate管理を考え直すことだと思います。よくあるのは更新される子要素ごとにstateを分けることですし、どうしても可変個が必要なら子コンポーネントの方でメモ化を使って重い計算や孫の再レンダリングを抑制する方向にいくとか。
提示されたプログラムはUserProvider以下で、ユーザデータの更新と共にuseUserを呼んでいないコンポーネントも巻き込んで再レンダリングを引き起こしているようです。
記事の書き方が不十分で分かりにくかったことは申し訳ありませんが、そのコードはアンチパターンのようです。
わざわざ試していただいでありがとうございます(私の方でちゃんと実行しておらず不完全なコードでお手数おかけしたと思います。すみません)
私の方でもちょっと手直ししてやってみました。
こんな感じで
useUser()
を呼ばない<ComponentB />
があったときに、<UserProvider />
内でsetUser()
した際に<ComponentB />
が再レンダリングされる、ということでしょうか。私の環境では再現しませんでした🤔
ただ、
<UserProvider />
の子供が再レンダリングされたとして、それは親がレンダリングされたからそのrender関数の中が一緒に実行されただけなのでは、という気もしています。例がconfusingですみませんでした。
一方で私の主張はやはり変わりませんで、
useState
によるステート管理&それに結びついたレンダリングのトリガーはReactの根幹なので無闇に忌避するべきではなく、むしろ極力Reactのライフサイクルに乗っておくべきと思っています。useRef
はReactのライフサイクルを無視する特段の事情(RealDOMを触るとか、意図してメモ化を無視するとか)で使うべき例外です。そもそも子コンポーネントの再レンダリングが起きること自体はアンチパターンというほど強い言葉で避けるものではないのではないでしょうか。子コンポーネントの計算量が増えるとアンチパターン"度"が上がるというような程度問題と思っています。
Reactの関数型的な(🪓が怖い…)実行モデルにおいては、計算量を無視できる理想的な世界では毎回全VDOMツリーをレンダリングする気持ちでコードを書き、しかし現実世界で計算量が問題になるからメモ化を駆使したりオブジェクトのmutabilityを気にしたりして最適化するんだと思っています。
計算量が重いコンポーネントに最適化が効いていなかったらそれは「アンチパターン」ですが、軽量コンポーネントならまあいいかなという。
と考えると、もし
<UserProvider />
の子供が再レンダリングされたとして、それ自体は(useContextのせいではなく親のrenderingのせいだと思うので)ContextAPIと結びつけて問題とすることではないと思いますし、またそこでパフォーマンスが問題になるのであれば子コンポーネントをメモ化することで対処すべきものと思います。
記事が分かりにくく誤解を生んで申し訳ありませんが、ContextAPIは全く悪くありません。
上位コンポーネントでuseStateを使うことによって、子コンポーネント全てに再レンダリングが発生する書き方をアンチパターンとしています。
正にこれがアンチパターンです。
親の再レンダリングを行わず、子にデータを配るのが本記事の主題です。
再レンダリングを回避するディスパッチャーの呼び方は提示しておりますので、稚拙な記事ながらもう一度読んでいただければ幸いです。
ふーむ記事の趣旨がちょっと分かったかもしれません。
勝手に私の言葉で言い換えます。間違っていたらご指摘ください。
useRef
で作ったref
に、複数の子からアクセスされる値を置くという設計を前提にする。ref
で管理され、さらにmutable objectなので、fieldの値の変更はReactライフサイクルの埒外にある。従ってそのobjectをProvider.value
に渡しても、fieldの値の変更でProvider
のrenderingが発火しないuseState
が置いてあって、そのdispatcherを呼ぶと同時に↑の値をmutateすることで、子だけrenderして親はrenderされない、ということができるそして以上が"データを Context 側に持たせつつ、無駄な再レンダリングを防ぐパターン"の章のエッセンスであり、本記事で提供したいテクニックである。
いかがでしょう?
ちなみに私も空雲さんがContextAPIそのものを悪いと言っているとは思っておりません。冗長な文章でわかりづらくすみませんでした。
「ContextAPI と setState は本来組み合わせてはいけない」(第1章タイトル)は偽ではないでしょうか、という意図です。
はい、その通りです
useStateが正しいのですが、ここは後ほど修正します
「useStateを上位コンポーネントに持ってきてはいけない」が正確ですが、「ContextAPI と useState は本来組み合わせてはいけない」も特に偽ではありません。
組み合わせた結果、ご提示いただいたようなプログラムのように、データがナイアガラの滝のようなってアンチパターン化してしまうからです。
こちらで続きを書きました
ContextAPIでReduxに似た機能を実装する内容です
アンチパターンというほどでもなく、もっとパフォーマンス的に有利な方法があって、状態管理ライブラリは同様のことをしている、という話だと思いました。
子コンポーネントの状態をrefで親に制御を渡せるけど、それを祖先に持ち上げるのをcontextでバケツリレーをスキップできる、と言い換えてもよさそうですね。
どうもありがとうございます。
であれば、このテクニックが動作すること自体は理解しますが、トリッキーでReactの思想に沿っていないと感じます。
これはuseContextを呼ぶ子全てにrenderingが発火することを指しているのでしょうか。
それはReactのデザイン上正しいものです。私の理解では「アンチパターン」とは呼べません。
それでパフォーマンス問題が起きるなら、先述の通り、state分割や孫のメモ化で解決できます。
逆にこれを肯定すると、「親のstateを子にprops, contextで渡す」というReactの根幹を成すデータフローが否定されます。
その前のコメントでも
とありましたが、私にとってこれは「アンチパターン」ではないので、「アンチパターン」という言葉の定義というか、この語から受ける印象がすれ違っているのでしょうか…
私はアンチパターン = ほぼ全てのケースにおいて忌避すべき書き方、くらいの強いネガティブな意味と捉えていました。
「親の再レンダリングを行わず、子にデータを配る」ことが主題である本記事においては、useContextでstateを参照している子の再レンダリングを発火するデザインを特にアンチパターンと呼ぶ、ということでしたら、なるほど限定的な意味として了解しました。
その上で、私は一般的な意味での「アンチパターン」という語のかなりネガティブな印象に基づいて、
任意の読者がこの記事を読んだ際に「ContextAPIとuseStateを一緒に使うのは基本的にダメなことなんだ」「データを保存する時は基本的にuseStateよりuseRefを使うべきなんだ」と理解するのではないかと思い、
それは違うよと言いたくてコメントしました。
(「アンチパターン」という単語の受け取り方にかかわらず、第1章タイトル「ContextAPI と useState は本来組み合わせてはいけない」や、まとめの「データを保存したいのであれば useRef が正しい選択です。」を見たら大多数の方はそう理解するのではないかと思いますが)
記事の主題ではないところに噛み付かれているとお感じかもしれませんが、
かなり強い表現で断定調で書かれている文章でしたので反応せずにはおれませんでした。
一応動作するテクニックとして"mutable objectをrefでContext 側に持たせつつ、無駄な再レンダリングを防ぐパターン"を紹介する内容であれば、確かにそれでも動きますねでいいのですが、
ほぼ全ての(私には例外が思いつきませんが)ケースで従うべきであろうReactの思想に則ったデザインを「アンチパターン」呼ばわりする記述は悪影響が大きいと思います。
最初の章のタイトルである「ContextAPI と useState(setState)は本来組み合わせてはいけない」も、一般に成り立ちません。むしろuseRefなどを使って純粋関数の世界を破壊するデザインの方が例外でしょう。
これは違います。
useStateを持っているコンポーネント以下のツリー全てが再レンダリングされることです。
記事がContextAPIの話と混ざってしまって申し訳ありませんが、useContextの効果とは関係ありません。
useStateのデータをContextに持たせることで、useContextとは関係なくデータの書き換えがツリー全体の再レンダリングの引き金になっていることがアンチパターンなのです。
以下、サンプルを作りましたので確認ください。
分かりやすいようにuseContextを持たないComponent2を追加しています
横槍失礼しますが、
例において、Dという出力がボタンを押すたびに出力されるという意味であれば、Providerの子要素を再生成しているのが原因なので、これを抑制すれば起きないとおもいます。
useMemoで一度しか生成されないようにしたり、Pageコンポーネントのpropsで受け取ったりする手があります。
サンプルありがとうございます。
なるほどそちらでしたか。
しかしそれに関しても私の主張は同じで、
従って問題ではない(アンチパターンではない)、
です。
これらはどれもReactの思想として正しいはずです。
それが現実にはしばしばパフォーマンス問題を引き起こしますが(それを問題視しておられると思うのですが)、それに対してReactは
React.memo()
を解決策として提供しています。今回の例でも
Component2
をメモ化すればComponent2
の再レンダリングは起きません。親に
ref
を置くことも解決策かもしれませんが、Reactの設計者はそのようなコードを意図していないだろう、ということです。本記事のコードは、Reactの世界の中に無理やり治外法権のstate objectを置いていて違和感があると言っても良いです(そういう意味では Tomohiko Himura氏の「もっとパフォーマンス的に有利な方法があって、状態管理ライブラリは同様のことをしている」はその通りだと思っていて、必要な場面で治外法権なstate objectを持つのはありえます。ただ、それを以て、普通のやり方を「アンチパターン」呼ばわりするのは違うだろう、と思います)
また、上の私の例では
<UserProvider />
が切り出されているので、<UserProvider />
の再レンダリングは子に伝播しないのではないでしょうか(上述の通り、私の手元ではおっしゃる問題は再現しませんでした)。この挙動は非自明ですが、Reactは頭がいいので最適化してくれているのでしょう。もしそうでなくても、同様に子を
React.memo
するのがReact wayだと思います。確かにUserProvider以下では
{children}
が変化しないため、useContextをもつコンポーネント以外は再レンダリングされませんでした。そのため限られたデータに対してContextをuseStateで使う場合はアンチパターンではなく、Yuichiro Tachibanaさんのプログラムはアンチパターンではありませんでした。お詫びいたします。Tomohiko Himuraさんもサンプルありがとうございます。
上記はReactの動作として正しいです。しかし持たなくて良い場所でステートを持つこととは関係しません。
必要の無い再レンダリングが起こらなければ、わざわざメモ化する必要もないからです。
前述でおっしゃるとおり、メジャーどころの状態管理ライブラリは
治外法権のstate object
を持ってくるのが普通なので、普通のやり方というなら現在ではこちらの方が普通です。時代と共に比較上効率が悪いものがアンチパターンになってしまうのは仕方がありません。売り言葉に買い言葉になってしまいますが、むしろrefの例こそ「使わなくて良い場所でrefを使っている」と私には思えます。
私にとっては、useStateを使った例は「Reactの流儀に従い自然な場所でステートを持っている」です。その結果起きうる性能問題はReact側も認識していて、公式に
React.memo
という解決策を提供しています。そこまで含めて、React流です。これも私に言わせれば、「メモ化しておけばわざわざ不自然なrefを使う必要はない」です。
うーん、やはり「普通」「アンチパターン」という言葉の使い方に齟齬があるんですかね…
私にとってはuseStateを使うやり方は普通ですし、状態管理ライブラリ使うのも普通です。要件に応じて使い分けるものです。ちなみに適切なライブラリを使わずに自前で変なrefを使うのが私にとっては一番アンチパターン度が高いです。
また効率悪いと言ってもごく微々たるものです。Reactのクリーンなデザインを破壊する理由にするほどのものではありません。useStateの例がアンチパターンなら、React公式がuseStateをdeprecatedに指定するとか何かドキュメントに書くとかするはずですよね…
もそれ自体は真だと思うのですが、通常のuseStateによるステート管理をアンチパターンと切り捨てるほどの強い解決策ではない、というのが私の主張です。
「それさえ出来ていれば」は言い換えれば「それをやる責任を開発者が意図的に負う」ということです。その獣道に進むことは状況によっては有効かもしれませんが、公式が意図した通りの舗装された道を歩く選択をアンチパターン呼ばわりしないで欲しいということです。後者は性能上の微々たる不利があるかもしれませんが、Reactのライフサイクル管理に乗っかることで、ほとんどの状況でその不利を補って余りあるクリーンなコード、ひいては安全性をもたらします。決して「アンチパターン」などという強い言葉で下に見れるものではないはずです。「必要に応じて最適化の余地がある」くらいが誠実ではないでしょうか。
こちら確認いただきありがとうございます。
この文を見て思ったのですが、ということはここでいう「アンチパターン」というのは、個別のプログラムのrendering状況をinspectorで見るなりconsole.logでチェックするなりして、不要な再レンダリングが起きていたらアンチパターン、そうでなければOK、みたいな意味なのでしょうか。
例えばReduxのhooks機能に関して、内部でContextAPIを使いつつuseStateを使わない組み合わせでStoreデータの管理を行っています。ReduxがuseStateを使っていないことが不自然でしょうか?
React流という用語の定義はよく分かりませんが、例えばReduxで状態管理を行った場合、再レンダリングの抑制にメモ化の必要はありません。それはReact流ではないということでしょうか?
ContextAPIにuseStateのデータを設定することで、パフォーマンスが落ちたり冗長な記述が必要になるのはアンチパターンです。そのためメジャーどころの状態管理ライブラリはuseStateのデータをStoreに使いません。
ContextAPIとuseStateの組み合わせはReact公式のリファレンスやチュートリアルに存在せず、公式の方法ではありません。
はい、その通りです。
どんなに冗長に書いても、小規模なプログラムなら目立たないので問題は発生しにくいです。しかしContextAPIとuseStateの組み合わせはプログラムは規模が大きくなるにつれて、パフォーマンスの劣化とそれに対応するためもメモ化やContextの分離など、本来必要の無いクリーンではないコードの増加を生みます。それがアンチパターンの結果です。
Reduxはまさに良い例ですね。
Redux自体はReactと関係ないデータストアです。それが
react-redux
でReactに接続する際にrefとかが登場したり自前でdispatcherを呼んだりします。React管理下の世界とその外との境界を跨ぐ事になる(副作用やレンダリングタイミングに意識的になる)ので。なので、Redux(や
react-redux
)がuseStateを使っていないことは自然です。React管理下の世界の外側にstoreオブジェクトがあるので。その他上記のコメント全体を通して、問題意識がわかった気がします。
Context分割もメモ化もしたくない、けど再レンダリングは抑制したいんですね。
であればそのような状態管理ライブラリを使うのが解であると主張をされるのではダメでしょうか。それは否定しません。
そして空雲さんは嫌いなようですが、ContextAPIとuseStateだって、適切な分割とメモ化と共に使ってレンダリング抑制の解になります。この辺の前提を飛ばして「ContextAPI と useState は本来組み合わせてはいけない」と主張するのは乱暴だと言っています。
また一方で、そういう状態管理ライブラリを使わずに、Reactを使ったアプリケーションコードの中に小さなredux + react-reduxを再実装するような書き方は一般に勧められないでしょう。それこそアンチパターンと言って良いと思います。
本文にもありますが「構造が少々複雑になります」よね。バグのもとです(空雲さん個人はこれでバグを生まないのかもしれませんが、一般論です)。
ちなみに私も知らなかったのですが、加えて https://qiita.com/uhyo/items/6a3b14950c1ef6974024 こんな問題もあるようです。
Reactが隠蔽してくれていたことを開発者側の責任に引き寄せた=React流(下記参照)から逸脱する弊害です。
Reduxなどのちゃんとメンテナンスされたライブラリを使うなら、アプリケーション開発者にはこれらのデメリットが見えないので特に問題は感じません。
…しかし空雲さんのプロフィールを見ると「車輪の再発明が生業」なんですね。仮に今回の例をそのレベルで肯定されるならもう何も言えません。
すみません勝手に言葉を作りました。Reactの思想に沿った書き方・Reactが隠蔽してくれている機能には素直に乗っておく書き方、くらいの意味でした。
はい。ライブラリ内部はステート管理に関してReact流でないと言えるでしょう。理由は上述の通りです。しかしその部分は隠蔽されているので利用者からは気になりません。
はい。素直にそれらのライブラリを使うのがいいと思います。
私はもう慣れましたが、これが気持ち悪い(Reactはなぜこの問題が解決できないんだ)という気持ちも分からなくはないですが…
とは言っても本記事の例だとContextの分割やメモ化を不要と言って避ける代わりに、本来必要でないrefを使い、開発者に余計な責任が生まれ、複雑な構造が現れますよね。
このトレードオフは無視できません。
分かりづらくてすみませんでしたが、ここは本文の
なども反論の射程に含んだつもりでした。公式が
useState
でデータ保存してるのだから、useRef
がデータ保存のための正しい選択ではないし、それを基ににアンチパターンを語るならそれは違うだろうと。ContextAPIとuseStateの組み合わせに関しては、確かにドンピシャなのは公式チュートリアルにないですね。ここはそのように設計して上手くいっている個人的な経験がベースになります。私はContext分割もメモ化も抵抗ありません。
なるほど。
大規模なReactアプリでパフォーマンス問題が起きうることには同意します。
それをアンチパターンと呼ぶかどうかで私は感覚が違うんだなと思いました。
私にとってこれは大規模化した際に顕在化するパフォーマンス問題で、あくまで程度問題です。最適化の対象にはなりえますが、アンチパターンとまで呼ぶのは抵抗があります。
むしろ「XXの書き方は基本的にダメだ」とか「YYなデザインは基本的にダメだ」が私にとっての「アンチパターン」です。
↓のような文章はまさにそんな感じですので、これらに反応してしまいます。
空雲さんとしては再レンダリング抑制が記事の主題なのにこれらの文章に噛みつかれて不思議かもしれませんが、
少なくとも私には、これらは再レンダリング抑制の文脈に関係なく、一般に忌避すべきパターンとして紹介されているように読めます。そしてそれは違うと反論した次第です。
そして私はコメント2往復目でやっと主題を読み取りました。
パフォーマンス的に劣るとか言う話ならともかく、「お気持ち」の部分は反論しようがありません。
React流という定義も公式が推奨しているものではなく、「お気持ち」や自分がそう感じたものになってしまっています。
ちなみにreact-reduxはuseRefで管理用のオブジェクトを作成して、それらをuseMemoでひとまとめにする実装を行っています。
正しい選択ではないという主張なら、Reduxの正しい実装方法をお教えいただければありがたいです。
一部お気持ちな表現を含めてしまったのは事実ですが、ちゃんとそのお気持ちに至る具体的な理由や、それ以外の合理的な反論やその理由も書いてあるのでそこを無視しないでいただけますかね…
頂いた反論に対する再反論も全て1個前のコメントに既に書いてある内容で事足ります。
これもまあこの言葉自体はそうなんですが、その私なりの意味について「Reactの思想に沿った書き方・Reactが隠蔽してくれている機能には素直に乗っておく書き方」と書きました。それに沿わない場合の具体的な弊害もちゃんと書きました。
それでもこの書き方がそうでない書き方に劣後するというなら、もうプログラマとして信仰している宗教が違うんだろうなと思います。
に対するReduxの正しい実装方法だけは、お教えいただきたいです。
広く使われているものが正しいとは限らないことは当然あるわけで、他に最善の方法があるなら是非知りたいです。
react-reduxは何を使ってデータ保存すれば良いのでしょうか?
Reduxの実装方法に問題はないと思っています。上の私のコメントでもReduxやその他の状態管理ライブラリを批判する記述ないはずです。むしろ「Redux(や react-redux)がuseStateを使っていないことは自然です。」などで肯定しています。
上にも書いた通り、Redux自体はReactとは独立したステート管理ライブラリです。従ってReduxのステート管理機構の実装にReactの知識は入り込みません(
redux
のコードにReact
は登場しません)。それがReactと接続する際の界面でrefやらdispatcherやらを意識的に使うことで、うまくReactから使えるようになっています(
react-redux
の部分)。ここはreact-redux
開発者の責任において、Reactのライフサイクル管理と整合するように注意深く実装されている部分です。私が問題視しているのは
の2点です。
理由は上のコメントをご覧ください。
Reactのステート管理機構は問題ありません。ご自分がいつも使っている方法を公式の方法と捉えていませんか?今回のような内容が先に流行っていたら、なんの疑問も持たずに受け入れていたのではありませんか?
前述したとおり、React公式にはuseStateのデータをContextAPIに入力する方法はありません。Reduxも今回作成したプログラムも「Reactのステート管理機構」の中での実装です。特別なことをしているわけではありません。再実装ではなくReactが用意している標準機能を使っているだけです。
もちろん必要ならライブラリを使うべきです。
今回はContextAPIを直接使う内容なので、アンチパターンを生まない方法を「Reactのステート管理機構」の範囲で記事にしました。
なので出来ればご自分の中での標準ではないという主張では無く、パフォーマンスやコードの冗長化、こういう問題が起こるから使い物にならないなど、実際に起こりうる話をしていただけるとありがたいです。
たとえば以下のような内容です。
時代と共にReduxの実装ですら問題が起こりうるので、こういう内容なら新たな対処法や作り方が模索できます。
えぇ…
って本文で言ってるじゃないですか…
"Reactのステート管理機構"が指す対象が不明瞭でしたかね?
useState
のことです。useRef
は"Reactのステート管理機構"ではありません。これはReactのライフサイクル管理の世界の外側を触るための副作用を伴うエスケープハッチです。react-redux
は、"Reduxというステート管理機構(Reactのステート管理機構ではない)"をReactにrefやらdispatcherやらで接続しているものです。これも上のコメントで既に述べています。以下に再掲します。読めていないんですかね…?
もう売り言葉に買い言葉で返しますが、
そのままお返しします。
今回のような内容がなぜ流行っていないかを考えてください。というか私のここまでのコメントはその理由の説明になっています。
その姿勢は基本的に素晴らしいと思いますが、こと本記事の内容に関しては
react-redux
とかのライブラリはメンテナがその苦労を背負ってくれているので、ライブラリも利用者でいる限りそんな苦労はしなくて済むということです。
はい、言っています。パフォーマンス上、ステートを保存するのはuseStateを使い、データを保存するのにはuseRefが正しい選択です。そして今回のプログラムは、ステートの変化を通知するためにコンポーネント内のuseStateを呼び出しています。これは"Reactのステート管理機構"を使って呼び出しています。"Reactのステート管理機構"に問題があったら出来ない動作です。
大半の読者のお気持ちはその読者にしか分かりません。しかし自分の意見を通すため、調査をしたわけでもない他人の気持ちを勝手に決めつけて、自分の論拠の付け足しにするのはいかがなものでしょうか?
ReactのAPIに沿った書き方というのがReact公式には掲載されておりません。もしそれらのドキュメントがあるなら提示をお願いします。
パフォーマンス上、Contextに格納するデータとしてrefは必要であり(ReduxはuseMemoでとりまとめている)でuseStateは不要です。本来とかAPIに従うというのが度々出てきますが、どれも公式には定められておりません。
また、今回の内容はそれほど複雑な構造はしていませんが、具体的に間違えやすいポイントなどを示していただければと思います。ぜひ、解説記事として取り上げさせてください。
はい、ここにすぐに出せる客観的な裏付けはないです。
私は同じ考え方をするプログラマが相当数いることを信じてこう書きましたが、この議論の参加者間でその点に合意が取れないなら、それに基づいた主張はここで終わりにします。
このコメントを見た第三者の判断材料として残すに留めます。賛同してくれる方がいることを願うばかりです。
ただし当該文章以外の、他の箇条書き部分の主張が変わるものではありませんのでそちらは参照し続けていただき、できればご意見賜れますと。
ただ、ここを深掘りしだすと、
なるべくReactのライフサイクル管理に乗っかって楽をする派(私) vs パフォーマンスのためにはReactのライフサイクル管理に関わる部分に自分で触ること、その結果として表出する複雑性やメンテナンス性の低下を気にしない(複雑と思わない)派(空雲さん)
の価値観のぶつかり合いなんですかね。
この仮説は以下の議論でも補強されると思います。
普通にReactのチュートリアルで紹介されている書き方のことですよ。せっかくReactが自動でやってくれていることには素直に乗っかろうという書き方です。Reactが
const [state, setState] = useState()
というAPIを提供しているのだから、それに乗っかってstate
とsetState
は両方使って再レンダリングはReactに任せようという書き方です。もしContextAPIとuseStateの組み合わせを特別に気にされているのであれば、上に書いた通り、それに関してはドンピシャなのは無いですが、
それを言い出したらデータ管理にrefを使うことを擁護する公式ドキュメントだって無いんじゃないですかね。
で、「データを更新したらviewがリアクティブに更新される」というReactの基本思想に忠実であろうとすれば自然とこうなりませんかね。Reactはその思想を擁護しつつ現実的なパフォーマンス問題に対処するためにメモ化の手段だって提供しています。
あと、これは補助的なものですが、"Don’t Overuse Refs"というメッセージもあります。これの本文の内容自体はこの議論と直接関連しないのですが、これもあり私はrefの濫用を避けよう(同じ問題を解決できるならrefを使わない策を選ぼう)という基本姿勢でいます。
そこでパフォーマンスを優先して、あえてReactがやっていくれている裏側の仕事にrefやらdispatcherやらで触ろうというのは、refやらレンダリングサイクルやらを意識できる上級者が思いつけるハックだと感じます。
上でも述べましたが、それが動くことは理解しますが、メリットとデメリットの天秤が釣り合っていないと感じます。
だって微々たるパフォーマンス改善のためにReactを使うことの恩恵を一つ手放しているんですよ?
(「メリットとデメリットの天秤が釣り合っていない」は私の「お気持ち」です。一方でここに合意できないならそれもまた「お気持ち」です。価値観のぶつかり合いといった所以です。「微々たる」とかも掘り下げれば詳しく説明できますが最終的にはお気持ちとか経験ベースの話に落ち着くんだと思います。)
あー、理解しました。その文章での"ステート"と"データ"はそういう使い分けでしたか…
では
そこについては対話を1往復巻き戻して、その前の私のコメントで書いた
という文章を説明しなおすことで再反論とさせていただきます。
この文章は、まさに今回仰った
において、useStateは変化の通知だけに使い、データを別のところに保存している方法を「そのような再実装」と表現しています。
それと対比される"Reactのステート管理機構"は、本来useStateを使って呼び出す、ステートの保存・変更検知・componentの再レンダリングを自動で行う機構という意味です。
この二つを対比して、後者を劣後させることを問題にしています。
例えば子で"mutable objectへのmutation"と"dispatch"の2つを同時にしなければならない部分。useState使っていれば1回の関数呼び出しで終わるところ、裏側の知識が剥き出しになり関心ごとが増えてしまいます。componentが増えてきたらdispatch忘れとかありそうですよね。
それならカスタムフック作れば…となりますが、どんどんライブラリっぽくなってきましたね。じゃあ既存のよくできた状態管理ライブラリ使えば良くないですか。
もちろんありません。「ReactのAPIに沿った書き方」という定義されていないことを標準のように語られていたのでそこを指摘させていただきました。
ContextAPIでuseState(useReducer)を使う場合、useContext以降でステートの変更を行うため、以下のような書き方を見かけます。Contextにdispatcherを含めて配ることによって、コンポーネント外からstateを変更するものですが、こちらの実装も駄目と言うことでしょうか?
別々に行う方法に不満があれば、記事中で紹介している
こちらの方は同じuseStateの[state,setState]の組み合わせで使用しています。通知だけに使用するやりかたも追加で紹介していますが、きちんと両方書いていますのでお読みくだされば幸いです。
また、続きとして書かせていただいた
の方でも同様で、最終的にはコンポーネント内で作られたsetStateに値を設定し利用する構造です。
はい、こちらも通知を別にする書き方の方のみに当てはまる内容です。useRefとContextAPIの組み合わせで必ず発生する問題ではありません。
はい、その通りです。
この流れは不自然では無いと考えます。
という話なので、状態管理ライブラリを採用するとっかかりになるのならそれで良いのではないでしょうか?
結構無視されてしまった部分があるので、まず私が核心だと思っているトピックを整理して再掲します。
まず、私は↓と思っています。
(補足): 1.と2.は状況・要件によって使い分けるもので一般化できる優劣はない。1.をアンチパターン呼ばわりするのはおかしい。
そして上のコメントで、3-1.について「勝手に大半の読者の気持ちを推測するな」と言われてしまいました。元々私は3-1はプログラマの一般的な価値観だと思っていて、ここは合意できると期待していたのですがダメでしたね。浮雲さんは車輪の再発明が生業だからでしょうか。ここがダメだと、この議論は「根本的なところで合意できないということ」に合意して終着な気がしています。
ちなみに3-1以外も、それぞれの理由をこれまで掘り下げてきたわけですが、そのまま行っても、どこか基本的に価値観のレベルで合意できないところに辿り着く気がしています。
ここまでが核心だと個人的には思っているのですがいかがでしょうか?
ちなみに「Reactの基本思想」みたいなまた嫌われそうな言葉を使ってしまいましたが、Why did we build React?の内容をもとに私はそう理解しています。
以下は直前のコメントに対する各論の返信です。
これはいいと思いますよ。
この
state
が書きかわったら、このProviderの下にいるConsumers (useContext
しているコンポーネントたち)はReactが自動で再レンダリングしますよね。私の主張通りです。より正確にいうと以下の流れですが。説明のために全体を
SomeProvider
で囲みます。state
が変化SomeProvider
が再レンダリングされる{state, setState}
)が変化するので、Consumersが再レンダリングされる。また、この書き方がOKなら、本文第1章タイトル「ContextAPI と useState は本来組み合わせてはいけない」はやはり間違い、 もしくは仮にそれ以外の意図があったとしてもミスリーディングではありますよね。
これは
state
とsetState
を持つ。setState
をContext経由で親に上げ、親がそれを呼ぶということですよね。
これはReactの教条主義的に反論するなら、子の操作を親に持ち上げている点がReactが推奨するパターンに反するので問題です。
図らずも前のコメントでリンクしたDon’t Overuse Refsはむしろこの論点に近いです。何か操作を行うためのAPIを子が外部に公開するより、親にstateを持たせてそれを下に降ろすことをまず考えてください。リンク先は、refがそのような用途に利用されがちなのでrefを対象にした文章ですが、原則は同じです。原則とはLifting State Upの内容です。
もし「それでは親の再レンダリングが起きる」という反論があれば、私は「それがReactの基本思想に従う書き方であり、メリットが最も大きいデザインなので問題ない」と再反論します。おそらくここまで何度もやってきた話ですし、本コメントの最初に書いた1.の意見の通りです。この点で合意に至れないならこれ以上の深掘りは無理かなと思います。
はい、そのくらいの姿勢であれば賛同します。
本文には「状態管理ライブラリを採用するとっかかりとして読んでね」というガイドがなく、アプリケーション開発者が自分でこの書き方をするのを薦めるように読めたので問題だと思った次第です(上記論点3-1.)。
ちなみに、メモ化が面倒という点はReact側も認識していて、自動でメモ化してくれるコンパイラ(React Forget)を実験中らしいです。
React without memo (YouTube)
逆説的に、今現在は申し訳ないけど面倒でも手動でメモ化してくれ、というのがReact本体のデザインです。React Forgetがこの問題を解決してくれるといいですね。
後続の私の書き込みの「トランジションサンプル」で、問題なく動作することが確認出来ましたのでご安心ください。
何を選択するかは各々の自由です。ただ、パフォーマンス的に問題のある書き方だと認識すら出来ないのは問題です。認識できなければ選択することは出来ません。
取捨選択は各々で行うべきことです。ライブラリ開発者以外はスキルが無いから悪影響というのは、あまりに他人を下に見過ぎではないでしょうか?プログラマの一般的な価値観やスキルは、我々が勝手に決めつけて良いものではありません。
時代が流れてReact18でSSR-Streamingとか、けっこう複雑な方へ流れていってます。ご興味があれば、是非こちらをお読みください。
いえ、Contextでディスパッチャーを配っていることに問題は無いのかという話です。これに問題が無いなら、「無駄な再レンダリングを起こさないパターン」でも、ディスパッチャーを配ってコンポーネント外で値を入れてもらうという同じ動作を行っています。
fetchなどで外部データを受け取ったり、windowイベントを受け取ってステートをセットしたりと、Webアプリを作る上では多くのケースでコンポーネント外からイベントを受け取ってステートを書き換える構造になっています。ディスパッチャーの含まれた関数を渡して、イベント発生時にステートを書き換えてもらうわけです。結局やっていることは同じです。
それがこちらの文章に続くわけです。
Lifting State Up
お気づきいただけましたか?一番最後の「Lessons Learned」がわかりやすいと思います。つたない要約で申し訳ありませんが、「stateを中継していくことでコンポーネントの変化が可視化され、デバッグが容易になる」と書かれています。要約に問題があればツッコミを入れてください。
これに従うと、そもそものところでContextAPIの使用すら想定外の運用になってしまいます。当たり前と言えば当たり前です。これが書かれた頃はContextAPIが存在しなかったのですから。
面白い技術ですね。逆に混沌としそうですが、そういうのをいじるのは大好きです。
ご返信ありがとうございます、なのですが各論はもういいんじゃないかと思いました。頂いた内容についての返信を下書きしたら、ほぼ今までのコメントの焼き直しになってしまいました。
永遠に話が噛み合わずにループするんだと思います。
全ての論点がここに行き着いた気がします。
再度書きますが、この議論は「根本的なところで合意できないということ」に合意して終着な気がしています。
お付き合いいただきありがとうございました。
こちらこそ、ありがとうございました。
また何かあればよろしくお願いいたします。
時間の出来たときに検証しますが、React18のトランジションに関しては
○「無駄な再レンダリングを起こさないパターン」のサンプル
× 「データを Context 側に持たせつつ、無駄な再レンダリングを防ぐパターン」のサンプル
で、コンポーネント内のuseStateを通していない後者の書き方が引っかかります。
こちらの記事の書き方は動くと思われますが、まだ未検証です
「無駄な再レンダリングを起こさないパターン」のサンプルに
こちらのトランジションのサンプルを拝借して正常動作するように合成してみました useRef内にディスパッチャーをため込んでデータを配ってますが、最終的にはコンポーネント内のstateを変更しているので問題なく動きます
codepenだと何故かreact@18設定で動かなかったので、codesandboxに入れています
ベータですが、一応公式が新しく書き直しているドキュメントを貼っておきます。ご参考まで。
@Yuichiro Tachibana さんがこんなに一生懸命に話しても平行線なので、もはや宗教なのかなと思いつつ、やはりアプリ開発の初心者がこの記事を見たら有害なんじゃないかなぁという感想だったのでコメントします。
そもそも不必要な(筆者の言葉を借りましたが、推測するにDOMの変更が必要ない再レンダーだと思っています)を過剰に忌み嫌っている気がありますが、それは親から子へ一方向に状態を伝え、状態が変わればコンポーネントツリーがリアクティブに変わる、というReactの設計思想そのものへの否定のように思えます。
強く言いたいのは再レンダーがたちまちパフォーマンスの低下を起こすわけではなく、本来の設計思想として許容されるべきものです。
中にはパフォーマンスが低下する場合もある、というのが正しく、もしパフォーマンスが低下した場合にも対処策としてmemoのようなAPIが用意されています。一方再レンダーの回避策としてのref、という話をreactアプリケーションという文脈で聞いたことがありません。
reduxの中でそのようなテクニックが使われているからアプリケーションでも使っていいでしょう、とはならないと思います。reduxは react と切り離された世界の状態管理ライブラリですので、その閉じた世界で、かつ、きちんとメインテナーがいる中で、reactとは違う状態管理を作るのは全くおかしなことではありません。そしてreduxとreactとの接続口としてrefなどが使われる、だけの話です。
一方、この記事の想定読者はライブラリ開発者ではなくreactアプリケーション開発者だと思われ、そういった開発者はreact流の状態管理(useStateやuseReducer)に乗っかるのが賢明だと思います。
親のstateの変更が子孫に伝わっていき、子孫の宣言的なviewが状態に応じて変わってくれる、親から子への状態の一方向性がReactの良さであって、提案されているトリッキーな書き方は、子が親の世話をしなければいけない、伝達が逆流するという意味でそれこそアンチパターンに見えます。自分がレビューするなら絶対にapproveしない実装でした。
親の再レンダーを回避しつつ子供のstateを更新する、一つのテクニックとしてこの方法を提案するなら何も言わなかったと思いますが、親がuseStateを使って子供を再レンダーすることがたちまちアンチパターンと言うなら、それは大きな誤解を初心者に与えて、奇妙なアプリケーション実装を世に生み出すことになるので、それは回避したいと思います。
私は@Yuichiro Tachibanaさん側の意見を持っています。
最近Reactのドキュメントが一新されましたが、useContext経由で渡されたデータの更新には「state」を利用すると明示されています。
@kazukinagataさんのいう通り、1つの方法として紹介するのではなく、広く使われており、公式でも使用されている書き方をアンチパターンとするのは、誤解を生むタイトルだと感じます。
@Yuichiro Tachibanaの提示したユーザー認証においてContextの値を参照していない孫要素が再レンダリングにより発生する不都合 >>> 公式やReactの思想を無視したトリッキーな書き方だと思います。
reduxの真似事をしたいのであればreduxを使えば良いのです。
再レンダリングが重くなるコンポーネントは特殊なケースのUIだけで、それらについてもuseMemoやreduxのuseSelector使えばいいと思います。
また、よほど重いコンポーネントでない限り再レンダリングすべきかどうかの比較関数よりも、再レンダリングしちゃった方が軽量です。