大きなプロジェクトに携わっていたのですが、レンダリングやメモ化をしっかり理解してリファクタリングをする必要があると感じたためReactのレンダリングについて学び直しました。
参考にしたのは以下の書籍です。英語になりますが、かなりいい内容だったのでおすすめです。特に中級者くらいの方!
レンダリングについて
Reactが初めてコンポーネントを作る時を mounting と言います。
この時にstateの初期化やフックを実行したりDOMに要素を追加したりします。
反対にReactがコンポーネントを不要と判断したときに unmounting が行われます。コンポーネントインスタンスに紐づいたものをクリーンアップします。
re-rendering(再レンダリング) は、新しい情報と共にReactがコンポーネントを更新します。mountingと比較してすでに存在しているコンポーネントを再利用するので軽くすみます。
この再レンダリングはReactを理解する上でとても重要になります。
最初にレンダリングされたコンポーネント内のすべてのコンポーネントを取得し、それらを再レンダリングし、さらにその内部にネストされたコンポーネントも再レンダリングしていきます。
ここで重要なのは、Reactはコンポーネントを再レンダリングする際に レンダリングのツリーを「上」にさかのぼることは決してない という点です。
もし状態(state)の更新がコンポーネントツリーの途中で発生した場合、その地点より「下」にあるコンポーネントだけが再レンダリングされます。
つまり子コンポーネントのみが再レンダリングされます。
propsの受け渡しとレンダリング
propsが変わるとがコンポーネントが再レンダリングされると聞いたことがあるかもしれません。しかし propsが変わるとコンポーネントが再レンダリングされるのは間違いです。 通常のReactの動作では、状態(state)の更新がトリガーされると、そのコンポーネント内のすべてのネストされたコンポーネントが、props に関係なく再レンダーされます。
逆に、状態の更新がトリガーされなければ、props を変更してもReactはそれを認識せず、その変更は無視されます。
つまり、props のみを変更しても、それに関連する 状態更新が発生しない限り、Reactは再レンダリングを行いません。
無駄な再レンダリングを減らすにはその状態に依存するコンポーネントを抽出することです。
それ自体をより小さなコンポーネントに分割します。
コンポーネントとエレメントについて
コンポーネントは引数を受け取り画面にレンダリングすべき要素を返す関数であり、エレメント(要素)は画面に何をレンダリングするかを記述するオブジェクトです。
このような作りになっていると毎回のスクロールで状態(state)が更新されることになり、状態の更新は App コンポーネントとその中にネストされたすべてのコンポーネントを再レンダーさせます。
その結果、非常に遅いコンポーネント群が再レンダーされ、スクロールの体験が遅く、ラグが生じてしまいます。
const MainScrollableArea = () => {
const [position, setPosition] = useState(300);
const onScroll = (e) => {
// calculate position based on the scrolled value
const calculated = getPosition(e.target.scrollTop);
// save it to state
setPosition(calculated);
};
return (
<div className="scrollable-block" onScroll={onScroll}>
{/* pass position value to the new movable component */}
<MovingBlock position={position} />
<VerySlowComponent />
<BunchOfStuff />
<OtherStuffAlsoComplicated />
</div>
);
}
以下のようにコンポーネントに分けることで再レンダリングされるのは ScrollableWithMovingBlock コンポーネント だけになります。
他のコンポーネントは、props を通じて渡されており、それらは ScrollableWithMovingBlock コンポーネントの外側にあります。
「階層的」なコンポーネントツリーにおいて、それらは親コンポーネントに属していることになるので再レンダリングされません。
const ScrollableWithMovingBlock = ({children}) => {
const [position, setPosition] = useState(300);
const onScroll = (e) => {
const calculated = getPosition(e.target.scrollTop);
setPosition(calculated);
};
return (
<div className="scrollable-block" onScroll={onScroll}>
{children}
</div>
);
};
const App = () => {
return (
<ScrollableWithMovingBlock>
<VerySlowComponent />
<BunchOfStuff />
<OtherStuffAlsoComplicated />
</ScrollableWithMovingBlock>
);
};
stateが変わってコンポーネントが再レンダリングされてもpropsとして渡された要素は再レンダリングされません。childrenはpropsと同じ扱いになります。
Memoization
メモ化についてです。
復習ですが、コールバック関数をメモ化するのがuseCallback
値をメモ化する際に使うのがuseMemoになります。
オブジェクトを定義すると変数にはその参照が保存されます。なので同じオブジェクトを定義しても参照先が異なり結果二つをa===bのように比較しても常にfalseになります。
const a = { id: 1 };
const b = { id: 1 };
a === b; // 常にfalse
Rreactの再レンダリングで値を比較する時に考える問題と同じです。
次のようなコンポーネントがあった時にコンポーネントの再レンダリングが行われた場合、ローカル関数は新しく作られます。なのでsubmitも毎回新しい参照を持つためuseEffectの依存配列にsubmitを含めると、異なる参照として検知されuseEffectが実行されてしまいます。
const Component = () => {
const submit = () => {};
useEffect(() => {
// 関数をここで呼び出す
submit();
// submit は useEffect の外で宣言されているため、
// 依存配列に含める必要がある
}, [submit]);
return ...;
};
この問題を回避するにはuseCallbackを使う必要があります。再レンダリングの際に、参照を維持し不要なuseEffectの実行を防ぐことができます。
const Component = () => {
const submit = useCallback(() => {
// 何かの処理
}, []);
useEffect(() => {
submit();
}, [submit]); // submit の参照が変わらないため、不要な再実行を防げる
return ...;
};
以下のようにしてしまうとコンポーネントの再レンダリングの度にsomething関数が呼ばれてしまいます。
const submit = useCallback(something(), []);
useCallbackの第一引数には関数を渡すべきで上記の場合関数の実行結果を渡しているからです。
また以下のようなuseCallbackもよくありません。一度コンポーネントが再レンダリングされると、その中にあるすべてのコンポーネントも再レンダリングされるのがReactの仕様です。useCallbackでラップするかどうかは関係なく、可読性も悪くしています。
const Component = () => {
const onClick = useCallback(() => {
// do something
}, []);
return <button onClick={onClick}>click me</button>;
};
本当にメモ化が必要な場面は2つしかありません。
- そのpropsが、下流のコンポーネント内の別のフックの依存配列として使用されている場合
前述した通り参照を維持する必要があるからです。
const Parent = () => {
// this needs to be memoized!
// Child uses it inside useEffect
const fetch = () => {};
return <Child onMount={fetch} />;
};
const Child = ({ onMount }) => {
useEffect(() => {
onMount();
}, [onMount]);
};
- コンポーネントが React.memo でラップされている場合
memoはコンポーネント自体をメモ化します。親コンポーネントの再レンダリングによって子コンポーネントの再レンダリングが発生する場合に限り、その子コンポーネントがmemoでラップされていればpropsの変更をチェックします。
propsに変更がなければそのコンポーネントは再レンダリングされず通常のレンダリングもそこで止まります。
const Child = ({ data, onChange }) => {};
const ChildMemo = React.memo(Child);
const Component = () => {
const data = useMemo(() => ({ ... }), []); // some object
const onChange = useCallback(() => {}, []); // some callback
// data and onChange now have stable reference
// re-renders of ChildMemo will be prevented
return <ChildMemo data={data} onChange={onChange} />
}
ただメモ化していてもメモ化されていないオブジェクトや関数をpropsとして渡すとメモ化が無駄になってしまいます。カスタムフックは複雑さを隠してくれますが、それと同時にデータや関数がメモ化されているかどうかも隠してしまうので注意が必要です。
すべてのコンポーネントにReact.memoを適用しない理由
React.memoは再レンダリングされる前にpropsが変更されたかどうかを比較します。この比較には時間とメモリを要します。propsが頻繁に変わるようなコンポーネントではメモ化が無駄になってしまいます。再レンダリング回避が目的ではない場合や複雑なコンポーネントは効果が薄いのでReact.memoをしても無駄になってしまうこともあります。
データ取得について
データフェッチを考える時に最も大事なことはコンポーネントのライフサイクルがいつトリガーされるかをしっかりと理解しておくことです。
またブラウザのリクエスト制限も考慮することが大事です。HTTP/1環境では6つの並列リクエストまでしか一度に処理できません。
以下の例ではサイドバーがデータをフェッチして取得します。またmainコンポーネントも内部でデータをフェッチする処理があります。
この場合、コンポーネントをレンダリング → データを待つ → 次のコンポーネントをレンダリング → そのコンポーネントもデータを待つという待ちの連鎖が発生してしまいアプリの表示が遅くなってしまいます。
const App = () => {
// useEffect 内でフェッチがトリガーされる
const { data } = useData('/get-sidebar');
// データを待つ間、ローディングを表示
if (!data) return 'loading';
return (
<>
<Sidebar data={data} />
<Main />
</>
);
};
このようなウォーターフォールを解決する方法として一番簡単なのはPromise.allを使いデータのフェッチをできるだけ上位のコンポーネントで行うことです。
これだと一番遅いリクエストを待てば完了となります。
もしくはすべてのリクエストを並列実行し、個別にデータを待つようにすればもっと効率的にもなります。ただこの方法だとfetchでstateを更新するたびに状態の変更が発生し再レンダリングされてしまいます。
useEffect(async () => {
const [sidebar, issue, comments] = await Promise.all([
fetch('/get-sidebar'),
fetch('/get-issue'),
fetch('/get-comments'),
]);
}, []);
// または
fetch('/get-sidebar')
.then((res) => res.json())
.then((data) => setSidebar(data));
fetch('/get-issue')
.then((res) => res.json())
.then((data) => setIssue(data));
fetch('/get-comments')
.then((res) => res.json())
.then((data) => setComments(data));
上位コンポーネントでfetchを行うとアプリの可読性やアーキテクチャの面ではあまり良くありません。そこでproviderを利用します。
const Context = React.createContext();
export const CommentsDataProvider = ({ children }) => {
const [comments, setComments] = useState();
useEffect(() => {
fetch('/get-comments')
.then((data) => data.json())
.then((data) => setComments(data));
}, []);
return (
<Context.Provider value={comments}>
{children}
</Context.Provider>
);
};
export const useComments = () => useContext(Context);
const Comments = () => {
// propsの受け渡しなしでデータにアクセス
const comments = useComments();
};
SWRやReact Queryのようなライブラリを使うとuseCallback、useState、エラーハンドリング、キャッシュ管理など、多くの面倒な処理を自動化してくれます。裏側では、SWRやReact QueryもuseEffectやそれに相当する仕組みを利用してデータを取得し、取得したデータを状態として管理し、コンポーネントの再レンダリングをトリガーします。
つまり、ライブラリを使うことでコードの記述量を減らし、エラーハンドリングやキャッシュ管理といった面倒な作業をライブラリに任せることができますが、データ取得の基本的な仕組み自体は変わらないということです。
感想
だーっと書いてしまいましたが、最初に紹介した本ではもっと深く書かれていました。自分にとってはとてもいい勉強になったのでおすすめです。