通常
コンポーネントに必要なデータを取得する際はuseEffectを用いて記述します。
const useData = <T>(url: string): T | null => {
const [data, setData] = useState<T | null>(null);
useEffect(() => {
let ignore = false;
fetch(url, { signal: abortController.signal })
.then((res) => res.json())
.then((data) => {
if (!ignore) {
setData(data);
}
})
.catch(reportError);
return () => {
ignore = true;
};
}, [url]);
return data;
};
もしくは
const useData = <T>(url: string): T | null => {
const [data, setData] = useState<T | null>(null);
useEffect(() => {
const abortController = new AbortController();
fetch(url, { signal: abortController.signal })
.then((res) => res.json())
.then((data) => setData(data))
.catch((error) => {
if (error.name === 'AbortError') return;
reportError(error);
});
return () => {
abortController.abort();
};
}, [url]);
return data;
};
記述の違いは競合状態の解決方法になります。その記述がない場合は以下のようになります。
const useData = <T>(url: string): T | null => {
const [data, setData] = useState<T | null>(null);
useEffect(() => {
fetch(url)
.then((res) => res.json())
.then((data) => setData(data))
.catch(reportError);
}, [url]);
return data;
};
初期マウント時とurlが変更されたタイミングの描画後に、useEffectに記述した内容が実行されて、fetchで獲得した情報をdataに詰め込んでいます。
dataはuseDataの返り値でデータの取得前はnullを返して、取得して詰め込まれた後はTを返すようになっています。
このままでは、fetchが完了する前に次のuseEffectが呼び出される場合に、前に呼び出したfetchよりも後に呼び出されたfetchが早く帰ってきて、dataに古いfetchの結果が詰め込まれるので注意する必要があります。最初に紹介したコードはその対策がされていました。
1つ目のコードはクリーンアップ時にuseEffect内のローカル変数ignoreをtrueにすることで、前に実行されたfetchの結果がdataへ詰め込まれないようにしました。
2つ目のコードはAbortControllerを用いてfetchにsignalを渡して、クリーンアップ時にAbortControllerをabortするようにしました。これによって前に実行されたfetchの結果がAbortErrorとなってfetchがキャンセルされて、dataへの更新が行われなくなります。
2つ目のコードはfetch自体をキャンセルするので無駄なリクエストが続かない点でメリットがありますが、IEへの対応が必要な場合はAbortControllerが使えないので注意する必要があります。
Suspense、ErrorBoundaryを利用する
SuspenseやErrorBoundaryを使ってコードに境界を設けている場合はもう少しシンプルに書けます。
こちら条件ではフレームワークやライブラリを用いてデータの取得を行うことが多く、スタンダードな方法がないので、あくまで私が良いと考えている方法での実装となります。
const cacheMap = new Map<string, unknown>();
const useData = <T>(url: string): T => {
const cacheData = cacheMap.get(url) as T | undefined;
if (cacheData === undefined) {
throw fetch(url)
.then((res) => res.json())
.then((data) => cacheMap.set(url, data));
}
return cacheData;
};
Suspense境界で捕捉されるのはthrowされたPromiseオブジェクトなので、fetchを投げる用に記述しています。cacheMapを作成して結果をキャッシュさせているのは、再レンダリングが発火することによるuseDataの再計算でfetchが再度投げられるのを防ぐためです。
このキャッシュ部分は、ReactのCanaryバージョンの世界ではcacheを使ったり、フレームワークやライブラリではstale-while-revalidateが用いられてたり、より良いデータ取得のために作り込まれることが多いです。
これは以下のように利用します。
const Sample = () => {
const data = useData(url);
return <Data data={data} />;
}
利用する際は最低でもルートで以下のように境界付けを行うようにしてください。
<ErrorBoundary>
<Suspense>
<Sample />
</Suspense>
</ErrorBoundary>
canaryバージョン
ReactのCanaryリリースされているuseを用いることで自作することなくデータの取得を行えます。こちらもSuspenseやErrorBoundaryを使ってコードに境界を設ける必要があります。
const Sample = () => {
const data = use(fetchData);
return <Data data={data} />;
};
useは他のhooksとは異なり、if文中などに記述することもできますが、呼び出す場所はレンダリング時に計算される場所でなくてはいけません(イベントハンドラ内には記述できません)。