React公式サイトのドキュメントが2023年3月16日に改訂されました(「Introducing react.dev」参照)。本稿は、応用解説の「Reusing Logic with Custom Hooks」をかいつまんでまとめた記事です。ただし、コードにはTypeScriptを加えました。反面、初心者向けのJavaScriptの基礎的な説明は省いています。
なお、本シリーズ解説の他の記事については「React + TypeScript: React公式ドキュメントの基本解説『Learn React』を学ぶ」をご参照ください。
Reactには、useState
やuseContext
、useEffect
などさまざまな組み込みフックが備わっています。けれど、もっと特定の目的に応じたフックが必要になることもあるでしょう。たとえば、つぎのような用途です。
- データの取得。
- ユーザがオンラインかどうかの監視。
- チャットルームへの接続。
Reactの組み込みフックには見当たらないかもしれません。けれど、アプリケーションの必要に合わせて、フックは自作できます。
カスタムフック:コンポーネント間でロジックを共有する
ネットワークに大きく依存するアプリケーションを開発しているとします(ほとんどのアプリケーションがそうでしょう)。アプリケーションの使用中にネットワークが急に切れたら、ユーザーに警告を示したいです。コンポーネントにはつぎのふたつの機能が必要になると考えられます。
こうして、コンポーネントとネットワークを同期させようということです。まずは、カスタムフックは使わず、ネット接続が想定された簡単なアプリケーションをふたつつくりましょう。
-
isOnline
: ネットワークがオンラインかどうかの状態変数。 -
useEffect
:online
とoffline
イベントのリスナーを登録。
ひとつめは、ネットワークに接続している('Online')か、切断している('Disconnected')かを画面に表示します(サンプル001)。実際に、ネットにつないだり、切ったりして結果をお確かめください(インタラクションはありません)。
export const StatusBar: FC = () => {
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
const handleOnline = () => {
setIsOnline(true);
};
const 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>;
};
サンプル001■React + TypeScript: Reusing Logic with Custom Hooks 01
ふたつめのアプリケーションも、ネットワーク接続に使うロジックは同じです。画面に表示するのはボタンで、ネットワークにつながっているか切れているかで見た目と振る舞いが変わります。
- ネットワークに接続中: 'Save progress'と表示。
- クリックするとコンソールに'Progress saved'と出力。
- ネットワークと切断中: 'Reconnecting...'と表示。
- ボタンは無効。
export const SaveButton: FC = () => {
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
const handleOnline = () => {
setIsOnline(true);
};
const handleOffline = () => {
setIsOnline(false);
};
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
const handleSaveClick = () => {
console.log('✅ Progress saved');
};
return (
<button disabled={!isOnline} onClick={handleSaveClick}>
{isOnline ? 'Save progress' : 'Reconnecting...'}
</button>
);
};
ネットワーク接続のロジックは、前掲StatusBar
コンポーネントからそのままコピー&ペーストしました。動きは、つぎのサンプル002でお確かめください。
サンプル002■React + TypeScript: Reusing Logic with Custom Hooks 02
前掲ふたつのアプリケーションは見た目が異なるものの、ネットワーク接続のロジックはまったく同じです。別モジュールに切り出せれば、使い回しができるでしょう。
共通のロジックを自作のカスタムフックとして切り出す
前掲ふたつのアプリケーションに共通する状態(isOnline
)とエフェクト(useEffect
)のロジックを備えたカスタムフックuseOnlineStatus
ができたとします。そうして書き替えた、ふたつのコンポーネントの定めがつぎのコードです。
export const StatusBar: FC = () => {
const isOnline = useOnlineStatus();
return <h1>{isOnline ? '✅ Online' : '❌ Disconnected'}</h1>;
};
export const SaveButton: FC = () => {
const isOnline = useOnlineStatus();
const handleSaveClick = () => {
console.log('✅ Progress saved');
};
return (
<button disabled={!isOnline} onClick={handleSaveClick}>
{isOnline ? 'Save progress' : 'Reconnecting...'}
</button>
);
};
フックの戻り値は状態変数(isOnline
)としました。本体のロジックは、もとのコンポーネントからそのまま移してしまって構いません。フックのモジュールsrc/useOnlineStatus.ts
の実装はつぎのとおりです。
import { useState, useEffect } from 'react';
export const useOnlineStatus = () => {
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
const handleOnline = () => {
setIsOnline(true);
};
const handleOffline = () => {
setIsOnline(false);
};
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
return isOnline;
};
ふたつのコンポーネントはルートモジュールsrc/App.tsx
でまとめましょう。はじめのコード例と同じく、ネットワークにつないだり、切ったりすると、それぞれの画面上の表示が変わります。以下のサンプル003で動きをお確かめください。
import { SaveButton } from './SaveButton';
import { StatusBar } from './StatusBar';
export default function App() {
return (
<>
<SaveButton />
<StatusBar />
</>
);
}
サンプル003■React + TypeScript: Reusing Logic with Custom Hooks 03
カスタムフックにより、コンポーネント間のロジックの重複が減らせました。もっと大事なのは、コンポーネント内のコードが読みやすくなったことです。フックがどのように実装(ブラウザへのイベント登録)されているか、コンポーネントからは問いません。フックの名前(オンラインステータスを使う)と戻り値から、何をするのかがわかるのです。
カスタムフックに切り出すことで、細々とした処理は隠蔽できます。外部システムやブラウザ API とのやり取りなどは、フックの中で済ませればよいことです。すると、コンポーネントのコードには、実装でなく意図が示されるようになるでしょう。
フックの名前はつねにuse
で始める
Reactアプリケーションは、コンポーネントで組み立てられます。そして、コンポーネントを構築するのがフックです。フックには組み込みやカスタムがあります。他の人がつくったカスタムフックを使うことも少なくないでしょう。さらに、自作することも考えられます。
コンポーネントやフックを書く場合、名前づけはつぎの規則にしたがってください。
-
Reactコンポーネントの名前は大文字で始めます。たとえば、前掲コード例の
StatusBar
やSaveButton
です。また、コンポーネントの戻り値は、JSXのようにReactが表示の仕方を知っている値でなければなりません。 -
フックは
use
で始めて頭文字が大文字の名前を続けてください。たとえば、useState
(組み込み)やuseOnlineStatus
(カスタム)です。フックからは任意の値が返せます。
この規則にしたがうことにより、コンポーネントを見るだけで、状態やエフェクト、その他のReactの機能がどこに「隠されて」いるのか、つねに明確に把握できるようになるでしょう。たとえば、コンポーネントから呼び出す関数がgetColor()
だとしたら、名前の頭にuse
はつかないので、フックではありません。つまり、関数はReactの状態をもたないということです。けれど、呼び出す関数の名前がuseOnlineStatus()
であればフックを意味します。状態を備えるなど、他のフックの呼び出しも含まれていそうです。
[注記] 前述の規則に反した名前づけは、リンターがReactの設定になっていると警告されます。たとえば、前掲サンプル003のカスタムフックuseOnlineStatus
の名前をgetOnlineStatus
に書き替えてみましょう。示されるのはつぎの警告です。
React Hook "useState" is called in function "getOnlineStatus" that is neither a React function component nor a custom React Hook function. React component names must start with an uppercase letter.
React Hook "useEffect" is called in function "getOnlineStatus" that is neither a React function component nor a custom React Hook function. React component names must start with an uppercase letter.
-
use
で始まらない関数getOnlineStatus
からはフック(useState
とuseEffect
)が呼び出せません。- フックが呼び出せるのは、React関数コンポーネントかカスタムフックだけです。
- 関数がカスタムフックでなくコンポーネントであれば、名前は大文字で始めなければなりません。
レンダリング中に呼び出される関数の名前はすべてuse
で始める方がよいか
フックを呼び出さない関数は、通常の関数です。名前をuse
で始めることは避けてください。
// 🔴 NG: フックを使わない関数にフックの名前づけ
// function useSorted(items: Item[]) {
// ✅ OK: フックを使わない通常の関数
function getSorted(items: Item[]) {
return items.slice().sort();
}
通常の関数であれば、条件文の中など、どこからでも呼び出せます。
function List({ items: Item[], shouldSort: boolean }) {
let displayedItems = items;
if (shouldSort) {
// ✅ OK: フックではないので条件文中からでも呼び出せる
displayedItems = getSorted(items);
}
// ...
}
内部でフックを用いる関数は、フックですので名前の頭にuse
をつけるべきです。
// ✅ OK: 他のフックを用いるのでフックとする
function useAuth() {
return useContext(Auth);
}
もっとも、フックは内部からフックを呼ばなければならないという技術的な制約はReactにありません。仕様のうえでは、他のフックを呼び出さないフックも定められます。けれど、混乱や制約のもととなるので、避けた方がよいでしょう。ただし、まれに役立つことはあるかもしれません。たとえば、関数が今現在はフックを使っていないとしましょう。でも将来、フックの呼び出しを加える可能性があるという場合です。それを示すために名前をuse
で始めるのは意味があります。
// ✅ OK: あとで他のフックを使うつもりなのでフックとして定める
function useAuth() {
// TODO: 認証の実装ができたらダミーの行と差し替える
// return useContext(Auth);
return TEST_USER;
}
こうすると、コンポーネントは関数を条件文内から呼び出せません。これは、関数内に実際にフックの呼び出しを加え、フックとして使うようになったときに重要です。将来にわたって、フックとして用いることがない場合には、名前の先頭にuse
を加える(フックとして定める)ことは避けてください。
カスタムフックが共有するのはロジックであって状態ではない
カスタムフックが切り出された前掲サンプル003で、ネットワークをつないだり切ったりすると、ふたつのコンポーネントはともに更新されました。けれど、ひとつの同じ状態変数isOnline
が、ふたつの間で共有されたと捉えるのは誤りです。
export const StatusBar: FC = () => {
const isOnline = useOnlineStatus();
};
export const SaveButton: FC = () => {
const isOnline = useOnlineStatus();
};
動作はカスタムフックを用いる前のコンポーネント(サンプル001および002)と変わるところはありません。
export const StatusBar: FC = () => {
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
}, []);
};
export const SaveButton: FC = () => {
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
}, []);
};
同じカスタムフックを使っても、コンポーネントの状態変数とエフェクトは、それぞれ別個だからです。ふたつのもっている値が一致するのは、(ネットワークの接続/切断という)同じ外部の値に同期しているからにすぎません。
別のForm
コンポーネントの例で考えてみましょう(サンプル004)。以下のコードから、共通に切り出せるロジックはつぎのとおりです。
- 状態変数。
-
firstName
とlastName
。
-
-
onChange
イベントハンドラ。-
handleFirstNameChange
とhandleLastNameChange
関数。
-
- 戻り値のJSXが含む
<input />
要素に加えるプロパティ。-
value
とonChange
。
-
export default function Form() {
const [firstName, setFirstName] = useState('Mary');
const [lastName, setLastName] = useState('Poppins');
const handleFirstNameChange: ChangeEventHandler<HTMLInputElement> = ({
target: { value }
}) => {
setFirstName(value);
};
const handleLastNameChange: ChangeEventHandler<HTMLInputElement> = ({
target: { value }
}) => {
setLastName(value);
};
return (
<>
<label>
First name:
<input value={firstName} onChange={handleFirstNameChange} />
</label>
<label>
Last name:
<input value={lastName} onChange={handleLastNameChange} />
</label>
</>
);
}
サンプル004■React + TypeScript: Reusing Logic with Custom Hooks 04
共通のロジックを切り出したのが、つぎのカスタムフックモジュールsrc/useFormInput.ts
です。
import { useState } from 'react';
import type { ChangeEventHandler } from 'react';
export const useFormInput = (initialValue: string) => {
const [value, setValue] = useState(initialValue);
const handleChange: ChangeEventHandler<HTMLInputElement> = ({
target: { value }
}) => {
setValue(value);
};
const inputProps = {
value,
onChange: handleChange
};
return inputProps;
};
すると、Form
コンポーネントはカスタムフックuseFormInput
を用いてつぎのように書き替えられます(サンプル005)。2度呼び出すフックに渡す状態変数の初期値が異なることにご注目ください。これは、状態変数(value
)もそれを扱うイベントハンドラ関数(handleChange
)も、フックの呼び出しごとに異なるということです。
export default function Form() {
// const [firstName, setFirstName] = useState('Mary');
// const [lastName, setLastName] = useState('Poppins');
const firstNameProps = useFormInput('Mary');
const lastNameProps = useFormInput('Poppins');
/* const handleFirstNameChange: ChangeEventHandler<HTMLInputElement> = ({
target: { value }
}) => {
setFirstName(value);
};
const handleLastNameChange: ChangeEventHandler<HTMLInputElement> = ({
target: { value }
}) => {
setLastName(value);
}; */
return (
<>
<label>
First name:
{/* <input value={firstName} onChange={handleFirstNameChange} /> */}
<input {...firstNameProps} />
</label>
<label>
Last name:
{/* <input value={lastName} onChange={handleLastNameChange} /> */}
<input {...lastNameProps} />
</label>
<p>
<b>
{/* Good morning, {firstName} {lastName}. */}
Good morning, {firstNameProps.value} {lastNameProps.value}.
</b>
</p>
</>
);
}
サンプル005■React + TypeScript: Reusing Logic with Custom Hooks 05
カスタムフックは、状態が備わったロジックを切り出して共有します。共通の状態を保持するのではありません。状態はフックの呼び出しごとに別個です。そのため、サンプル005では、カスタムフックuseFormInput
の同じロジックを用いて、ふたつの異なる状態が扱えました。
状態を共有したいという場合には「React + TypeScript: コンポーネント間で状態を共有する」をご参照ください。
フックの間でリアクティブな値を渡す
カスタムフックの中のコードは、コンポーネントが再レンダーされるごとに再実行されます。したがって、コンポーネントと同じく、カスタムフックは純粋な関数でなければなりません。カスタムフックのコードは、それを用いるコンポーネント本体の一部だと考えてください。
カスタムフックは、コンポーネントとともに再レンダーされます。そのため、つねに最新のプロパティと状態が受け取れるのです。つぎのChatRoom
コンポーネントで、サーバーURL(serverUrl
)やチャットルーム(roomId
)を変更してみてください(サンプル006)。
export const ChatRoom: FC<Props> = ({ roomId }) => {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');
useEffect(() => {
const options = {
serverUrl,
roomId
};
const connection = createConnection(options);
connection.on('message', (connectionInfo, theme) => {
showNotification('New connection: ' + connectionInfo, theme);
});
connection.connect();
return () => connection.disconnect();
}, [roomId, serverUrl]);
};
export const createConnection = ({ serverUrl, roomId }: Props) => {
// 実際にはサーバーとの接続を実装
let messageCallback: MessageCallback | null;
return {
connect() {
const connectionInfo = `✅ Connecting to "${roomId}" room at ${serverUrl}...`;
console.log(connectionInfo);
messageCallback && messageCallback(connectionInfo, 'white');
},
disconnect() {
const connectionInfo = `❌ Disconnected from "${roomId}" room at ${serverUrl}`;
console.log(connectionInfo);
messageCallback && messageCallback(connectionInfo, 'dark');
messageCallback = null;
},
on(event: string, callback: MessageCallback) {
messageCallback = callback;
}
};
};
サンプル006■React + TypeScript: Reusing Logic with Custom Hooks 06
serverUrl
やroomId
の値が変わると、エフェクトは変更に「反応」して再同期されます。コンソールの出力から、チャットはエフェクトの依存配列が改められるたびに再接続していることを確かめられるでしょう。
つぎに、ChatRoom
コンポーネントから、エフェクトのコードを新たなカスタムフック(useChatRoom
)に移します。
import { useEffect } from 'react';
import { createConnection } from './chat';
import { showNotification } from './notifications';
type Props = { serverUrl: string; roomId: string };
export const useChatRoom = ({ serverUrl, roomId }: Props) => {
useEffect(() => {
const options = { serverUrl, roomId };
const connection = createConnection(options);
connection.on('message', (connectionInfo, theme) => {
showNotification('New connection: ' + connectionInfo, theme);
});
connection.connect();
return () => connection.disconnect();
}, [roomId, serverUrl]);
};
これで、ChatRoom
コンポーネントにはエフェクトが要らなくなりました。カスタムフックuseChatRoom
を呼び出せば済み、実装について気にする必要はありません。そして、コンポーネントからロジックが切り分けられて、見やすいコードになったでしょう。もちろん、プロパティや状態の変更にロジックが反応することは変わりません(サンプル007)。
export const ChatRoom: FC<Props> = ({ roomId }) => {
/* useEffect(() => {
}, [roomId, serverUrl]); */
useChatRoom({ roomId, serverUrl });
};
サンプル007■React + TypeScript: Reusing Logic with Custom Hooks 07
このコード例では、ChatRoom
コンポーネントがひとつのフック(useState
)から得た状態変数(serverUrl
)をプロパティ(roomId
)と合わせて別のフック(useChatRoom
)に渡しました。これらは、ChatRoom
コンポーネントが再レンダーされるたびにフックを更新するリアクティブな値です。再レンダー後に受け取った値が異なれば、エフェクトはチャットに再接続します。
export const ChatRoom: FC<Props> = ({ roomId }) => {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');
useChatRoom({ roomId, serverUrl });
};
カスタムフックにイベントハンドラを渡す
[注記] この項でご紹介するのは実験的なAPI(useEffectEvent
)です。まだ、Reactの安定版にはリリースされていません。
カスタムフックuseChatRoom
の使い回しが増えてくると、コンポーネントから動きをカスタマイズしたくなることもあるでしょう。たとえば、今メッセージが届いたらどうするかは、フックの中に決め打ちです。
export const useChatRoom = ({ serverUrl, roomId }: Props) => {
useEffect(() => {
connection.on('message', (connectionInfo, theme) => {
showNotification('New connection: ' + connectionInfo, theme);
});
}, [roomId, serverUrl]);
};
このロジックをコンポーネントから関数(onReceiveMessage()
)として渡せるようにしましょう(サンプル008)。
export const ChatRoom: FC<Props> = ({ roomId }) => {
// useChatRoom({ roomId, serverUrl });
useChatRoom({
roomId,
serverUrl,
onReceiveMessage(connectionInfo, theme) {
showNotification('New connection: ' + connectionInfo, theme);
}
});
};
カスタムフックuseChatRoom
は、コンポーネントの渡す引数オブジェクトからイベントハンドラ関数(onReceiveMessage
)を取り出し、メッセージが届いたら呼び出します。
// export const useChatRoom = ({ serverUrl, roomId }: Props) => {
export const useChatRoom = ({ serverUrl, roomId, onReceiveMessage }: Props) => {
useEffect(() => {
connection.on("message", (connectionInfo, theme) => {
// showNotification("New connection: " + connectionInfo, theme);
onReceiveMessage(connectionInfo, theme);
});
// }, [roomId, serverUrl]);
}, [roomId, serverUrl, onReceiveMessage]); // ✅ OK: すべての依存値が宣言
};
関数onReceiveMessage
は、エフェクトの依存配列に含めなければなりません。すると、動作はするものの、コンポーネントが再レンダーされるたびに、チャットは再接続されてしまいます。望ましいことではありません。
サンプル008■React + TypeScript: Reusing Logic with Custom Hooks 08
ハンドラ関数(onReceiveMessage
)を依存配列から除くには、エフェクトイベントで包むことです。ChatRoom
コンポーネントが再レンダーされるたびにチャットを再接続することはなくなります。動作と各モジュールのコードは以下のサンプル009でお確かめください。
export const useChatRoom = ({ serverUrl, roomId, onReceiveMessage }: Props) => {
const onMessage = useEffectEvent(onReceiveMessage);
useEffect(() => {
connection.on("message", (connectionInfo, theme) => {
// onReceiveMessage(connectionInfo, theme);
onMessage(connectionInfo, theme);
});
// }, [roomId, serverUrl, onReceiveMessage]);
}, [roomId, serverUrl]); // ✅ OK: すべての依存値が宣言
};
サンプル009■React + TypeScript: Reusing Logic with Custom Hooks 09
カスタムフックuseChatRoom
の引数オブジェクトに、イベントハンドラonReceiveMessage
が加えられました。コンポーネントはそれさえ知っていれば、フックが内部的にどう処理するか気にする必要はありません。これがカスタムフックの優れた力です。
カスタムフックはいつ使うか
コードが共有化できるからといって、何でもカスタムフックにすることはありません。たとえば、簡単なカスタムフックの例としてご紹介した前掲サンプル005のuseFormInput
は、実践ではあえてロジックを切り出すまでもないでしょう。
けれど、エフェクトを書くときはつねに、カスタムフックに包むことでよりわかりやすくできないか考えてください。エフェクトはそうたびたび使うべきではありません。エフェクトを用いるのはつぎのふたつの場合でしょう。
- 「Reactの外に出て」外部システムと同期する。
- ReactのAPIに組み込まれていない処理を行う。
これらの処理は、カスタムフックに包むことで、何をしたいのか、データフローがどうなるのかはっきりするはずです。
たとえば、以下のコンポーネントShippingForm
はつぎのふたつのドロップダウンを備えます。
-
country
に応じた都市のリスト(cities
)。 - 選択された都市(
city
)内にある地区のリスト(areas
)。
function ShippingForm({ country }: Props) {
const [cities, setCities] = useState<string[] | null>(null);
// エフェクトはcountryに応じてcitiesを取得
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<string | null>(null);
const [areas, setAreas] = useState<string[] | null>(null);
// エフェクトはcityに応じてareasを取得
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]);
}
コードには重複が見られて冗長です。けれど、ふたつのエフェクトは互いに独立させることが適切といえます。それぞれは異なるものを同期しているからです。ひとつのエフェクトにまとめるべきではありません。替わりに、ふたつに共通のロジックをカスタムフック(useData
)に切り出せば、ShippingForm
コンポーネントは簡素化できるでしょう。
function useData(url: string | null) {
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 }: Props) {
const cities = useData(`/api/cities?country=${country}`);
const [city, setCity] = useState<string | null>(null);
const areas = useData(city ? `/api/areas?city=${city}` : null);
}
カスタムフックに切り出したことにより、コードのデータの流れがはっきりしたでしょう。フックuseData
に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: string }) {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');
// 🔴 NG: 「ライフサイクル」のカスタムフックを使用
useMount(() => {
const connection = createConnection({ roomId, serverUrl });
connection.connect();
post('/analytics/event', { eventName: 'visit_chat' });
});
}
// 🔴 NG: 「ライフサイクル」のカスタムフックを定義
function useMount(fn: () => void) {
useEffect(() => {
fn();
}, []); // 🔴 NG: 依存値fnが不足
}
「ライフサイクル」のカスタムフックuseMount
は、Reactの設計思想に合いません。たとえば、このコード例には誤りがあります(roomId
とserverUrl
の変更に「反応」しないことです)。けれど、リンターは警告を示しません。リンターが確かめるのはuseEffect
の直接的な呼び出しだけだからです。カスタムフックのことまではわかりません。
エフェクトを書くのであれば、まず用いるのは直接ReactのAPIです。
function ChatRoom({ roomId: string }) {
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');
// ✅ OK: カスタムフックの名前が目的を表す
useChatRoom({ serverUrl, roomId });
useImpressionLog('visit_chat', { roomId });
}
優れたカスタムフックは、すべきことが絞り込まれて、コードの呼び出しを宣言的にします。たとえば、useChatRoom(options)
の呼び出しはチャットルームに接続するだけです。他方で、useImpressionLog(eventName, extraData)
はアナリティクスへの表示ログの送信のみを担います。用途を限定しない抽象的なカスタムフックは、将来的に問題の解決より発生の原因になるかもしれません。
ここで、前掲サンプル003に戻りましょう。カスタムフックuseOnlineStatus
はuseState
とuseEffect
を組み合わせて実装しましした。けれど、このコードは不十分で、まだ見落としがあります。たとえば、コンポーネントがマウントされたとき、isOnline
の初期値はtrue
です。でも、ネットワークはすでにオフラインかもしれません。ブラウザのnavigator.onLine
APIを使えば調べられます。ただし、直接使うと、サーバが初期HTMLを生成する場合に動作しません。
export const useOnlineStatus = () => {
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
const handleOnline = () => {
setIsOnline(true);
};
const handleOffline = () => {
setIsOnline(false);
};
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
return isOnline;
};
React 18に備わった、これらの問題が解決できる専用のAPIであるuseSyncExternalStore
(構文の説明は後述)を使いましょう。書き替えるのは、カスタムフックuseOnlineStatus
の実装だけです。
const subscribe = (callback: () => void) => {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
};
export const useOnlineStatus = () => {
/* const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
}, []);
return isOnline; */
return useSyncExternalStore(
subscribe,
() => navigator.onLine, // クライアントの値を取得する
() => true // サーバーの値を取得する
);
};
コンポーネントからのカスタムフックの呼び出しに変わりはありません。つまり、コンポーネントにはまったく触れることなく移行ができました(サンプル010)。
export const StatusBar: FC = () => {
const isOnline = useOnlineStatus();
};
export const SaveButton: FC = () => {
const isOnline = useOnlineStatus();
};
サンプル010■React + TypeScript: Reusing Logic with Custom Hooks 10
これも、エフェクトをカスタムフックで包むことが有益な理由のひとつです。具体的には、つぎのような点が挙げられるでしょう。
- データフローがエフェクトにどう入って出てゆくのかはっきりします。
- コンポーネントについて考えるべきは意図です。エフェクトの実装がどうなっているかは気にしなくて構いません。
- Reactが新たな機能を追加したときも、コンポーネントは変更せずに済みます。エフェクトを置き替えればよいからです。
アプリケーションのコンポーネントから、共通する定型コードをカスタムフックに切り出すことは役立つでしょう。コンポーネントのコードが表すのは意図になり、コンポーネント内にエフェクトをたびたび書くことは避けられるからです。多くのカスタムフックがReactコミュニティにはあり、メンテナンスされています。
useSyncExternalStore
の構文
useSyncExternalStore
は、外部データストアから値を読み取るフックです。
構文
useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?)
引数
-
subscribe
: ストアに登録(サブスクライブ)する関数です。登録解除のための関数を返さなければなりません。 -
getSnapshot
: コンポーネントが必要なデータのスナップショットをストアから読み取って返す関数です。 -
getServerSnapshot
(省略可能): ストアのデータの初期スナップショットを返す関数で、サーバレンダリング中、およびクライアント上でのサーバレンダリングされたコンテンツのハイドレーション中にのみ用いられます。
戻り値
レンダリングロジックで使えるストアの現在のスナップショットです。
Reactはデータ取得のための組み込みソリューションを提供するか
詳しくはまだ検討しています。けれど、将来予定しているのは、データ取得のつぎのような書き方です。
import { use } from 'react'; // 未実装
function ShippingForm({ country }: Props) {
const cities = use(fetch(`/api/cities?country=${country}`));
const [city, setCity] = useState<string | null>(null);
const areas = city ? use(fetch(`/api/areas?city=${city}`)) : null;
}
アプリケーションが予め前掲コード例のuseData
のようなカスタムフックを使っていれば、新しく推奨される手法に移行するのも楽でしょう。コンポーネントごとに内部のエフェクトを、いちいち手書きで修正せずに済むからです。ただし、カスタムフックへの切り出しは必須ではありません。どちらがよいかは、選択次第です。
ロジックの切り分け方はひとつではない
アプリケーションのロジックの切り分け方はひとつではありません。フェードインアニメーションを標準JavaScriptで実装したいとしましょう。用いるのはブラウザのrequestAnimationFrame
APIです。まずは、エフェクトにアニメーションループを定めます(サンプル011)。フレームごとに、DOMノードのopacity
を初期値の0から1になるまで更新するアニメーションです。DOMノードはref
で保持しなければなりません。
export const Welcome: FC = () => {
const ref = useRef<HTMLHeadingElement>(null);
useEffect(() => {
const duration = 1000;
const node = ref.current;
let startTime: number | null = performance.now();
let frameId: number | null = null;
const onProgress = (progress: number) => {
if (!node) return;
node.style.opacity = String(progress);
};
const onFrame = (now: number) => {
const timePassed = startTime ? now - startTime : 0;
const progress = Math.min(timePassed / duration, 1);
onProgress(progress);
if (progress < 1) {
frameId = requestAnimationFrame(onFrame);
}
};
const start = () => {
onProgress(0);
startTime = performance.now();
frameId = requestAnimationFrame(onFrame);
};
const stop = () => {
frameId && cancelAnimationFrame(frameId);
startTime = null;
frameId = null;
};
start();
return () => stop();
}, []);
};
サンプル011■React + TypeScript: Reusing Logic with Custom Hooks 11
このコード例ではカスタムフックは使いませんでした。つぎは、フェードインアニメーションさせるエフェクトのロジックをカスタムフックuseFadeIn
に切り出しましょう(サンプル012)。
export const useFadeIn = (
ref: RefObject<HTMLHeadingElement>,
duration: number
) => {
useEffect(() => {
const node = ref.current;
let startTime: number | null = performance.now();
let frameId: number | null = null;
const onProgress = (progress: number) => {
if (!node) return;
node.style.opacity = String(progress);
};
const onFrame = (now: number) => {
const timePassed = startTime ? now - startTime : 0;
const progress = Math.min(timePassed / duration, 1);
onProgress(progress);
if (progress < 1) {
frameId = requestAnimationFrame(onFrame);
}
};
const start = () => {
onProgress(0);
startTime = performance.now();
frameId = requestAnimationFrame(onFrame);
};
const stop = () => {
frameId && cancelAnimationFrame(frameId);
startTime = null;
frameId = null;
};
start();
return () => stop();
}, [ref, duration]);
};
export const Welcome: FC = () => {
const ref = useRef<HTMLHeadingElement>(null);
/* useEffect(() => {
}, []); */
useFadeIn(ref, 1000);
};
サンプル012■React + TypeScript: Reusing Logic with Custom Hooks 12
さらに、別の新たなカスタムフックへの切り出しもできます。useFadeIn
の中のアニメーション設定ロジックです。カスタムフックuseAnimationLoop
としましょう(サンプル013)。
export const useAnimationLoop = (
isRunning: boolean,
drawFrame: (timePassed: number) => void
) => {
const onFrame = useEffectEvent(drawFrame);
useEffect(() => {
if (!isRunning) {
return;
}
const startTime = performance.now();
let frameId: number | null = null;
const tick = (now: number) => {
const timePassed = now - startTime;
onFrame(timePassed);
frameId = requestAnimationFrame(tick);
};
tick(performance.now());
return () => {
if (frameId) cancelAnimationFrame(frameId);
};
}, [isRunning]);
};
export const useFadeIn = (
ref: RefObject<HTMLHeadingElement>,
duration: number
) => {
const [isRunning, setIsRunning] = useState(true);
/* useEffect(() => {
}, [ref, duration]); */
useAnimationLoop(isRunning, (timePassed: number) => {
const progress = Math.min(timePassed / duration, 1);
if (ref.current) {
ref.current.style.opacity = String(progress);
}
if (progress === 1) {
setIsRunning(false);
}
});
};
なお、useAnimationLoop
には実験的なAPIであるuseEffectEvent
を用いました。エフェクトから関数の呼び出しを切り出し、依存値に含めないためです。
サンプル013■React + TypeScript: Reusing Logic with Custom Hooks 13
カスタムフックへのロジックの切り出し方は、ひとつではありません。コード分割の線引きを決めるのは開発者です。今回のコード例なら、カスタムフックuseFadeIn
のエフェクトからアニメーションのロジックをクラス(FadeInAnimation
)に移す手もあるでしょう(サンプル014)。
export class FadeInAnimation {
node: HTMLHeadingElement | null;
duration: number;
startTime: number | null;
frameId: number | null;
constructor(node: HTMLHeadingElement | null) {
this.node = node;
this.duration = 0;
this.startTime = null;
this.frameId = null;
}
start(duration: number) {
this.duration = duration;
this.onProgress(0);
this.startTime = performance.now();
this.frameId = requestAnimationFrame(() => this.onFrame());
}
onFrame() {
const timePassed = this.startTime ? performance.now() - this.startTime : 0;
const progress = Math.min(timePassed / this.duration, 1);
this.onProgress(progress);
if (progress < 1) {
this.frameId = requestAnimationFrame(() => this.onFrame());
} else {
this.stop();
}
}
onProgress(progress: number) {
if (!this.node) return;
this.node.style.opacity = String(progress);
}
stop() {
this.frameId && cancelAnimationFrame(this.frameId);
this.startTime = null;
this.frameId = null;
this.duration = 0;
}
}
export const useFadeIn = (
ref: RefObject<HTMLHeadingElement>,
duration: number
) => {
useEffect(() => {
const animation = new FadeInAnimation(ref.current);
animation.start(duration);
return () => animation.stop();
}, [ref, duration]);
};
サンプル014■React + TypeScript: Reusing Logic with Custom Hooks 14
エフェクトを用いると、Reactは外部システムと同期できます。エフェクト同士の調整(たとえば、複数のアニメーションの連動)がかなり増えてくるようになったら、前掲コード例(サンプル014)のようにロジックをエフェクトやフックから完全に切り出してしまうことも検討に値するでしょう。そうすれば、外に出したロジックは「外部システム」になります。エフェクトは簡素に保てるでしょう。Reactの外に移したシステムに、メッセージを送れば済むからです。
この項のコード例は、フェードインのロジックをJavaScriptで書くという想定でした。ただし、今回の具体的なフェードインアニメーションの例で、もっと簡潔で効率的なのは標準のCSSアニメーションで実装することです(サンプル015)。このように、フックを使わずに済むこともあります。
.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;
}
}
export const Welcome: FC = () => {
return (
<h1 className="welcome">Welcome</h1>
);
};
サンプル015■React + TypeScript: Reusing Logic with Custom Hooks 15
まとめ
この記事では、つぎのような項目についてご説明しました。
- コンポーネント間でロジックを共有するのがカスタムフックです。
- カスタムフックの名前は
use
ではじめて頭文字が大文字の単語を続けてください。 - カスタムフックが共有するのは状態をもつロジックです。状態そのものではありません。
- リアクティブな値はフックの間で受け渡しできます。その値はつねに最新です。
- フックはコンポーネントが再レンダーされるごとに、再実行されます。
- カスタムフックのコードは、コンポーネントと同じく純粋でなければなりません。
- カスタムフックがエフェクトのために受け取るイベントハンドラは、エフェクトイベントで包んで切り出しましょう。
-
useMount
のような抽象的なカスタムフックは書かないでください。目的はいつも限定するようにします。 - コードをどこでどう切り分けるか線引きするのは開発者です。