📘

React勉強しなおしてみた #5

2024/04/24に公開

前回記事はこちら

本記事の内容

元JavaエンジニアがReactを再学習する記録。
本記事内では下記セクションを学習する。

  • 避難ハッチ

避難ハッチ

公式学習ページ
#1作成したプロジェクトを引き続き使用して学習していく。

refで値を参照する

コンポーネントに情報を記憶させたいが、その情報が新しいレンダーをトリガしないようにしたい場合、refを使用できる。
stateと同様にrefはReactによって再レンダー間で保持されるが、セットするとコンポーネントが再レンダーされるstateと違い、refは変更してもコンポーネントは再レンダーされない。
下記は公式ページに載っている例。再レンダーされないことを確認するため、buttonのテキストに{ref.current}を追加してある。この場合、クリックした後表示されるアラートの数字は+1されるが、ボタンのテキストは再レンダーされないため変わらない。

src\components\counter\Counter.tsx
export function CounterUseRef() {
  let ref = useRef(0);

  function handleClick() {
    ref.current = ref.current + 1;
    alert(`You clicked ${ref.current} times!`);
  }

  return <button onClick={handleClick}>Click me! {ref.current}</button>;
}

refでDOMを操作する

Reactはレンダー結果に合致するよう自動的にDOMを更新するため、コンポーネントでDOMを操作する必要は通常ない。ただし、ノードにフォーカスを当てたり、スクロールさせたり、サイズや位置を測定するなどのためにReactが管理するDOM要素へのアクセスが必要なことがある。Reactにはこれらを行なう組み込むの方法が存在しないため、refが必要となる。
以下の例はボタンをクリックするとrefを使用して入力欄にフォーカスが当たる。

src\components\form\MyForm.tsx
export function FocusForm() {
  const inputRef = useRef<HTMLInputElement>(null);

  function handleClick() {
    inputRef.current?.focus();
  }

  return (
    <div>
      <input ref={inputRef} />
      <button onClick={handleClick}>Focus the input</button>
    </div>
  );
}

エフェクトを使って同期を行う

Reactのstateの基づいて非React製のコンポーネントを制御する、サーバとの接続を確立する、コンポーネントが画面に表示されたとき分析用ログを送信する、など一部のコンポーネントでは外部システムと同期する必要がある。その場合、Effectを使うことでレンダー後にコードを実行することができる。
Effectとは特定のイベントによってではなく、レンダー自体によって引き起こされる副作用を指定するものである。チャットアプリを例に考えるのであれば、メッセージ送信はユーザが特定のボタンをクリックすることによって直接引き起こされるためイベントである。しかしサーバー接続のセットアップはコンポーネントが表示される原因となるインタラクションに関係なく行われるべきであるためエフェクトになる。
コンポーネントがレンダーされる度Reactは画面を更新し、その後でuseEffect内のコードを実行する。
useEffect(()=>{ /* 実行したいコード */ }, [/* 依存値のリスト */])という風に記述する。

以下はボタンでビデオの再生/停止を制御する例。まずReactが画面を更新し、isPlayingが変更された場合、useEffect内のコード(再生/停止)が実行される。

src\components\videoPlayer\VIdeoPlayer.tsx
import { useState, useRef, useEffect } from "react";

type VideoProps = {
  src: string;
  isPlaying: boolean;
};

function Video({ src, isPlaying }: VideoProps) {
  const ref = useRef<HTMLVideoElement>(null);

  useEffect(() => {
    if (isPlaying) {
      ref.current?.play();
    } else {
      ref.current?.pause();
    }
  }, [isPlaying]);

  return <video ref={ref} src={src} loop playsInline />;
}

export default function VideoPlayer() {
  const [isPlaying, setIsPlaying] = useState(false);
  return (
    <>
      <button onClick={() => setIsPlaying(!isPlaying)}>{isPlaying ? "Pause" : "Play"}</button>
      <Video
        isPlaying={isPlaying}
        src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
      />
    </>
  );
}

