はじめに
2023年3月、Reactの公式ドキュメントが更新されました。
特にuseEffectに関するページである"You Might Not Need an Effect"はTwitter等で大きな話題になっていたように感じます。
本記事では、MI-6株式会社のSaaSプロダクト「miHub」の開発チームが上記のドキュメントを参考に取り組んだリファクタリングの中からuseEffectをなくせるよくあるパターンを紹介します。
パターン1: 不要なstate
propsで渡ってくる値が、親コンポーネントでfetchされたデータだった場合に多かった記述です。
値が入るタイミングがわからないのでuseEffectで確実に計算結果を反映してレンダリングしようとしていました。
import React, { useState, useEffect } from 'react';
type = Props = {
count: number
max: number
}
export const Counter: React.FC<Props> = (props) => {
const [isOverMax, setIsOverMax] = useState<boolean>(false);
useEffect(() => {
setIsOverMax(props.count >= props.max);
}, [props.count, props.max]);
...
}
この例の場合、以下の記述で十分です。
export const Counter: React.FC<Props> = (props) => {
const isOverMax = props.count >= props.max;
...
}
理由は、propsが変わるとコンポーネントが再計算されるからです。
useEffectを使うと、props変化→再計算→レンダリング→useEffect→state変化→再計算→レンダリングと無駄な計算が発生します。
パターン2: データフェッチにhooksを利用する場合
パターン1に似ていますが、こちらはhooks化することで不要なEffectを減らせるパターンです。
useEffectは冒頭の公式ドキュメントで
Effects are an escape hatch from the React paradigm. They let you “step outside” of React and synchronize your components with some external system like a non-React widget, network, or the browser DOM.
とあるように、ネットワークとの通信では利用が推奨されています。例えば以下のようなコードです。
import React, { useState, useEffect } from 'react';
import { fetchSomeData } from './api';
const ExampleComponent = () => {
const [data, setData] = useState(null);
useEffect(() => {
const fetchData = async () => {
const response = await fetchSomeData();
setData(response.data);
};
fetchData();
}, []);
...
};
この例では、axiosなど何らかの手段でデータをfetchして、その結果を確実に表示するためにuseEffectを使う必要があります。
miHubでは、少し前にuseFetch
というデータ取得処理を抽象化するhooksを作りました。
このhooks内部は以下のようなイメージの処理を行います。
const useFetch = (url) => {
const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchData = async () => {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error('Network response was not ok');
}
const json = await response.json();
setData(json);
} catch (error) {
setError(error);
} finally {
setIsLoading(false);
}
};
fetchData();
}, [url]);
return { data, isLoading, error };
};
このhooksを利用すると、最初のコードは以下のように書き直すことができます。
const ExampleComponent = () => {
const { data, isLoading, error } = useFetch('/some-endpoint');
...
};
レスポンスを受け取ったらhooks内部でstateが変化するので、コンポーネント内でuseEffectを利用しなくてもdataは通信結果と同期します。
パターン3: イベントハンドラ内で処理できる場合
Reactコンポーネント内で、状態が変更された際に何らかの処理を実行する場合、useEffectフックをよく利用していました。
import React, { useState, useEffect } from 'react';
const ExampleComponent = () => {
const [count, setCount] = useState<number>(0);
const [message, setMessage] = useState<string>('');
useEffect(() => {
setMessage('count has changed to', count);
}, [count]);
const handleButtonClick = () => {
setCount(count + 1);
};
...
};
しかし、状態が変更された際に直接イベントハンドラ内で必要な処理を実行することもできます。
const ExampleComponent = () => {
const [count, setCount] = useState(0);
const [message, setMessage] = useState<string>('');
const handleButtonClick = () => {
setCount(count + 1);
setMessage('count has changed to', count);
};
...
};
非同期の処理が必要な場合はuseEffectの利用が適切ですが、上記のようにユーザーのアクションに対して即時的に処理をする場合はイベントハンドラ内に書くことで、Effectによる不要なレンダリングを減らすことができます。
パターン4: データ取得を親コンポーネントに移動
コンポーネントの分割を行うと、データ取得も子コンポーネントにまとめた方がスッキリする場合があります。
// Parentコンポーネント
import React, { useEffect, useState } from 'react';
import Child from './Child';
const Parent = () => {
const [data, setData] = useState(null);
useEffect(() => {
someFunction()
}, [data]);
const handleChildData = (childData) => {
setData(childData);
}
return (
<>
<Child onData={handleChildData} data={data} />
...
</>
)
}
// Childコンポーネント
import React, { useEffect, useState } from 'react';
const Child = ({ onData, data }) => {
const { request } = useRequest('get', 'https://api.example.com/child-data');
const onClick = async () => {
const response = await request();
onData(response.data);
}
return (
<>
<Button onClick={onClick}>get!</Button>
<div>{data}</div>
</>
)
}
上記のような実装があったのですが、これでは
- 子コンポーネントでデータ取得
- 親のsetterに渡す
- stateの変化で親子ともに再レンダリング
- 親はuseEffectでデータの取得を検知
- 処理の結果また必要に応じて再レンダリング
と無駄な再計算処理が走ってしまいます。
また、データの流れも追いにくく、バグが発生しやすい状況になります。
// Parentコンポーネント
import React, { useEffect, useState } from 'react';
import Child from './Child';
const Parent = () => {
const [data, setData] = useState(null);
useEffect(() => {
someFunction()
}, [data]);
const { request } = useRequest('get', 'https://api.example.com/child-data');
const onClick = async () => {
const response = await request();
setData(response.data);
}
return (
<>
<Child onClick={onClick} data={data} />
...
</>
)
}
// Childコンポーネント
import React, { useEffect, useState } from 'react';
const Child = ({ onClick, data }) => {
return (
<>
<Button onClick={onClick}>get!</Button>
<div>{data}</div>
</>
)
}
リファクタリング後は、親でデータ取得の処理を記述します。
リクエストを飛ばすonClickをpropsで渡すことで、処理自体は以前のままで
- 親コンポーネントでデータ取得
- stateの変化で親子ともに再レンダリング
の2ステップだけで処理が行われ、データの流れも見やすくなりました。
まとめ
親子のデータ受け渡し、通信によるデータ取得タイミングと画面描画タイミングなどが複雑に絡み合っているので、元の動作を担保するのは非常に骨の折れる作業ですが、リファクタリングをしてみると非効率な実装が潜んでいます。
公式ドキュメントには他の例も紹介されていますが、miHubでは上記のパターンで不要なEffectは大体なくすことができました。