カスタムフックでロジックを再利用する
React には useState
、useContext
、useEffect
など複数の組み込みフックが存在します。しかし、データの取得やユーザのオンライン状態の監視、チャットルームへの接続など、より特化した目的のためのフックが欲しいこともあります。React にこれらのフックはありませんが、アプリケーションの要求に合わせて独自のフックを作成することが可能です。
このページで学ぶこと
- カスタムフックとは何で、どのように自分で作成するのか
- コンポーネント間でロジックを再利用する方法
- カスタムフックの命名や構成の方法
- カスタムフックを抽出するタイミングと理由
カスタムフック:コンポーネント間でのロジック共有
ネットワークに大きく依存するアプリを開発していると想像してください(ほとんどのアプリがそうですが)。アプリの使用中にユーザのネットワーク接続が急に切断された場合に、ユーザに警告を表示したいとします。どのようにすればよいでしょうか? コンポーネントには以下の 2 つが必要になるようです。
これにより、コンポーネントはネットワークの状態と同期するようになります。まずは以下のようなコードができるでしょう。
import { useState, useEffect } from 'react'; export default function StatusBar() { const [isOnline, setIsOnline] = useState(true); useEffect(() => { function handleOnline() { setIsOnline(true); } function handleOffline() { setIsOnline(false); } window.addEventListener('online', handleOnline); window.addEventListener('offline', handleOffline); return () => { window.removeEventListener('online', handleOnline); window.removeEventListener('offline', handleOffline); }; }, []); return <h1>{isOnline ? '✅ Online' : '❌ Disconnected'}</h1>; }
ネットワークをオン・オフしてみて、この StatusBar
が操作に反応してどのように更新されるか観察してみてください。
さて、別のコンポーネントでも同じロジックを使用したくなったところを想像してください。ネットワークがオフの間は “Save” の代わりに “Reconnecting…” と表示されて無効になるような保存ボタンを実装したいとします。
まず、isOnline
state とエフェクトを、SaveButton
にコピー・ペーストしてみましょう。
import { useState, useEffect } from 'react'; export default function SaveButton() { const [isOnline, setIsOnline] = useState(true); useEffect(() => { function handleOnline() { setIsOnline(true); } function handleOffline() { setIsOnline(false); } window.addEventListener('online', handleOnline); window.addEventListener('offline', handleOffline); return () => { window.removeEventListener('online', handleOnline); window.removeEventListener('offline', handleOffline); }; }, []); function handleSaveClick() { console.log('✅ Progress saved'); } return ( <button disabled={!isOnline} onClick={handleSaveClick}> {isOnline ? 'Save progress' : 'Reconnecting...'} </button> ); }
ネットワークをオフにするとボタンの外観が変わることを確認してください。
これらの 2 つのコンポーネントはうまく動作していますが、それらの間でロジックが重複しているのは残念な感じがします。視覚的な外観は異なるにせよ、ロジックはそれらの間で再利用したいと思うことでしょう。
コンポーネントから独自のカスタムフックを抽出する
useState
や useEffect
と同様に、組み込みの useOnlineStatus
というフックがあるところを、ちょっと想像してみてください。それがあれば、これらのコンポーネントを簡略化し、両者で重複しているコードを取り除けるでしょう。
function StatusBar() {
const isOnline = useOnlineStatus();
return <h1>{isOnline ? '✅ Online' : '❌ Disconnected'}</h1>;
}
function SaveButton() {
const isOnline = useOnlineStatus();
function handleSaveClick() {
console.log('✅ Progress saved');
}
return (
<button disabled={!isOnline} onClick={handleSaveClick}>
{isOnline ? 'Save progress' : 'Reconnecting...'}
</button>
);
}
このような組み込みのフックは存在しませんが、自分で書くことは可能です。useOnlineStatus
という関数を宣言して、先ほど作成したコンポーネントから、重複しているコードをすべて移動しましょう。
function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
function handleOnline() {
setIsOnline(true);
}
function handleOffline() {
setIsOnline(false);
}
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
return isOnline;
}
関数の最後で isOnline
を返します。これにより、コンポーネント側でその値を読み取ることができるようになります。
import { useOnlineStatus } from './useOnlineStatus.js'; function StatusBar() { const isOnline = useOnlineStatus(); return <h1>{isOnline ? '✅ Online' : '❌ Disconnected'}</h1>; } function SaveButton() { const isOnline = useOnlineStatus(); function handleSaveClick() { console.log('✅ Progress saved'); } return ( <button disabled={!isOnline} onClick={handleSaveClick}> {isOnline ? 'Save progress' : 'Reconnecting...'} </button> ); } export default function App() { return ( <> <SaveButton /> <StatusBar /> </> ); }
ネットワークのオン・オフを切り替えることで、両方のコンポーネントが更新されることを確認してください。
これで、コンポーネント間のロジックの重複が減りました。さらに重要なのは、コンポーネント内のコードが、「オンラインステータスを使用 (use) する」という、何をしたいのかの記述になっているということです。どのようにして実現するのか(ブラウザのイベントに登録する)ではありません。
ロジックをカスタムフックに抽出することで、外部システムやブラウザ API とのやり取りに関する面倒な詳細を隠蔽することができます。あなたのコンポーネントのコードは、実装方法ではなく意図を表現するようになるのです。
フックの名前は常に use
で始める
React アプリケーションはコンポーネントから構築されます。コンポーネントは、組み込みのものやカスタムのものなど、フックから構築されます。他の人が作成したカスタムフックをよく使うことになりますが、時には自分で書くこともあるでしょう!
以下の命名規則に従う必要があります。
- React コンポーネントの名前は大文字で始まる必要があります。例えば、
StatusBar
やSaveButton
などです。React コンポーネントは、JSX のような、React が表示方法を知っているものを返す必要もあります。 - フックの名前は
use
で始めて大文字を続ける必要があります。例えば、useState
(組み込みのもの)やuseOnlineStatus
(上述のようなカスタムのもの)などです。フックは任意の値を返すことができます。
この慣習により、コンポーネントを見るだけで、その中の state、エフェクト、その他の React 機能がどこに「隠れている」可能性があるか、常に把握できることが保証されます。例えば、コンポーネント内で getColor()
関数の呼び出しを見た場合、名前が use
で始まっていないので React の state が内部に含まれている可能性はありません。しかし useOnlineStatus()
のような関数呼び出しは、内部で他のフックを呼び出している可能性が高いです!
さらに深く知る
いいえ。フックを呼び出さない関数は、フックである必要はありません。
関数がフックを呼び出さない場合は、use
プレフィックスを避けてください。代わりに、use
プレフィックスなしの通常の関数として記述してください。例えば、以下の useSorted
はフックを呼び出さないので、代わりに getSorted
という名前にしましょう。
// 🔴 Avoid: A Hook that doesn't use Hooks
function useSorted(items) {
return items.slice().sort();
}
// ✅ Good: A regular function that doesn't use Hooks
function getSorted(items) {
return items.slice().sort();
}
これにより、コードはこの通常の関数を、条件分岐内を含むどんな場所からでも呼び出すことができます。
function List({ items, shouldSort }) {
let displayedItems = items;
if (shouldSort) {
// ✅ It's ok to call getSorted() conditionally because it's not a Hook
displayedItems = getSorted(items);
}
// ...
}
関数の内部で 1 つ以上のフックを使用している場合は、use
プレフィックスを付ける(つまりフックにする)必要があります。
// ✅ Good: A Hook that uses other Hooks
function useAuth() {
return useContext(Auth);
}
厳密には、これは React によって強制されているわけではありません。原理上は、他のフックを呼び出さないフックを作成することは可能です。混乱を招き余計な制限が加わるため、このようなパターンは避けるのが賢明です。ただし、まれにこれが役立つ場合もあります。例えば、関数が現在はフックを使用していない場合でも、将来的にフック呼び出しを追加する予定があるかもしれません。その場合、use
プレフィックスを使って名前を付けておくことは理にかなっているでしょう。
// ✅ Good: A Hook that will likely use some other Hooks later
function useAuth() {
// TODO: Replace with this line when authentication is implemented:
// return useContext(Auth);
return TEST_USER;
}
こうすれば、コンポーネントはこのコードを条件分岐内で呼び出すことができなくなります。中でフック呼び出しを実際に追加したときに、このことが重要になります。現在も将来も内部でフックを使用する予定がない場合は、フックにしないでください。
カスタムフックは state 自体ではなく、state を使うロジックを共有する
前の例では、ネットワークをオンまたはオフに切り替えると、両方のコンポーネントが同時に更新されました。しかし、isOnline
という単一の state 変数がそれらの間で共有されていると考えるのは間違いです。こちらのコードを見てください。
function StatusBar() {
const isOnline = useOnlineStatus();
// ...
}
function SaveButton() {
const isOnline = useOnlineStatus();
// ...
}
これは、重複を抽出する前と同じ方法で動作します。
function StatusBar() {
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
// ...
}, []);
// ...
}
function SaveButton() {
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
// ...
}, []);
// ...
}
これらは、完全に独立した state 変数とエフェクトです! 同時に同じ値になっているのは、たまたま(ネットワークがオンかどうかという)同一の外部の値と同期させたからです。
より分かりやすく説明するために、別の例を考えてみましょう。この Form
コンポーネントを考えてみてください。
import { useState } from 'react'; export default function Form() { const [firstName, setFirstName] = useState('Mary'); const [lastName, setLastName] = useState('Poppins'); function handleFirstNameChange(e) { setFirstName(e.target.value); } function handleLastNameChange(e) { setLastName(e.target.value); } return ( <> <label> First name: <input value={firstName} onChange={handleFirstNameChange} /> </label> <label> Last name: <input value={lastName} onChange={handleLastNameChange} /> </label> <p><b>Good morning, {firstName} {lastName}.</b></p> </> ); }
各フォームフィールドに対応して、繰り返しのロジックがあります。
- state 変数(
firstName
とlastName
)。 - change ハンドラ(
handleFirstNameChange
とhandleLastNameChange
)。 - 対応する入力フィールドに
value
とonChange
属性を指定するための JSX。
この繰り返しのロジックを useFormInput
というカスタムフックに抽出することができます。
import { useState } from 'react'; export function useFormInput(initialValue) { const [value, setValue] = useState(initialValue); function handleChange(e) { setValue(e.target.value); } const inputProps = { value: value, onChange: handleChange }; return inputProps; }
コード内で宣言されているのは value
という 1 つの state 変数だけであることに注目してください。
しかし Form
コンポーネントは useFormInput
を 2 回 呼び出しています。
function Form() {
const firstNameProps = useFormInput('Mary');
const lastNameProps = useFormInput('Poppins');
// ...
これが、2 つの別々の state 変数を宣言するのと同じように動作する理由です!
カスタムフックは、state 自体ではなく、state を扱うロジックを共有できるようにするためのものです。フックの呼び出しは、同じフックの他の場所からの呼び出しとは完全に独立しています。これが、上記の 2 つのサンドボックスが完全に同等である理由です。よろしければ、スクロールして上に戻って見比べてみてください。カスタムフックを抽出する前と後で、挙動は全く同一です。
複数のコンポーネント間で state 自体を共有する必要がある場合は、リフトアップして下に渡すようにしてください。
フック間でリアクティブな値を渡す
カスタムフック内のコードは、コンポーネントの再レンダーごとに実行されます。そのため、コンポーネントと同様に、カスタムフックは純粋である必要があります。カスタムフックのコードは、コンポーネントの本体の一部だと考えてください!
カスタムフックはコンポーネントと一緒に再レンダーされるため、常に最新の props と state を受け取ります。どういうことか理解するために、以下のチャットルームの例を考えてみましょう。サーバの URL やチャットルームを変更してみてください。
import { useState, useEffect } from 'react'; import { createConnection } from './chat.js'; import { showNotification } from './notifications.js'; export default function ChatRoom({ roomId }) { const [serverUrl, setServerUrl] = useState('https://localhost:1234'); useEffect(() => { const options = { serverUrl: serverUrl, roomId: roomId }; const connection = createConnection(options); connection.on('message', (msg) => { showNotification('New message: ' + msg); }); connection.connect(); return () => connection.disconnect(); }, [roomId, serverUrl]); return ( <> <label> Server URL: <input value={serverUrl} onChange={e => setServerUrl(e.target.value)} /> </label> <h1>Welcome to the {roomId} room!</h1> </> ); }
serverUrl
や roomId
を変更すると、エフェクトは変更に「反応」して再同期されます。コンソールのメッセージを見ると、エフェクトの依存配列に変更があるたびにチャットが再接続されていることがわかります。
次に、エフェクトのコードをカスタムフックに移動します。
export function useChatRoom({ serverUrl, roomId }) {
useEffect(() => {
const options = {
serverUrl: serverUrl,
roomId: roomId
};
const connection = createConnection(options);
connection.connect();
connection.on('message', (msg) => {
showNotification('New message: ' + msg);
});
return () => connection.disconnect();
}, [roomId, serverUrl]);
}
これにより、ChatRoom
コンポーネントは単にカスタムフックを呼び出せばよく、内部でどのように動作するかを気にしなくてもよくなります。
export default function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');
useChatRoom({
roomId: roomId,
serverUrl: serverUrl
});
return (
<>
<label>
Server URL:
<input value={serverUrl} onChange={e => setServerUrl(e.target.value)} />
</label>
<h1>Welcome to the {roomId} room!</h1>
</>
);
}
これはずっとシンプルに見えます!(しかしやっていることは同じです。)
依然として props や state の変更に対しロジックが反応していることに注意してください。サーバ URL やルームを編集してみてください。
import { useState } from 'react'; import { useChatRoom } from './useChatRoom.js'; export default function ChatRoom({ roomId }) { const [serverUrl, setServerUrl] = useState('https://localhost:1234'); useChatRoom({ roomId: roomId, serverUrl: serverUrl }); return ( <> <label> Server URL: <input value={serverUrl} onChange={e => setServerUrl(e.target.value)} /> </label> <h1>Welcome to the {roomId} room!</h1> </> ); }
ここでは、あるフックの返り値を取得して…
export default function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');
useChatRoom({
roomId: roomId,
serverUrl: serverUrl
});
// ...
…それを別のフックに入力として渡しています。
export default function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');
useChatRoom({
roomId: roomId,
serverUrl: serverUrl
});
// ...
ChatRoom
コンポーネントが再レンダーされるたびに、あなたのフックには最新の roomId
と serverUrl
が渡されます。したがって、再レンダー後にこれらの値が異なる場合にはエフェクトがチャットの再接続を行います。(オーディオやビデオ処理ソフトウェアを使ったことがある場合、このようなフックのチェーンは、視覚エフェクトやオーディオエフェクトのチェーンに似ていると感じるかもしれません。useState
の出力が useChatRoom
の入力に “フィードイン” しています。)
カスタムフックにイベントハンドラを渡す
useChatRoom
がより多くのコンポーネントで使用されるようになると、コンポーネント側でその動作をカスタマイズしたくなってくるでしょう。例えば現在のところ、メッセージが届いたときの処理ロジックはフック内にハードコードされています。
export function useChatRoom({ serverUrl, roomId }) {
useEffect(() => {
const options = {
serverUrl: serverUrl,
roomId: roomId
};
const connection = createConnection(options);
connection.connect();
connection.on('message', (msg) => {
showNotification('New message: ' + msg);
});
return () => connection.disconnect();
}, [roomId, serverUrl]);
}
このロジックをコンポーネント側に戻したいとしましょう。
export default function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');
useChatRoom({
roomId: roomId,
serverUrl: serverUrl,
onReceiveMessage(msg) {
showNotification('New message: ' + msg);
}
});
// ...
これを実現するために、カスタムフックを変更して、onReceiveMessage
を名前付きオプションの 1 つとして受け取るようにします。
export function useChatRoom({ serverUrl, roomId, onReceiveMessage }) {
useEffect(() => {
const options = {
serverUrl: serverUrl,
roomId: roomId
};
const connection = createConnection(options);
connection.connect();
connection.on('message', (msg) => {
onReceiveMessage(msg);
});
return () => connection.disconnect();
}, [roomId, serverUrl, onReceiveMessage]); // ✅ All dependencies declared
}
これで動作しますが、カスタムフックがイベントハンドラを受け取る場合、改善できることがもう 1 つあります。
onReceiveMessage
を依存値として追加すると、コンポーネントが再レンダーされるたびにチャットが再接続されてしまうため、あまり望ましくありません。このイベントハンドラをエフェクトイベント (Effect Event) にラップして、依存配列から取り除きます。
import { useEffect, useEffectEvent } from 'react';
// ...
export function useChatRoom({ serverUrl, roomId, onReceiveMessage }) {
const onMessage = useEffectEvent(onReceiveMessage);
useEffect(() => {
const options = {
serverUrl: serverUrl,
roomId: roomId
};
const connection = createConnection(options);
connection.connect();
connection.on('message', (msg) => {
onMessage(msg);
});
return () => connection.disconnect();
}, [roomId, serverUrl]); // ✅ All dependencies declared
}
これで、ChatRoom
コンポーネントが再レンダーされるたびにチャットが再接続されることはなくなります。以下が、イベントハンドラをカスタムフックに渡す完全なデモです。試してみてください。
import { useState } from 'react'; import { useChatRoom } from './useChatRoom.js'; import { showNotification } from './notifications.js'; export default function ChatRoom({ roomId }) { const [serverUrl, setServerUrl] = useState('https://localhost:1234'); useChatRoom({ roomId: roomId, serverUrl: serverUrl, onReceiveMessage(msg) { showNotification('New message: ' + msg); } }); return ( <> <label> Server URL: <input value={serverUrl} onChange={e => setServerUrl(e.target.value)} /> </label> <h1>Welcome to the {roomId} room!</h1> </> ); }
useChatRoom
を使うために内部の動作を知らなくても良くなったことに着目してください。他のコンポーネントに追加したり、他のオプションを渡したりしても、同じように動作します。これがカスタムフックの威力です。
カスタムフックを使うタイミング
あらゆる小さなコードの重複に対してカスタムフックを抽出する必要はありません。多少の重複は問題ありません。例えば、先ほどのように 1 回の useState
呼び出しをラップするだけの useFormInput
フックを抽出することは、おそらく不要でしょう。
ただし、エフェクトを書くときは常に、更にそのエフェクトをカスタムフックにラップすることでより分かりやすくならないか、検討するようにしてください。エフェクトは頻繁には必要とされないものです。エフェクトを書くということは、外部システムと同期するために「React の外に踏み出す」必要がある、もしくは React に組み込みの API がない何かを行う必要があるということです。カスタムフックにラップすることで、あなたの意図とデータの流れを正確に表現することができます。
例えば、都市のリストを表示するドロップダウンと、そこで選択中の都市内にある地区のリストを表示する別のドロップダウンがある、ShippingForm
というコンポーネントを考えてみましょう。まずは次のようなコードを書くことになるでしょう。
function ShippingForm({ country }) {
const [cities, setCities] = useState(null);
// This Effect fetches cities for a country
useEffect(() => {
let ignore = false;
fetch(`/api/cities?country=${country}`)
.then(response => response.json())
.then(json => {
if (!ignore) {
setCities(json);
}
});
return () => {
ignore = true;
};
}, [country]);
const [city, setCity] = useState(null);
const [areas, setAreas] = useState(null);
// This Effect fetches areas for the selected city
useEffect(() => {
if (city) {
let ignore = false;
fetch(`/api/areas?city=${city}`)
.then(response => response.json())
.then(json => {
if (!ignore) {
setAreas(json);
}
});
return () => {
ignore = true;
};
}
}, [city]);
// ...
このコードはかなりの繰り返しになっていますが、これらのエフェクトを互いに独立させておくことは正当です。これらは 2 つの異なるものを同期しているので、1 つのエフェクトに統合すべきではありません。代わりに、これらに共通のロジックを独自の useData
フックとして抽出することで、上記の ShippingForm
コンポーネントを簡略化することができます。
function useData(url) {
const [data, setData] = useState(null);
useEffect(() => {
if (url) {
let ignore = false;
fetch(url)
.then(response => response.json())
.then(json => {
if (!ignore) {
setData(json);
}
});
return () => {
ignore = true;
};
}
}, [url]);
return data;
}
これで、ShippingForm
コンポーネントの両方のエフェクトを useData
の呼び出しに置き換えることができます。
function ShippingForm({ country }) {
const cities = useData(`/api/cities?country=${country}`);
const [city, setCity] = useState(null);
const areas = useData(city ? `/api/areas?city=${city}` : null);
// ...
カスタムフックに抽出することで、データの流れが明示的になります。url
を入力し data
を出力しているということです。useData
の中にエフェクトを「隠す」ことで、ShippingForm
コンポーネントで作業中の誰かが不要な依存値を追加してしまうことを防げます。時間が経つにつれて、アプリのほとんどのエフェクトはカスタムフックに書かれるようになるでしょう。
さらに深く知る
まず、カスタムフックの名前を選ぶところから始めましょう。明確な名前を選ぶことが難しいと感じる場合、エフェクトがコンポーネントの他のロジックとあまりにも密接に関連しており、まだ抽出する準備ができていないということかもしれません。
理想的には、カスタムフックの名前は、コードをあまり書かない人でも、何をするのか、何を受け取るのか、何を返すのかを推測できるほどに明確であるべきです。
- ✅
useData(url)
- ✅
useImpressionLog(eventName, extraData)
- ✅
useChatRoom(options)
外部システムと同期する場合、カスタムフックの名前は、そのシステム固有の専門用語を使用したより技術的なものになるかもしれません。そのシステムに精通している人にとって明確である限り、問題はありません。
- ✅
useMediaQuery(query)
- ✅
useSocket(url)
- ✅
useIntersectionObserver(ref, options)
カスタムフックは具体的かつ高レベルのユースケースに対して使うようにしてください。useEffect
API 自体の代替物ないし便利なラッパとして機能させるための、カスタム「ライフサイクル」フックを作ったり使ったりしないようにしてください。
- 🔴
useMount(fn)
- 🔴
useEffectOnce(fn)
- 🔴
useUpdateEffect(fn)
例えば、この useMount
フックは、あるコードが「マウント時」にのみ実行されるようにしようとしています。
function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');
// 🔴 Avoid: using custom "lifecycle" Hooks
useMount(() => {
const connection = createConnection({ roomId, serverUrl });
connection.connect();
post('/analytics/event', { eventName: 'visit_chat' });
});
// ...
}
// 🔴 Avoid: creating custom "lifecycle" Hooks
function useMount(fn) {
useEffect(() => {
fn();
}, []); // 🔴 React Hook useEffect has a missing dependency: 'fn'
}
useMount
のようなカスタム「ライフサイクル」フックは、React のパラダイムと適合しません。例えば、このコードサンプルには間違いがあります(roomId
や serverUrl
の変更に「反応」しません)が、リンタは useEffect
の直接的な呼び出しのみをチェックするため、これに対して警告を出してくれません。あなたのフックのことは知らないからです。
エフェクトを書く場合は、まず React の API を直接使ってください。
function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');
// ✅ Good: two raw Effects separated by purpose
useEffect(() => {
const connection = createConnection({ serverUrl, roomId });
connection.connect();
return () => connection.disconnect();
}, [serverUrl, roomId]);
useEffect(() => {
post('/analytics/event', { eventName: 'visit_chat', roomId });
}, [roomId]);
// ...
}
その後、様々な高レベルのユースケースに対してカスタムフックを抽出するようにします(必須ではありません)。
function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');
// ✅ Great: custom Hooks named after their purpose
useChatRoom({ serverUrl, roomId });
useImpressionLog('visit_chat', { roomId });
// ...
}
良いカスタムフックとは、動作を制約することで呼び出し側のコードをより宣言的にするものです。例えば、useChatRoom(options)
はチャットルームへの接続のみを行い、useImpressionLog(eventName, extraData)
はアナリティクスに表示ログを送信することのみを行います。あなたのカスタムフックの API がユースケースを制約しない非常に抽象的なものである場合、長期的には解決される問題よりも多くの問題を引き起こす可能性が高いでしょう。
カスタムフックはより良いパターンへの移行を支援する
エフェクトは “避難ハッチ” です。「React の外に踏み出す」必要があり、当該ユースケースに対してより良い組み込みのソリューションがない場合に使用するものです。長期的な React チームの目標は、より具体的な問題に対してより具体的なソリューションを提供することで、アプリ内のエフェクトの数を最小限に減らすことです。エフェクトをカスタムフックにラップしておくことで、これらのソリューションが利用可能になったときにコードのアップグレードが容易になります。
こちらの例に戻りましょう。
import { useState, useEffect } from 'react'; export function useOnlineStatus() { const [isOnline, setIsOnline] = useState(true); useEffect(() => { function handleOnline() { setIsOnline(true); } function handleOffline() { setIsOnline(false); } window.addEventListener('online', handleOnline); window.addEventListener('offline', handleOffline); return () => { window.removeEventListener('online', handleOnline); window.removeEventListener('offline', handleOffline); }; }, []); return isOnline; }
上記の例では、useOnlineStatus
は useState
と useEffect
のペアで実装されています。しかし、これは最善のソリューションではありません。考慮されていないエッジケースがいくつかあります。例えば、コンポーネントがマウントされたとき isOnline
は true
であると仮定していますが、ネットワークがすでにオフラインになっていた場合、これは誤りです。ブラウザの navigator.onLine
API を使ってそれをチェックすることはできますが、それを直接使うと、サーバで初期 HTML を生成する際には動作しません。要するに、このコードには改善の余地があるということです。
幸いなことに React 18 には、これらの問題をすべて解決してくれる専用の API である useSyncExternalStore
が含まれています。以下は、この新しい API を活用して書き直された useOnlineStatus
フックです。
import { useSyncExternalStore } from 'react'; function subscribe(callback) { window.addEventListener('online', callback); window.addEventListener('offline', callback); return () => { window.removeEventListener('online', callback); window.removeEventListener('offline', callback); }; } export function useOnlineStatus() { return useSyncExternalStore( subscribe, () => navigator.onLine, // How to get the value on the client () => true // How to get the value on the server ); }
どのコンポーネントも変更することなしにこの移行ができたことに注目してください。
function StatusBar() {
const isOnline = useOnlineStatus();
// ...
}
function SaveButton() {
const isOnline = useOnlineStatus();
// ...
}
これが、カスタムフックにエフェクトをラップすることが有益であるもうひとつの理由です。
- エフェクトに出入りするデータの流れが非常に明確になる。
- コンポーネントがエフェクトの実装そのものではなく、意図にフォーカスできるようになる。
- React が新しい機能を追加したときに、コンポーネントを変更せずにエフェクトを削除できるようになる。
デザインシステムと同様に、アプリのコンポーネントから共通の定型コードをカスタムフックに抽出することは役立つでしょう。これにより、コンポーネントのコードは意図を表現するようになり、生のエフェクトを頻繁に書くことを避けることができるようになります。React コミュニティでは多くの優れたカスタムフックがメンテナンスされています。
さらに深く知る
まだ詳細は検討中ですが、将来的にはデータフェッチを以下のように書くことになるでしょう。
import { use } from 'react'; // Not available yet!
function ShippingForm({ country }) {
const cities = use(fetch(`/api/cities?country=${country}`));
const [city, setCity] = useState(null);
const areas = city ? use(fetch(`/api/areas?city=${city}`)) : null;
// ...
アプリで上記のような useData
のようなカスタムフックを使用しておくことで、最終的に推奨されるアプローチに移行する際に、コンポーネントごとに手動で生のエフェクトを書く場合よりも変更が少なくて済みます。ただし、古いアプローチでも問題なく動作するので、生のエフェクトを書くことに満足している場合は、それを続けることもできます。
やり方は 1 つではない
ブラウザの requestAnimationFrame
API を使って、ゼロからフェードインアニメーションを実装したいとしましょう。アニメーション用のループを設定するエフェクトから始めることになるでしょう。アニメーションの各フレームでは、ref で保持している DOM ノードの不透明度を 1
になるまで更新していきます。最初のコードは次のようになるでしょう。
import { useState, useEffect, useRef } from 'react'; function Welcome() { const ref = useRef(null); useEffect(() => { const duration = 1000; const node = ref.current; let startTime = performance.now(); let frameId = null; function onFrame(now) { const timePassed = now - startTime; const progress = Math.min(timePassed / duration, 1); onProgress(progress); if (progress < 1) { // We still have more frames to paint frameId = requestAnimationFrame(onFrame); } } function onProgress(progress) { node.style.opacity = progress; } function start() { onProgress(0); startTime = performance.now(); frameId = requestAnimationFrame(onFrame); } function stop() { cancelAnimationFrame(frameId); startTime = null; frameId = null; } start(); return () => stop(); }, []); return ( <h1 className="welcome" ref={ref}> Welcome </h1> ); } export default function App() { const [show, setShow] = useState(false); return ( <> <button onClick={() => setShow(!show)}> {show ? 'Remove' : 'Show'} </button> <hr /> {show && <Welcome />} </> ); }
このコンポーネントをより読みやすくするために、useFadeIn
カスタムフックにロジックを抽出することができます。
import { useState, useEffect, useRef } from 'react'; import { useFadeIn } from './useFadeIn.js'; function Welcome() { const ref = useRef(null); useFadeIn(ref, 1000); return ( <h1 className="welcome" ref={ref}> Welcome </h1> ); } export default function App() { const [show, setShow] = useState(false); return ( <> <button onClick={() => setShow(!show)}> {show ? 'Remove' : 'Show'} </button> <hr /> {show && <Welcome />} </> ); }
この useFadeIn
はこのままでも構いませんが、さらにリファクタリングすることも可能です。例えば、アニメーションループの設定ロジックを useFadeIn
の外のカスタム useAnimationLoop
フックへと抽出することができます。
import { useState, useEffect } from 'react'; import { experimental_useEffectEvent as useEffectEvent } from 'react'; export function useFadeIn(ref, duration) { const [isRunning, setIsRunning] = useState(true); useAnimationLoop(isRunning, (timePassed) => { const progress = Math.min(timePassed / duration, 1); ref.current.style.opacity = progress; if (progress === 1) { setIsRunning(false); } }); } function useAnimationLoop(isRunning, drawFrame) { const onFrame = useEffectEvent(drawFrame); useEffect(() => { if (!isRunning) { return; } const startTime = performance.now(); let frameId = null; function tick(now) { const timePassed = now - startTime; onFrame(timePassed); frameId = requestAnimationFrame(tick); } tick(); return () => cancelAnimationFrame(frameId); }, [isRunning]); }
ただし、これは必須ではありませんでした。通常の関数と同様、コードのどこに分割線を引いていくのかは、最終的にあなたが決めることです。また、まったく異なるアプローチを取ることもできます。エフェクト内にロジックを保持する代わりに、命令型のロジックのほとんどを JavaScript のクラス内に移動することもできるでしょう。
import { useState, useEffect } from 'react'; import { FadeInAnimation } from './animation.js'; export function useFadeIn(ref, duration) { useEffect(() => { const animation = new FadeInAnimation(ref.current); animation.start(duration); return () => { animation.stop(); }; }, [ref, duration]); }
エフェクトとは React を外部システムに接続することができるものです。エフェクト間で多くの調整が必要になればなるほど(例えば、複数のアニメーションを連動させるなど)、上記のサンドボックスのようにエフェクトやフックからロジックを完全に抽出してしまうことがより意味を持つようになります。そうすればその抽出したコードこそが「外部システム」となります。これにより、その React 外に移動したシステムにメッセージを送るだけでよくなるため、エフェクトはシンプルに保たれるでしょう。
なお上記の例では、フェードインのロジックを JavaScript で記述する必要があると仮定していました。ただしこの特定のケースに関して言えば、このフェードインアニメーションは単純な CSS アニメーションで実装する方がずっと簡単で効率的です。
.welcome { color: white; padding: 50px; text-align: center; font-size: 50px; background-image: radial-gradient(circle, rgba(63,94,251,1) 0%, rgba(252,70,107,1) 100%); animation: fadeIn 1000ms; } @keyframes fadeIn { 0% { opacity: 0; } 100% { opacity: 1; } }
ときには、そもそもフック自体が不要ということです!
まとめ
- カスタムフックを使ってコンポーネント間でロジックを共有できる。
- カスタムフックの名前は
use
で始めて大文字を続ける必要がある。 - カスタムフックは state 自体ではなく、state を使うロジックを共有する。
- あるフックから別のフックにリアクティブな値を渡すことができ、それらは最新の状態に保たれる。
- すべてのフックはコンポーネントが再レンダーされるたびに再実行される。
- カスタムフックのコードは、コンポーネントコードと同様に純粋である必要がある。
- カスタムフックが受け取るイベントハンドラはエフェクトイベントにラップする。
useMount
のようなカスタムフックを作成してはいけない。常に目的は具体的なものにする。- コードの境界をどこにどのように置くかはあなたが決定する。
チャレンジ 1/5: useCounter
フックを抽出
このコンポーネントは、state 変数とエフェクトを使い、1 秒ごとに増加する数値を表示しています。このロジックを useCounter
というカスタムフックに抽出してください。目標は、Counter
コンポーネントの実装が以下のようになることです。
export default function Counter() {
const count = useCounter();
return <h1>Seconds passed: {count}</h1>;
}
カスタムフックを useCounter.js
に記述して、Counter.js
ファイルにインポートする必要があります。
import { useState, useEffect } from 'react'; export default function Counter() { const [count, setCount] = useState(0); useEffect(() => { const id = setInterval(() => { setCount(c => c + 1); }, 1000); return () => clearInterval(id); }, []); return <h1>Seconds passed: {count}</h1>; }