エフェクトは必要ないかもしれない

エフェクトはReactの外に踏み出して何らかの外部システムと同期させることができるものであり、外部システムが関与していない場合(propsやstateの変更に合わせてstateを更新したい場合)エフェクトは必要ない。

エフェクトが不要な場合の一般的なユースケース

  • レンダーのためのデータ変換
  • ユーザイベントの処理

以下の例ではfullNamestateを更新するためにuseEffectを使用している。これは冗長かつ、「外部システムが関与していない」エフェクトであるため不適切である。

// Bad
function Form() {
  const [firstName, setFirstName] = useState('Taylor');
  const [lastName, setLastName] = useState('Swift');

  const [fullName, setFullName] = useState('');
  useEffect(() => {
    setFullName(firstName + ' ' + lastName);
  }, [firstName, lastName]);
  // ...
}

// Good
function Form() {
  const [firstName, setFirstName] = useState('Taylor');
  const [lastName, setLastName] = useState('Swift');
  const fullName = firstName + ' ' + lastName;
  // ...
}

リアクティブなエフェクトのライフサイクル

エフェクトはコンポーネントとはことなるライフサイクルを持つ。コンポーネントはマウント、更新、アンマウントを行うことができるが、エフェクトは同期の開始と停止の二つしかできない。エフェクトがprops, stateに依存しこれらが変化する場合、このサイクルは繰り返し発生する。
以下の例ではエフェクトはpropsであるroomIdに依存していて、roomIdが変更されるとエフェクトが再同期(サーバに接続)する。

import { useState, useEffect } from 'react';
import { createConnection } from './chat.js';

const serverUrl = 'https://localhost:1234';

function ChatRoom({ roomId }) {
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]);

  return <h1>Welcome to the {roomId} room!</h1>;
}

export default function App() {
  const [roomId, setRoomId] = useState('general');
  return (
    <>
      <label>
        Choose the chat room:{' '}
        <select
          value={roomId}
          onChange={e => setRoomId(e.target.value)}
        >
          <option value="general">general</option>
          <option value="travel">travel</option>
          <option value="music">music</option>
        </select>
      </label>
      <hr />
      <ChatRoom roomId={roomId} />
    </>
  );
}

エフェクトから依存値を取り除く

エフェクトを記述する際、リンタはエフェクトが読み取るすべてのリアクティブな値(prosやstate)がエフェクトの依存値のリストに含まれているか確認する。これによりエフェクトがコンポーネントの最新のpropsやstateと同期された状態を保つことができる。不要な依存値があるとエフェクトが頻繁に実行されすぎたり、無限ループが発生する。
以下の例では入力フィールドを編集される度に再作成するoptionsに依存しているため、メッセージを入力するたびにサーバへ再接続されてしまう。

import { useState, useEffect } from 'react';
import { createConnection } from './chat.js';

const serverUrl = 'https://localhost:1234';

function ChatRoom({ roomId }) {
  const [message, setMessage] = useState('');

  const options = {
    serverUrl: serverUrl,
    roomId: roomId
  };

  useEffect(() => {
    const connection = createConnection(options);
    connection.connect();
    return () => connection.disconnect();
  }, [options]);

  return (
    <>
      <h1>Welcome to the {roomId} room!</h1>
      <input value={message} onChange={e => setMessage(e.target.value)} />
    </>
  );
}

export default function App() {
  const [roomId, setRoomId] = useState('general');
  return (
    <>
      <label>
        Choose the chat room:{' '}
        <select
          value={roomId}
          onChange={e => setRoomId(e.target.value)}
        >
          <option value="general">general</option>
          <option value="travel">travel</option>
          <option value="music">music</option>
        </select>
      </label>
      <hr />
      <ChatRoom roomId={roomId} />
    </>
  );
}

これを解決するため、optionsオブジェクトの作成はエフェクト内に移動し、エフェクトはroomIdにのみ依存するよう修正する。

