Hooksという魔法のせいでなんだかステート(状態)を関数が持てるような錯覚を起こし、
バグで詰まってしまった愚か者がいるらしいですよ。(私のことです)
下記、検証や考察で仮説を建てたものなので誤り等あるかもしれません。
その際はコメントにて指摘いただけると嬉しいです🙇♂️
React FC事件簿
問題が起きていたコードをものすごく簡略化したのが下記のコードになります。
import React, { useEffect, useState } from "react";
import "./styles.css";
export default function App() {
const [counter, setCounter] = useState(0);
const [madeDom, setMadeDom] = useState(null);
const counterCheck = () => {
alert(`カウントは${counter}`);
};
useEffect(() => {
setMadeDom(<ChildComp handler={counterCheck} />);
}, []);
return (
<div className="App">
現在カウンターは {counter}
<button onClick={() => setCounter(counter + 1)}>カウントUP</button>
{madeDom}
</div>
);
}
function ChildComp({ handler }) {
return <button onClick={handler}>子供カウントチェック</button>;
}
親コンポーネントは初回レンダリングの際に useEffect
にてmadeDom
を作成し、それを
useState
で保持しています。
またmadeDomには、親コンポーネントのメソッド counterCheck
を渡しています。
こちらは親のステート counter
を表示するメソッドです。
親コンポーネントはビューとして
・自身のカウンター、
・カウントアップ用ボタン
・生成したmadeDom
を表示させています。
初回はこんな塩梅ですね。
そしてカウントUPさせたのち、子供カウントチェックボタンを押すと....
あれ、、、
同じcounterを保持しているはずなのに親と子で異なる結果となりました。
もっと正確にいうなら、子に渡したメソッドから参照するcounter
は増加せず、
親からダイレクトに表示しているcounter
のみのカウントが増加しています。
ここで理由が即座に説明できる方には、もしかしたらこの記事を読む必要はないかもしれません。
ですが、私と同じような混乱を感じている方は引き続きお付き合いいただけたらと思います。
Classは状態がありますので
上記と全く同じ実装をClassでやってみましょう。
import React, { Component } from "react";
import "./styles.css";
export default class App extends Component {
constructor(props) {
super(props);
this.countCheckHandler = this.countCheckHandler.bind(this);
this.state = {
counter: 0,
madeDom: null
};
}
countCheckHandler() {
alert(`カウントは${this.state.counter}`);
}
componentDidMount() {
this.setState({
madeDom: <ChildComp handler={this.countCheckHandler} />
});
}
render() {
return (
<div className="App">
現在カウンターは {this.state.counter}
<button
onClick={() => this.setState({ counter: this.state.counter + 1 })}
>
カウントUP
</button>
{this.state.madeDom}
</div>
);
}
}
class ChildComp extends Component {
render() {
return (
<button
onClick={() => {
this.props.handler();
}}
>
子供カウントチェック
</button>
);
}
}
クラスに変えただけなのですが、子コンポーネント経由で増加分を正しく表示できています。
繰り返しになりますが、クラスは内部情報を持ちますが関数は持ちません。
少し真相に近づいてきました。
classコンポーネントが見ているデータ先
実際にthis
で確認してみましょう。 渡しているメソッドにconsoleを付け加えます。
countCheckHandler() {
+ console.log(this);
alert(`カウントは${this.state.counter}`);
}
意図していた通り親コンポーネントの App
が参照されています。
なので、ハンドラー内で呼び出している this.state.counter
は間違いなく Appのcounterが呼び出されています。
【Classでの内部状態考察】
では関数コンポーネントではどうでしょうか。
【Funcでの内部状態考察】
このように、生成時のみCounterをコピーしてくる形になるので、その後Appの情報が増えてもChildCompは知らんべ、ということのようです。
なぜなら、関数は内部状態を持たないので(本日n回目)、Appのcounter状態をみる、という芸当はChildComp側はできないわけです。
じゃあuseStateってなんなんだ! propsってなんなんだ! 状態みたいなの保ててるんだけど!?!?!?
と混乱したところで、そもそもHooksで表現しているStateの仕組みって何よ、ってところをおさらいします。
そもそもHooksってどうやって状態を表現しているんだろう。
https://daveceddia.com/intro-to-hooks/#the-magic-of-hooks
上記をぜひ読んでください。
...だけでは味気ないので、ゆる〜く超意訳してみます。
Reactがfunctionコンポーネントを初めてレンダリングする際、オブジェクトを生成します。
このコンポーネントのオブジェクトはDOMに存在し続ける限りずっと生き続けます。
Reactはこのオブジェクトを使用して、色々なメタデータを扱っているわけですね。
また、コンポーネントは自分でレンダリングするのではなく、Reactが呼び出すことでレンダリングされます。
コンポーネント自体は返すものは、DOMノードに変換可能なオブジェクト構造でしかありません。
このReactが呼び出すための準備の際にstateがセットアップされます。
function AudioPlayer() {
const [volume, setVolume] = useState(80);
const [position, setPosition] = useState(0);
const [isPlaying, setPlaying] = useState(false);
}
(コードは記事からそのまま拝借しました)
このように3回useState
が呼び出された場合、Reactは3つの値を配列に入れていきます。
次にレンダーされる場合、この3つのhooksは常に同じ順番で呼び出されます(呼び出し順は常に同じでなくてはならない、というhooksのルールを思い出してください。)そして、新しい状態を作る代わりに、2回目のレンダーではそのポジションにある値を返します。
これがReactが変数がスコープ外の複数の関数の呼び出しがあってもステートを作成・維持できる方法です。
単にオブジェクトであるというのがミソですね。
hooksで起きた問題箇所を詳しく調べる
少しコードサンプルを変えて色々検証してみます。
import React, { useEffect, useState } from "react";
import "./styles.css";
export default function App() {
const [count, setCount] = useState(0);
// 1つ目は変数に格納したものを表示させる
const myChild = <Child count={count} />;
//2つ目は初回レンダリング次のみ生成し、それを保持する
const [myChild2, setMyChild2] = useState(null);
useEffect(() => {
setMyChild2(<Child count={count} />);
}, []);
return (
<div className="App">
<button
onClick={() => {
setCount(count + 1);
}}
>
UP
</button>
<Child count={count} />
{myChild}
{myChild2}
</div>
);
}
function Child({ count }) {
return (
<>
<p>カウントは{count}</p>
<button
onClick={() => {
alert(count);
}}
>
カウントチェック
</button>
</>
);
}
わざわざ子コンポーネントに確認用alertのハンドラーを追加しているのは、
レンダリングはされていないが内部の情報は更新されているかも?という疑いを検証するためです。
このようにuseEffect
で初回レンダリングで生成しているChild
コンポーネントのみ、
親のcountを追えていないことがわかります。
もちろん、アラートでの表示も同様でした。
つまりuseEffectで初回のみmyChild2
を再計算させているため、
useStateで生成されたオブジェクトが追えていない、ということのようです。
本来であれば
・count
が変わる
・App内が再計算される
・Childコンポーネントのprops も再計算される
という流れがうまく働いていなかったことが原因でした。
まとめと書簡
今後useEffect内でコンポーネントを生成する場合、
再計算されないこと/そして関数である故に、propsの値などを直に参照できていると思い込まないことに注意していこうと思います。
また、検証に当たってreact内の実装をガツガツ読めるようになった方がより検証しやすいな〜と思ったので、
もっと実装を直でガツガツ読めるようになりたいです..(途中までコード追ってたのですが挫けました)
改善しました
import React, { useState } from "react";
import "./styles.css";
export default function App() {
const [counter, setCounter] = useState(0);
const counterCheck = () => {
alert(`カウントは${counter}`);
};
return (
<div className="App">
現在カウンターは {counter}
<button onClick={() => setCounter(counter + 1)}>カウントUP</button>
<ChildComp handler={counterCheck} />
</div>
);
}
function ChildComp({ handler }) {
return <button onClick={handler}>子供カウントチェック</button>;
}
実際はもっと複雑だったのですが、上記のような形で
レンダリングにChildCompを書き込むことで、問題なくカウンターを呼び出すことができるようになりました。
または、useEffectの第二引数に依存する変数を指定することでも改善できます。
import React, { useEffect, useState } from "react";
import "./styles.css";
export default function App() {
const [counter, setCounter] = useState(0);
const [madeDom, setMadeDom] = useState(null);
const counterCheck = () => {
alert(`カウントは${counter}`);
};
useEffect(() => {
setMadeDom(<ChildComp handler={counterCheck} />);
}, [counter]);
return (
<div className="App">
現在カウンターは {counter}
<button onClick={() => setCounter(counter + 1)}>カウントUP</button>
{madeDom}
</div>
);
}
function ChildComp({ handler }) {
return <button onClick={handler}>子供カウントチェック</button>;
}
めでたしめでたし。