カスタムフックによるロジックの再利用

Reactには、useStateuseContextuseEffectなどのいくつかの組み込みフックがあります。場合によっては、より具体的な目的(データの取得、ユーザーのオンライン状態の追跡、チャットルームへの接続など)のフックが必要になることがあります。Reactにはこれらのフックが見つからない場合がありますが、アプリケーションのニーズに合わせて独自のフックを作成できます。

学習内容

  • カスタムフックとは何か、そして独自のフックを作成する方法
  • コンポーネント間のロジックの再利用方法
  • カスタムフックの命名と構造化の方法
  • カスタムフックを抽出する時と理由

カスタムフック:コンポーネント間のロジックの共有

ネットワークに大きく依存するアプリを開発しているとします(ほとんどのアプリはそうでしょう)。アプリの使用中にネットワーク接続が誤って切断された場合に、ユーザーに警告したいとします。どのようにすればよいでしょうか?コンポーネントには2つのものが必要なようです。

  1. ネットワークがオンラインかどうかを追跡する状態。
  2. グローバルなonlineおよびofflineイベントを購読し、その状態を更新する効果。

これにより、コンポーネントはネットワークの状態と同期した状態を保ちます。次のようなものから始めることができます。

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がアクションに応じてどのように更新されるかに注目してください。

今度は、別のコンポーネントでも同じロジックを使用したいとします。ネットワークがオフラインの間に無効になり、「保存」の代わりに「再接続中…」を表示する「保存」ボタンを実装したいとします。

isOnline状態と効果を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つのコンポーネントは正常に動作しますが、それらの間のロジックの重複は残念です。異なる視覚的な外観を持っているにもかかわらず、それらの間のロジックを再利用したいようです。

コンポーネントからの独自のカスタムフックの抽出

useStateuseEffectと同様に、組み込みの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 />
    </>
  );
}

ネットワークのオンとオフを切り替えて、両方のコンポーネントが更新されることを確認してください。

これで、コンポーネントにはそれほど多くの重複したロジックがありません。さらに重要なのは、コンポーネント内のコードは、実行したいこと(オンライン状態を使用する!)を記述するようになり、実行方法(ブラウザイベントを購読すること)を記述するのをやめることです。

ロジックをカスタムフックに抽出すると、外部システムまたはブラウザAPIの処理方法に関する複雑な詳細を隠すことができます。コンポーネントのコードは、実装ではなく、意図を表すようになります。

フック名は常にuseで始まります

Reactアプリケーションはコンポーネントから構築されます。コンポーネントは、ビルトインまたはカスタムのいずれかのフックから構築されます。他の人が作成したカスタムフックを頻繁に使用する可能性がありますが、時々自分で書くこともあります。

これらの命名規則に従う必要があります。

  1. Reactコンポーネント名は、大文字で始める必要があります。 例えば StatusBarSaveButton のように。Reactコンポーネントは、JSXなどのReactが表示できるものを返す必要があります。
  2. フック名は、use に続いて大文字で始める必要があります。 例えば useState(ビルトイン)または useOnlineStatus(カスタム、ページの先頭のように)など。フックは任意の値を返すことができます。

この規則により、コンポーネントを見て、その状態、効果、その他のReact機能がどこに「隠れている」かを常に知ることができます。たとえば、コンポーネント内に getColor() 関数呼び出しがあると、その名前が use で始まらないため、内部にReactの状態が含まれている可能性がないと確信できます。ただし、useOnlineStatus() のような関数呼び出しには、内部で他のフックへの呼び出しが含まれている可能性が非常に高いです!

注意

リンターがReact用に設定されている場合、この命名規則が適用されます。useOnlineStatusgetOnlineStatus に名前変更して、上のサンドボックスをスクロールしてください。リンターが useStateuseEffect を内部で呼び出せなくなることに注意してください。フックとコンポーネントだけが他のフックを呼び出すことができます!

詳細

レンダリング中に呼び出されるすべての関数は、useプレフィックスで始める必要がありますか?

いいえ。フックを呼び出さない関数は、フックである必要はありません。

関数がフックを呼び出さない場合は、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);
}
// ...
}

関数に use プレフィックスを付けて(そしてフックにする)、内部で少なくとも1つのフックを使用する必要があります。

// ✅ 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;
}

すると、コンポーネントはそれを条件付きで呼び出すことができなくなります。これは、実際に内部にフック呼び出しを追加すると重要になります。内部でフックを使用する予定がない場合(現在も将来も)、フックにしないでください。

カスタムフックを使用すると、状態自体ではなく、状態のあるロジックを共有できます