import { useState, useEffect } from 'react';
import { createConnection } from './chat.js';

const serverUrl = 'https://localhost:1234';

function ChatRoom({ roomId }) {
  const [message, setMessage] = useState('');

  useEffect(() => {
    const options = {
      serverUrl: serverUrl,
      roomId: roomId
    };
    const connection = createConnection(options);
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]); // roomIdのみに依存

  return (
    <>
      <h1>Welcome to the {roomId} room!</h1>
      <input value={message} onChange={e => setMessage(e.target.value)} />
    </>
  );
}

export default function App() {
  const [roomId, setRoomId] = useState('general');
  return (
    <>
      <label>
        Choose the chat room:{' '}
        <select
          value={roomId}
          onChange={e => setRoomId(e.target.value)}
        >
          <option value="general">general</option>
          <option value="travel">travel</option>
          <option value="music">music</option>
        </select>
      </label>
      <hr />
      <ChatRoom roomId={roomId} />
    </>
  );
}

カスタムフックでロジックを再利用する

ReactにはuseState, useContext, useEffectなど複数の組み込みフックが存在する。しかし、データの取得やユーザのオンライン状態の監視、チャットルームへの接続など、より特化した目的のためのフックが欲しいこともある。これらを行なうため、アプリケーションの要求に応じた独自のフックを作成することができる。
以下の例はネットワークがオンラインかオフラインかによって表示が切り替わるコンポーネント。ネットワークがオンラインか否かを保持するstateと、グローバルのonline, offlineイベントにリスナを登録しisOnlinestateを更新するエフェクトが定義されている。

src\components\network\NetworkStatus.tsx
import { useState, useEffect } from "react";

export default function NetworkStatus() {
  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>;
}

このロジックと同じロジックを他のコンポーネントでも使用したいとする。例としてネットワークがオフの間は「Save」の代わりに「Reconnecting…」と表示され無効になるようなボタンを実装する。

src\components\network\SaveButton.tsx
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>
  );
}

これでも動きはするが重複したロジックは再利用したい。

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

useStateuseEffectなんかと同様にフックがあれば重複したコードを削除し、コンポーネントを簡略化できそうである。
useOnlineStatusという関数を宣言し、重複したコードを移動させる。そして関数の最後でisOnlineを返すようにすればコンポーネント側でその値を読み込みことができるようになる。

src\components\network\hooks.ts
import { useEffect, useState } from "react";

export function useOnlineStates() {
  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;
}
src\components\network\NetworkStatus.tsx
import { useOnlineStates } from "./hooks";

export default function NetworkStatus() {
  const isOnline = useOnlineStates();

  return <h1>{isOnline ? "✅ Online" : "❌ Disconnected"}</h1>;
}
src\components\network\SaveButton.tsx
import { useOnlineStates } from "./hooks";

export default function SaveButton() {
  const isOnline = useOnlineStates();

  function handleSaveClick() {
    console.log("✅ Progress saved");
  }

  return (
    <button disabled={!isOnline} onClick={handleSaveClick}>
      {isOnline ? "Save progress" : "Reconnecting..."}
    </button>
  );
}

ロジックをカスタムフックに抽出することで、外部システムやブラウザAPIとのやり取りを隠ぺいすることができる。

フックの名前は常にuseで始める

Reactアプリケーションはコンポーネントから構築される。コンポーネントは組み込みのものやカスタムのものなどフックから構築される。
カスタムフックの命名はuseStateuseOnlineStatusのように、useで初めて大文字を続ける必要がある。

カスタムフックは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(() => {
    // ...
  }, []);
  // ...
}

Formを例に見てみる。

