原文アドレス:Were React Hooks a Mistake?
ウェブ開発コミュニティは最近数週間、信号について話し合っています。これは非常に効率的な UI 更新を実現する反応型プログラミングパターンです。Devon Govett は、信号と可変状態について考えさせられるTwitter スレッドを書きました。Ryan Carniato は、信号と優れた記事を比較して応答しました。
議論から明らかになったことの一つは、多くの人々が React のプログラミングモデルに適応できていないということです。なぜでしょうか?
私が考えるに、問題は人々のコンポーネントに対するメンタルモデルと、React におけるフックを使用した関数コンポーネントの動作が一致していないことにあります。私は大胆な声明をします:人々は信号を好むのです。なぜなら、信号に基づくコンポーネントは、フックを使用した関数コンポーネントよりもクラスコンポーネントにより似ているからです。
少し戻りましょう。React コンポーネントは以前、次のように見えていました。
class Game extends React.Component {
state = { count: 0, started: false }
increment() {
this.setState({ count: this.state.count + 1 })
}
start() {
if (!this.state.started) setTimeout(() => alert(`あなたのスコアは ${this.state.count} です!`), 5000)
this.setState({ started: true })
}
render() {
return (
<button
onClick={() => {
this.increment()
this.start()
}}
>
{this.state.started ? '現在のスコア: ' + this.state.count : '開始'}
</button>
)
}
}
各コンポーネントは React.Component クラスのインスタンスです。状態は state プロパティに保存され、コールバックはインスタンス上のメソッドです。React がコンポーネントをレンダリングする必要があるとき、render メソッドを呼び出します。
あなたは今でもこのようなコンポーネントを書くことができます。構文は削除されていません。しかし、2015 年に React は無状態関数コンポーネント(stateless function components)という新しいものを導入しました。
function CounterButton({ started, count, onClick }) {
return <button onClick={onClick}>{started ? '現在のスコア: ' + count : '開始'}</button>
}
class Game extends React.Component {
state = { count: 0, started: false }
increment() {
this.setState({ count: this.state.count + 1 })
}
start() {
if (!this.state.started) setTimeout(() => alert(`あなたのスコアは ${this.state.count} です!`), 5000)
this.setState({ started: true })
}
render() {
return (
<CounterButton
started={this.state.started}
count={this.state.count}
onClick={() => {
this.increment()
this.start()
}}
/>
)
}
}
当時、これらのコンポーネントには状態を追加する方法がありませんでした —— それはクラスコンポーネントに保持され、props として渡される必要がありました。アイデアは、大多数のコンポーネントは無状態であり、木の頂近くのいくつかの有状態コンポーネントによってサポートされるというものでした。
しかし、クラスコンポーネントを書くとき、状況は少し厄介でした。有状態ロジックの構成は特に厄介です。たとえば、ウィンドウのリサイズイベントをリッスンするために複数の異なるクラスが必要な場合、どうすればよいでしょうか?各インスタンスメソッドを再定義しないといけないのでしょうか?それらがコンポーネントの状態と相互作用する必要がある場合は?React はこの問題を mixin で解決しようとしましたが、チームはすぐに欠点に気付きました。
さらに、人々は本当に関数型コンポーネントが好きでした!状態を追加するためのライブラリさえありました。したがって、React が組み込みの解決策を提案したのは驚くべきことではありません:フックです。
function Game() {
const [count, setCount] = useState(0)
const [started, setStarted] = useState(false)
function increment() {
setCount(count + 1)
}
function start() {
if (!started) setTimeout(() => alert(`あなたのスコアは ${count} です!`), 5000)
setStarted(true)
}
return (
<button
onClick={() => {
increment()
start()
}}
>
{started ? '現在のスコア: ' + count : '開始'}
</button>
)
}
私が初めてフックを使おうとしたとき、それは本当に啓示でした。フックは、振る舞いをカプセル化し、有状態ロジックを再利用することを確かに容易にしました。私は躊躇せずに飛び込みました;それ以来、私が書いた唯一のクラスコンポーネントはエラーバウンダリだけです。
とはいえ - 一見するとこのコンポーネントは上記のクラスコンポーネントと同じですが、重要な違いがあります。おそらくあなたは気づいているでしょう:UI のスコアは更新されますが、アラートが表示されると常に 0 が表示されます。なぜなら、setTimeout は最初に start が呼ばれたときにのみ発生し、初期のカウント値を閉じ込めているからです。そのため、常にその値しか見ることができません。
あなたは useEffect を使ってこの問題を解決できると思うかもしれません:
function Game() {
const [count, setCount] = useState(0)
const [started, setStarted] = useState(false)
function increment() {
setCount(count + 1)
}
function start() {
setStarted(true)
}
useEffect(() => {
if (started) {
const timeout = setTimeout(() => alert(`あなたのスコアは ${count} です!`), 5000)
return () => clearTimeout(timeout)
}
}, [count, started])
return (
<button
onClick={() => {
increment()
start()
}}
>
{started ? '現在のスコア: ' + count : '開始'}
</button>
)
}
このアラートは正しいカウントを表示します。しかし、新たな問題が発生します:もしあなたがボタンを連打すると、ゲームは永遠に終了しません!効果関数のクロージャが「古く」ならないように、count と started を依存配列に追加しました。それらが変更されるたびに、更新された値を見る新しい効果関数を得ます。しかし、その新しい効果は新しいタイムアウトも設定します。ボタンをクリックするたびに、アラートが表示される前に 5 秒間の新鮮な時間を得ることができます。
クラスコンポーネントでは、メソッドは常に最新の状態にアクセスできます。なぜなら、クラスインスタンスへの安定した参照を持っているからです。しかし、関数コンポーネントでは、各レンダリングごとに自身の状態を持つ新しいコールバックが作成されます。関数が呼び出されるたびに、それは自身のクロージャを持ちます。将来のレンダリングは過去のレンダリングの状態を変更することができません。
言い換えれば:クラスコンポーネントは、マウントされた各コンポーネントに対して 1 つのインスタンスしか持ちませんが、関数コンポーネントは複数の「インスタンス」を持ちます —— 各レンダリングが新しいものを作成します。フックはこの制約をさらに強化しました。これは、使用中に遭遇するすべての問題の根源です:
- 各レンダリングは自身のコールバック関数を作成します。これは、useEffect やその兄弟のように、実行する副作用の前に参照の等価性をチェックするものがあれば、あまりにも頻繁にトリガーされます。
- コールバック関数は、それらが所属するレンダリングの状態とプロパティを閉じ込めます。これは、useCallback、非同期操作、タイムアウトなどの理由で、複数のレンダリング間で持続するコールバック関数が古いデータにアクセスすることを意味します。
React はこの状況に対処するための逃げ道を提供します:useRef。これは、レンダリング間で安定したアイデンティティを持つ可変オブジェクトです。私はこれを、同じマウントされたコンポーネントの異なるインスタンス間で値を行き来させる方法だと考えています。このアイデアを持って、以下はフックを使用した私たちのゲームがどのように見えるかです:
function Game() {
const [count, setCount] = useState(0)
const [started, setStarted] = useState(false)
const countRef = useRef(count)
function increment() {
setCount(count + 1)
countRef.current = count + 1
}
function start() {
if (!started) setTimeout(() => alert(`あなたのスコアは ${countRef.current} です!`), 5000)
setStarted(true)
}
return (
<button
onClick={() => {
increment()
start()
}}
>
{started ? '現在のスコア: ' + count : '開始'}
</button>
)
}
これは面倒です!私たちは今、2 つの異なる場所でカウントを追跡しなければならず、私たちのインクリメント関数はそれらを同時に更新しなければなりません。それが機能する理由は、各スタートクロージャが同じ countRef にアクセスできるからです;私たちが 1 つのクロージャでそれを変更すると、他のすべてのクロージャも変更された値を見ることができます。しかし、私たちは useState を useRef に依存させることから逃れることはできません。なぜなら、参照の変更は React を再レンダリングさせないからです。私たちは、UI を更新するための不変の状態と、現在の状態を持つ可変の参照の間で 2 つの異なる世界に閉じ込められています。
クラスコンポーネントにはこの欠点はありません。マウントされた各コンポーネントはクラスインスタンスであり、私たちに内蔵の参照方法を提供しました。フックは私たちに有状態ロジックをより良く組み合わせるために必要なインフラを提供しましたが、その代償も伴いました。
もし私たちが Solid を使用して小さなカウンターゲームを再構築するなら、それは次のようになります:
function Game() {
const [count, setCount] = createSignal(0)
const [started, setStarted] = createSignal(false)
function increment() {
setCount(count() + 1)
}
function start() {
if (!started()) setTimeout(() => alert(`あなたのスコアは ${count()} です!`), 5000)
setStarted(true)
}
return (
<button
onClick={() => {
increment()
start()
}}
>
{started() ? '現在のスコア: ' + count() : '開始'}
</button>
)
}
それは最初のフックバージョンとほぼ同じように見えます!唯一の目に見える違いは、useState の代わりに createSignal を呼び出し、count と started は値にアクセスするたびに呼び出す関数であることです。しかし、クラスコンポーネントと関数コンポーネントのように、見た目の類似性は重要な違いを隠しています。
Solid や他の信号ベースのフレームワークの重要な点は、コンポーネントが一度だけ実行され、フレームワークが信号が変更されたときに DOM を自動的に更新するデータ構造を設定することです。コンポーネントが一度だけ実行されるということは、私たちには 1 つのクロージャしかないということです。1 つのクロージャしかないことは、マウントされた各コンポーネントに安定したインスタンスを提供します。なぜなら、クロージャはクラスに相当するからです。
何ですって?
これは本当です!根本的に、彼らはデータと振る舞いの束です。クロージャは主に振る舞い(関数呼び出し)であり、関連するデータ(閉じ込められた変数)を持っていますが、クラスは主にデータ(インスタンスプロパティ)と関連する振る舞い(メソッド)です。もし本当に望むなら、どちらか一方を使ってもう一方を書くことができます。
考えてみてください。クラスコンポーネントを使用すると...
- コンストラクタはコンポーネントのレンダリングに必要なすべてのもの(初期状態の設定、インスタンスメソッドのバインドなど)を設定します。
- 状態を更新すると、React はクラスインスタンスを変更し、render メソッドを呼び出し、DOM に必要な変更を加えます。
- すべての関数はクラスインスタンスに保存された最新の状態にアクセスできます。
そして信号コンポーネントを使用すると...
- 関数本体はコンポーネントのレンダリングに必要なすべてのもの(データフローの設定、DOM ノードの作成など)を設定します。
- 信号を更新すると、フレームワークは保存された値を変更し、信号に依存するものを実行し、DOM に必要な変更を加えます。
- すべての関数は関数のクロージャに保存された最新の状態にアクセスできます。
この観点から見ると、トレードオフが見えやすくなります。クラスと同様に、信号は可変です。これは少し奇妙かもしれません。結局のところ、Solid コンポーネントは何も割り当てていません —— それは setCount を呼び出しました、React と同じように!しかし、count は値そのものではなく —— それは信号の現在の状態を返す関数です。setCount を呼び出すと、それは信号を変更し、count () へのさらなる呼び出しは新しい値を返します。
Solid の createSignal は React の useState のように見えますが、信号は実際には参照に似ています:可変オブジェクトへの安定した参照です。違いは、不可変性の周りに構築された React では、参照は逃げ道であり、レンダリングには影響しません。しかし、Solid のようなフレームワークは信号を最優先にします。フレームワークはそれらを無視せず、変更時に反応し、その値を使用する DOM の特定の部分のみを更新します。
この状況がもたらす重大な結果は、UI がもはや状態の純粋な関数ではなくなることです。これが React が不可変性を受け入れた理由です:それは状態と UI の一貫性を保証します。変異が導入されると、UI を同期させる方法も必要です。信号はこの目標を達成するための信頼できる方法になることを約束し、その成功はその約束を果たす能力によって決まります。
簡単にまとめると:
- まず、私たちはクラスコンポーネントを持ち、レンダリング間で単一のインスタンスに状態を保持します。
- 次に、フックを持つ関数コンポーネントがあり、各レンダリングは独自の隔離されたインスタンスと状態を持っています。
- そして今、私たちは信号に移行し、再び単一のインスタンスに状態を保持します。
では、React フックは間違いだったのでしょうか?それらは確かにコンポーネントを分解し、有状態ロジックを再利用することを容易にしました。私がこれらの言葉を打っているとき、もしあなたが私にフックを放棄してクラスコンポーネントに戻るかどうか尋ねたら、私はそうしないと答えます。
同時に、私は信号の魅力が、以前クラスコンポーネントを使用していたときに持っていた機能を再び得ることにあることも認識しています。React は不可変性に大胆な試みをしましたが、人々はデータを不可変に保ちながら操作する便利な方法を探し続けていました。これが、immer や MobX のようなライブラリが存在する理由です:可変データを使用する人間とコンピュータの相互作用は非常に便利であることが証明されています。
信号はフックよりも優れていますか?私はそれが正しい質問ではないと思います。すべての事柄にはトレードオフがあり、私たちが信号に対して行ったトレードオフはかなり明確です:それらは状態の不可変性と UI を純粋な関数として放棄し、より良い更新性能と各マウントされたコンポーネントの安定した可変インスタンスを得ました。
時間が経てば、信号が React が解決しようとした問題を取り戻すかどうかがわかります。しかし今のところ、フレームワークはフックの組み合わせ性とクラスの安定性の間で快適なポイントを探そうとしているようです。少なくとも、これは探求する価値のある選択肢です。