前の例では、ネットワークのオンとオフを切り替えると、両方のコンポーネントが同時に更新されました。ただし、単一の isOnline 状態変数がそれらの間で共有されていると考えるのは間違っています。このコードを見てください。

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(() => {
// ...
}, []);
// ...
}

これらは完全に独立した2つの状態変数と効果です!ネットワークがオンかどうかという同じ外部値で同期させたため、同時に同じ値になっただけです。

これをより明確にするために、別の例が必要です。この 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>
    </>
  );
}

各フォームフィールドに重複するロジックがあります。

  1. 状態の一部があります(firstNamelastName)。
  2. 変更ハンドラーがあります(handleFirstNameChangehandleLastNameChange)。
  3. その入力の 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つの状態変数のみ宣言されていることに注意してください。

ただし、Form コンポーネントは useFormInput2回呼び出します。

function Form() {
const firstNameProps = useFormInput('Mary');
const lastNameProps = useFormInput('Poppins');
// ...

そのため、2つの別々の状態変数を宣言するのと同じように動作します!

カスタムフックを使うと、状態に関するロジックを共有できますが、状態そのものは共有できません。 カスタムフックへの各呼び出しは、同じフックへの他のすべての呼び出しから完全に独立しています。

複数のコンポーネント間で状態そのものを共有する必要がある場合は、代わりに上位に持ち上げて、子コンポーネントに渡す方法を使用してください。

フック間でリアクティブな値を渡す

カスタムフック内のコードは、コンポーネントの再レンダリングのたびに再実行されます。このため、コンポーネントと同様に、カスタムフックは純粋である必要があります。 カスタムフックのコードをコンポーネント本体の一部と考えてください!

カスタムフックはコンポーネントと共に再レンダリングされるため、常に最新の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を変更すると、Effectは変更に「反応」し、再同期します。Effectの依存関係を変更するたびに、チャットが再接続されることをコンソールメッセージで確認できます。

Effectのコードをカスタムフックに移動してみましょう。

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>
</>
);
}

ずっとシンプルになりました!(しかし、同じ動作をします。)

ロジックは依然としてpropと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コンポーネントが再レンダリングされるたびに、最新のroomIdserverUrlがフックに渡されます。これが、再レンダリング後の値が異なるたびにEffectがチャットに再接続する理由です。(オーディオやビデオ処理ソフトウェアを扱ったことがある場合、このようにフックをチェーンすることは、ビジュアルまたはオーディオエフェクトをチェーンすることに似ているかもしれません。useStateの出力がuseChatRoomの入力に「供給」されるかのようです。)

カスタムフックにイベントハンドラーを渡す

開発中

このセクションでは、まだ安定版のReactでリリースされていない実験的なAPIについて説明します。

より多くのコンポーネントで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がどのように機能するかを知る必要がなくなったことに注目してください。他のコンポーネントに追加し、他のオプションを渡しても、同じように機能します。それがカスタムフックの力です。

カスタムフックを使用する場合

コードの小さな重複部分ごとにカスタムフックを抽出する必要はありません。多少の重複は問題ありません。たとえば、前の例のように単一のuseState呼び出しをラップするためにuseFormInputフックを抽出することは、おそらく不必要です。

ただし、Effectを記述する場合は、カスタムフックにもラップする方が明確になるかどうかを検討してください。Effectは頻繁に必要ではありません。そのため、Effectを記述する必要があるということは、外部システムと同期したり、Reactに組み込みのAPIがないことを行うために「Reactの外に出る」必要があることを意味します。カスタムフックにラップすることで、意図とデータのフローを正確に伝えることができます。

たとえば、2つのドロップダウンを表示するShippingFormコンポーネントを考えてみましょう。1つは都市のリストを表示し、もう1つは選択した都市の地域のリストを表示します。次のようなコードから始めるかもしれません。

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]);

// ...

このコードは非常に反復的ですが、これらのEffectを互いに分離しておくことは正しいです。 これらは2つの異なるものを同期しているので、1つのEffectにマージすべきではありません。代わりに、独自の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コンポーネントの両方のEffectを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内でEffectを「隠す」ことで、ShippingFormコンポーネントに取り組んでいる人が不要な依存関係を追加するのを防ぐこともできます。時間が経つにつれて、アプリのほとんどのEffectはカスタムフックに含まれるようになります。

詳細

カスタムフックは、具体的な高レベルのユースケースに焦点を当ててください。

まず、カスタムフックの名前を選びましょう。明確な名前を選ぶのに苦労する場合は、Effectがコンポーネントの他のロジックと密接に結合しており、まだ抽出する準備ができていないことを意味している可能性があります。