src\components\form\MyForm.tsx
export default function MyForm() {
  const [firstName, setFirstName] = useState("");
  const [lastName, setLastName] = useState("");

  function handleFirstNameChange(value: string) {
    setFirstName(value);
  }

  function handleLastNameChange(value: string) {
    setLastName(value);
  }

  return (
    <ul style={{ listStyle: "none" }}>
      <li>
        <label>
          First name:
          <input value={firstName} onChange={(e) => handleFirstNameChange(e.target.value)} />
        </label>
      </li>
      <li>
        <label>
          Last name:
          <input value={lastName} onChange={(e) => handleLastNameChange(e.target.value)} />
        </label>
      </li>
      <li>
        <p>Full name: {`${firstName} ${lastName}`}</p>
      </li>
    </ul>
  );
}

各フィールドに対して繰り返しのロジックがある。

  1. state変数(firstNamelastName
  2. changeハンドラ(handleFirstNameChangehandleLastNameChange
  3. 対応する入力フィールドに valueonChange 属性を指定するためのJSX
    この繰り返しのロジックをuseFormInputというカスタムフックに抽出する。
src\components\form\hooks.ts
import { useState } from "react";

export function useFormInput(initialValue: string) {
  const [value, setValue] = useState(initialValue);

  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
    setValue(e.target.value);
  }

  const inputProps = {
    value: value,
    onChange: handleChange,
  };

  return inputProps;
}
src\components\form\MyForm.tsx
export default function MyForm() {
  const firstNameProps = useFormInput("Mary");
  const lastNameProps = useFormInput("Poppins");

  return (
    <ul style={{ listStyle: "none" }}>
      <li>
        <label>
          First name:
          <input {...firstNameProps} />
        </label>
      </li>
      <li>
        <label>
          Last name:
          <input {...lastNameProps} />
        </label>
      </li>
      <li>
        <p>Full name: {`${firstNameProps.value} ${lastNameProps.value}`}</p>
      </li>
    </ul>
  );
}

コード内で宣言されているのはvalueというstate一つだけだが、MyFormではuseFormInputを2回呼び出している。
カスタムフックはstate自体ではなくstateを扱うロジックを共有できるようにするためのもの。フックの呼び出しは同じフックの別の場所からの呼び出しとは独立している。これが2つの別々のstateを宣言するのと同じように動作する理由である。

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

カスタムフック内のコードは、コンポーネントの再レンダーごとに実行されるため、カスタムフックはコンポーネント同様に純関数である必要がある。
カスタムフックはコンポーネントと一緒に再レンダーされるため、常に最新のstate, propsを受け取る。

カスタムフックを使うタイミング

あらゆるコードの重複に対してカスタムフックを抽出する必要はない。先ほど作成したuseFormInputのような一回のuseState呼び出しをラップするだけのフックの抽出は不要。
ただし、エフェクトを書くときは常に、そのエフェクトをカスタムフックにラップすることでより分かりやすくならないか検討する。エフェクトは頻繁に必要になるものではなく、外部システムと同期するため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]);

  // ...

このコードはかなりの繰り返しがあるが、これらのエフェクトは二つの異なるものを同期しているため1つのエフェクトに統合すべきではない。代わりにこれらの共通のロジックをuseDataフックとして抽出する。

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;
}
function ShippingForm({ country }) {
  const cities = useData(`/api/cities?country=${country}`);
  const [city, setCity] = useState(null);
  const areas = useData(city ? `/api/areas?city=${city}` : null);
  // ...

ShippingFormコンポーネントの両方のエフェクトをuseDataの呼び出しに置き換えることでコンポーネントを簡略化できる。またカスタムフックに抽出することで、データの流れが明示的になる。useDataの中にエフェクトを隠すことによって ShippingForm コンポーネントで作業中の誰かが不要な依存値を追加してしまうことを防げる。

カスタムフックはより良いパターンへの移行を支援する

エフェクトはReactの外に踏み出す必要があり、当該ユースケースに対してより良い組み込みのソリューションがない場合に使用するものである。Reactチームは「より具体的な問題に対してより具体的なソリューションを提供することで、アプリ内のエフェクトの数を最小限に減らす」ことを目標としており、将来ソリューションが利用可能になったとき、エフェクトをカスタムフックにラップしておくことでコードのアップグレードが容易になる。

Discussion