理想的には、カスタムフックの名前は、コードを書かない人でも、そのカスタムフックが何をするのか、何を必要とするのか、そして何を返すのかをある程度推測できるほど明確であるべきです。

  • 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のパラダイムにはうまく適合しません。たとえば、このコード例には間違いがあります(roomIdserverUrlの変更に「反応」しません)。しかし、リンターは直接のuseEffect呼び出しのみをチェックするため、この点について警告しません。リンターはあなたのフックについて知りません。

Effectを作成する場合は、最初に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がユースケースを制約せず、非常に抽象的なものである場合、長期的には解決するよりも多くの問題を引き起こす可能性があります。

カスタムフックは、より良いパターンへの移行に役立ちます

Effectは「エスケープハッチ」です。「Reactの外に出る」必要がある場合、そしてユースケースに最適な組み込みソリューションがない場合に使用します。Reactチームの目標は、より具体的な問題に対するより具体的なソリューションを提供することで、時間の経過とともにアプリ内のEffectの数を最小限に抑えることです。Effectをカスタムフックでラップすると、これらのソリューションが利用可能になったときにコードをアップグレードしやすくなります。

この例に戻りましょう。

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;
}

上記の例では、useOnlineStatususeStateuseEffectのペアを使用して実装されています。しかし、これは最善の解決策ではありません。考慮されていないエッジケースがいくつかあります。たとえば、コンポーネントがマウントされると、isOnlineは既にtrueであると想定していますが、ネットワークが既にオフラインになっている場合は間違っている可能性があります。navigator.onLineAPIを使用してそれを確認できますが、それを直接使用しても、初期HTMLの生成のためにサーバー上では機能しません。つまり、このコードは改善できます。

幸いなことに、React 18にはuseSyncExternalStoreと呼ばれる専用のAPIが含まれており、これによりこれらの問題がすべて解決されます。これが、この新しい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();
// ...
}

これは、Effectをカスタムフックでラップすることが有益である理由のもう1つです。

  1. Effectとのデータの送受信を非常に明示的にします。
  2. コンポーネントは、Effectの正確な実装ではなく、意図に集中できます。
  3. Reactに新しい機能が追加されると、コンポーネントを変更せずにこれらのEffectを削除できます。

デザインシステムと同様にデザインシステム、アプリのコンポーネントから共通のイディオムを抽出してカスタムフックを作成すると役立つ場合があります。これにより、コンポーネントのコードは意図に集中し、生のEffectを頻繁に記述する必要がなくなります。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のようなカスタムフックをアプリで使用する場合、最終的に推奨されるアプローチに移行するための変更は、各コンポーネントに手動で生のEffectを記述する場合よりも少なくなります。ただし、古いアプローチでも問題なく動作するため、生のEffectの記述に問題ない場合は、引き続きその方法を使用できます。

複数の方法があります

ブラウザのrequestAnimationFrame APIを使用して、フェードインアニメーションをゼロから実装したいとしましょう。アニメーションループを設定するEffectから始めるかもしれません。アニメーションの各フレームで、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]);
}

しかし、そうする必要はありませんでした。通常の関数と同様に、最終的にはコードの異なる部分間の境界線をどこに引くかを決定します。まったく異なるアプローチを取ることもできます。Effectにロジックを保持する代わりに、ほとんどの命令型ロジックを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]);
}

Effectを使用すると、Reactを外部システムに接続できます。Effect間の調整が必要なほど(たとえば、複数のアニメーションをチェーンする場合)、EffectとHookからそのロジックを完全に抽出することがより意味を持ちます(上記のサンドボックスのように)。次に、抽出したコードが「外部システム」になります。これにより、Effectは、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で始める必要があります。
  • カスタムフックは、状態自体ではなく、状態のあるロジックのみを共有します。
  • リアクティブな値をフック間で渡すことができ、最新の状態を維持できます。
  • すべてのフックは、コンポーネントが再レンダリングされるたびに再実行されます。
  • カスタムフックのコードは、コンポーネントのコードと同様にピュアである必要があります。
  • カスタムフックで受信したイベントハンドラをEffectイベントでラップします。
  • useMountのようなカスタムフックを作成しないでください。目的を明確に保ってください。
  • コードの境界線をどのように、どこに設定するかは、あなた次第です。

チャレンジ 1 5:
useCounterフックを抽出する

このコンポーネントは、状態変数とEffectを使用して、毎秒増分する数値を表示します。useCounterというカスタムフックにこのロジックを抽出します。目標は、Counterコンポーネントの実装を正確に次のようにすることです。

export default function Counter() {
const count = useCounter();
return <h1>Seconds passed: {count}</h1>;
}

useCounter.jsでカスタムフックを作成し、App.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>;
}