4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

React を使い始めてそこそこ経つのですが、アンチパターンのコードを時々書いてしまうので、公式チュートリアルを見返して気をつけたいことをメモ代わりに残していこうと思います。

コンポーネントの定義はネストさせない

コンポーネントがほかのコンポーネントをレンダーすることはできますが、コンポーネントの定義をネストさせてはいけません。
とても遅く、バグの原因になります。代わりに、すべてのコンポーネントをトップレベルで定義するようにしてください。

React 公式ドキュメント「初めてのコンポーネント - コンポーネントのネストと整理方法

ダメな例
export const Profile = () => {
  // コンポーネントの定義内で別のコンポーネントの定義をしている
  const Avatar = ({ name, size }) => {
    return (
      <img
        className="avatar"
        src={getUrl(name)}
        alt={name}
        width={size}
        height={size}
      />
    );
  };

  return (
    <div>
      <Avatar size={100} name="Katsuko Saruhashi" />
      <Avatar size={80} name="Aklilu Lemma" />
    </div>
  );
};
正しい例
// コンポーネントはトップレベルで定義
const Avatar = ({ name, size }) => {
  return (
    <img
      className="avatar"
      src={getUrl(name)}
      alt={name}
      width={size}
      height={size}
    />
  );
};

export const Profile = () => {
  return (
    <div>
      <Avatar size={100} name="Katsuko Saruhashi" />
      <Avatar size={80} name="Aklilu Lemma" />
    </div>
  );
};

keyにインデックスを使わない

アイテムのインデックスをkeyとして使用したくなるかもしれません。実際keyを指定しなかった場合、React はデフォルトでインデックスを使用します。しかし、アイテムが挿入されたり削除されたり、配列を並び替えたりすると、レンダーするアイテムの順序も変わります。インデックスをキーとして利用すると、微妙かつややこしいバグの原因となります。

React 公式ドキュメント「リストのレンダー - なぜ React はkeyを必要とするのか

ダメな例
// ...
const myArray = ["foo", "bar", "baz"];

return (
  <ul>
    {myArray.map((item, index) => (
      <li key={index}>{item}</li> // keyにインデックスを使っている
    ))}
  </ul>
);
正しい例
// ...
const myArray = ["foo", "bar", "baz"];

return (
  <ul>
    {myArray.map((item, index) => (
      <li key={item}>{item}</li> // keyには識別できる値を使う
    ))}
  </ul>
);

冗長な state を避ける

他の state 変数から計算できるものは state 変数にせず、レンダリング中に計算しましょう

このフォームには 3 つの state 変数があります。firstNamelastName、そして fullName です。しかし、fullName は冗長です。レンダー中に fullName は常に firstNamelastName から計算できるので、state から削除しましょう。

React 公式ドキュメント「state 構造の選択 - 冗長な state を避ける

ダメな例1(state変数が冗長)
import { useState } from 'react';

const Form = () => {
  const [firstName, setFirstName] = useState("");
  const [lastName, setLastName] = useState("");
  // fullNameはfirstNameとlastNameから計算可能
  const [fullName, setFullName] = useState("");

  const handleFirstNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setFirstName(e.target.value);
    setFullName(e.target.value + " " + lastName);
  };

  const handleLastNameChange = (e) => {
    setLastName(e.target.value);
    setFullName(firstName + " " + e.target.value);
  };

  return (
    <>
      <h2>Form</h2>
      <label>
        First name: <input value={firstName} onChange={handleFirstNameChange} />
      </label>
      <label>
        Last name: <input value={lastName} onChange={handleLastNameChange} />
      </label>
      <p>
        Your full name: <b>{fullName}</b>
      </p>
    </>
  );
};

他の state 変数が変化したときにuseEffectで再計算させるのも良くないそうです。

例えば、firstName と lastName の 2 つの state 変数を持つコンポーネントがあるとします。これらを連結して fullName を計算したいとします。となると、firstName または lastName が変更されたときに fullName を更新したくなるでしょう。直観的には、fullName という state 変数を追加して、エフェクトでそれを更新すればいいと思うかもしれません。
これは必要以上に複雑です。また、非効率的でもあります。古くなった fullName の値でレンダー処理を最後まで行った直後に、更新された値で再レンダーをやり直すことになります。
既存の props や state から計算できるものは、state に入れないでください。代わりに、レンダー中に計算します。

React 公式ドキュメント「そのエフェクトは不要かも - props または state に基づいて state を更新する

ダメな例2(state変数が冗長、useEffectで再計算している)
import { useState, useEffect } from 'react';

const Form = () => {
  const [firstName, setFirstName] = useState("");
  const [lastName, setLastName] = useState("");
  // fullNameはfirstNameとlastNameから計算可能
  const [fullName, setFullName] = useState("");
  // 古くなった fullName の値でレンダー処理を最後まで行った直後に、
  // 更新された値で再レンダリングすることになる
  useEffect(() => {
    setFullName(`${firstName} ${lastName}`);
  }, [firstName, lastName]);

  const handleFirstNameChange = (e) => {
    setFirstName(e.target.value);
  };

  const handleLastNameChange = (e) => {
    setLastName(e.target.value);
  };

  return (
    <>
      <h2>Form</h2>
      <label>
        First name: <input value={firstName} onChange={handleFirstNameChange} />
      </label>
      <label>
        Last name: <input value={lastName} onChange={handleLastNameChange} />
      </label>
      <p>
        Your full name: <b>{fullName}</b>
      </p>
    </>
  );
};
正しい例
import { useState } from 'react';

const Form = () => {
  const [firstName, setFirstName] = useState("");
  const [lastName, setLastName] = useState("");

  // レンダリング中に計算
  const fullName = `${firstName} ${lastName}`;

  const handleFirstNameChange = (e) => {
    setFirstName(e.target.value);
  };

  const handleLastNameChange = (e) => {
    setLastName(e.target.value);
  };

  return (
    <>
      <h2>Form</h2>
      <label>
        First name: <input value={firstName} onChange={handleFirstNameChange} />
      </label>
      <label>
        Last name: <input value={lastName} onChange={handleLastNameChange} />
      </label>
      <p>
        Your full name: <b>{fullName}</b>
      </p>
    </>
  );
};

(計算のコストが高い場合はuseMemoを使うのが良いと思います。)

fullNameをメモ化する場合
const fullName = useMemo(() => `${firstName} ${lastName}`, [firstName, lastName]);

state 変数はマウント時にのみ初期化される(props が変化して再レンダリングされても state 変数はそのまま)

React 公式ドキュメント「state 構造の選択 - props を state にコピーしない

ここでは、color という state 変数が、 props である messageColor の値で初期化されています。問題は、親コンポーネントが後で異なる messageColor 値(例えば 'blue' から 'red')を渡してきた場合、state 変数である color の方は更新されないということです! state は最初のレンダー時(マウント時)にのみ初期化されます。

例1
function Message({ messageColor }) {
  const [color, setColor] = useState(messageColor);
  // ...

つまり、state 変数はマウント時にのみ初期化されます!

例2 Counter定義
const Counter = ({ id, initialCount }) => {
  const [count, setCount] = useState(initialCount);

  return (
    <>
      <p>Selected ID: <b>{id}</b></p>
      <p>Current Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
    </>
  );
};

上記の例 2 の場合、props のinitialCountの値が更新されてCounterコンポーネントが再レンダリングされても、countの値は保持され続ける(initialCountの値に初期化されない)のがポイントです。
なかなかアンマウントされないコンポーネント内で、useState の初期値にprops を使う場合は注意が必要です

例えば、以下のようなケースが該当します。

  • まず、初期状態はid = 1のデータが選択されていて、カウンターには0と表示されているとします
  • この状態でカウンターの「+1」ボタンを押すと、カウンターには1と表示されます
  • 次にid = 2データを選択した場合、カウンターにはid = 2の初期値である5が表示されるのではなく、1が表示されたままになります
例2 Counter呼び出し
const MyApp = () => {
  const data = [
    { id: 1, count: 0 },
    { id: 2, count: 5 },
    { id: 3, count: 10 }
  ];
  const [selectedDataId, setSelectedDataId] = useState(1);
  const selectedData = data.find((item) => item.id === selectedDataId);
  return (
    <>
      {/* データ一覧表示 */}
      <ul>
        {data.map((item) => (
          <li
            onClick={() => setSelectedDataId(item.id)}
            key={item.id}
          >{`id: ${item.id}, count: ${item.count}`}</li>
        ))}
      </ul>
      {/* 選択したデータのカウンター表示 */}
      <Counter id={selectedData?.id} initialCount={selectedData?.count} />
    </>
  );
};

See the Pen qiita_0328dcc1fabe1302f6e9_1 by che-ryu (@hfsadoll-the-vuer) on CodePen.

このようにstate 変数の初期値に props を使用すると意図しない挙動になる可能性があるため、注意が必要です。

では逆に、initialCountの値が更新されてCounterコンポーネントが再レンダリングされたとき、
countの値をinitialCountの値に再度初期化したいときの方法を紹介します。

方法 1. コンポーネント呼び出し時に key を設定する

Counter呼び出し
{/* 選択したデータのカウンター表示 */}
<Counter
  id={selectedData?.id}
  initialCount={selectedData?.count}
+ key={selectedData?.id}
/>

See the Pen qiita_0328dcc1fabe1302f6e9_2 by che-ryu (@hfsadoll-the-vuer) on CodePen.

keyの値が変わるとコンポーネントが新しくマウントされます。
つまり、選択されているデータが変わりkeyの値が変わるとコンポーネントが新しくマウントされるため、countの値がinitialCountの値に初期化されます。

公式ドキュメントの言葉と同じように解説すると、

「id を Counter コンポーネントの key として渡すことで、異なる id を持つ 2 つの Counter コンポーネントを、state を共有すべきでない 2 つの異なるコンポーネントとして React に扱わせることができます。key が変更されるたびに、React は DOM を再作成し、Counter コンポーネントとそのすべての子コンポーネントの state をリセットします。」

となります。

React 公式ドキュメント「そのエフェクトは不要かも - props が変更されたときにすべての state をリセットする

方法 2. useEffect で初期値変更時に値を上書きする(できれば避けたい)

Counter定義
const Counter = ({ id, initialCount }) => {
  const [count, setCount] = useState(initialCount);
+  useEffect(() => {
+    setCount(initialCount);
+  }, [initialCount]);

  return (
    <>
      <p>
        Selected ID: <b>{id}</b>
      </p>
      <p>Current Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
    </>
  );
};

See the Pen qiita_0328dcc1fabe1302f6e9_3 by che-ryu (@hfsadoll-the-vuer) on CodePen.

一応上記の方法でも、props 変更時に state 変数を初期化することができます。
ただし公式ドキュメントにもあるように、この方法を採用すると「まず古くなった値でレンダーされ、その後再度レンダーされる」ため、非効率的になります。
なので基本的には方法 1を検討するのが良いと思います。

useEffectの依存配列の指定の違いについて

依存配列がない場合と、空の [] という依存配列がある場合の挙動は異なります。

React 公式ドキュメント「エフェクトを使って同期を行う - エフェクトの依存配列を指定する

useEffect(() => {
  // 毎回のレンダリング後に実行される
});

useEffect(() => {
  // マウント時(コンポーネント出現時)のみ実行される
}, []);

useEffect(() => {
  // マウント時と、a か b の値が前回のレンダリング時から変わった場合に実行される
}, [a, b]);

useEffectのクリーンアップ関数を意識する

React 公式ドキュメント「エフェクトを使って同期を行う - データのフェッチ

以下の例1のuseEffectは、マウント時およびuserIdが変わったときにtodosを取得してstate変数にセットしています。

このコードのポイントは

  • ignorefalseの場合のみtodosを更新しています
  • todosが更新される前にuserIdが変更されると、クリーンアップ関数が実行され、ignoretrueになります(つまりtodosが更新されない)
    • 例えばuserId1が指定されuserID = 1todosを取得中のとき、userId2に変わるとuserID = 1todosは不要になるため
    • またuserID = 2todos取得完了後にuserID = 1todos取得が完了した場合でも、userID = 1todosに上書きされないようにするため

です。

例1
// ...
useEffect(() => {
  let ignore = false;

  const startFetching = async() => {
    const json = await fetchTodos(userId);
    if (!ignore) {
      setTodos(json);
    }
  }

  startFetching();

  return () => {
    ignore = true;
  };
}, [userId]);
// ...

このフェッチは、イベントハンドラに移動する必要はありません。
イベントハンドラにロジックを入れる必要があったここまでの例と矛盾しているように思えるかもしれません。しかし、タイピングというイベントがデータのフェッチを行う理由だというわけではないことに留意しましょう。検索フィールドは URL から事前入力されることがよくありますし、ユーザは入力フィールドに触れずに戻る・進むといったナビゲーションを行うこともあります。
この pagequery がどこから来たかのかは問題ではありません。このコンポーネントが表示されている間は results を、現在の pagequery に対応するネットワークからのデータに同期させる必要があるのです。だからこれはエフェクトであるべきだということです。

競合状態を修正するには、クリーンアップ関数を追加して古いレスポンスを無視する必要があります。
これにより、エフェクトがデータを取得する際に、最後にリクエストしたもの以外のすべてのレスポンスが無視されます。

React 公式ドキュメント「そのエフェクトは不要かも - データのフェッチ

以下の例2のuseEffectは、マウント時と、queryおよびpageが変わったときにresultsを取得してstate変数にセットしています。

例2
const SearchResults = ({ query }) => {
  const [results, setResults] = useState([]);
  const [page, setPage] = useState(1);

  useEffect(() => {
    let ignore = false;
    fetchResults(query, page).then((json) => {
      if (!ignore) {
        setResults(json);
      }
    });
    return () => {
      ignore = true;
    };
  }, [query, page]);

  const handleNextPageClick = () => {
    setPage(page + 1);
  };
  // ...
};

例えば、検索条件を入力して送信した直後、すぐに検索条件を変更して再度送信するという操作はあまり想定されないとは思いますが、バグを未然に防ぐためにもデータフェッチ時にはクリーンアップ関数を書くようにしたいですね。

useEffect は分割させる

React 公式ドキュメント「エフェクトから依存値を取り除く - エフェクトが複数の互いに無関係なことを行っていないか?

エフェクトを分割することは正当です。各エフェクトは独立した同期プロセスを表すべきです。

例えば以下のように、都市と地域を選択できるフォームのコンポーネントがあるとします。大まかな処理の流れは次の通りです。

  • ユーザーがcountryを選択する
  • useEffectの依存配列にcountryが含まれているためエフェクトが実行され、選択可能なcityのリストを取得する
  • ユーザーがcityを選択する
    • useEffectの依存配列にcityが含まれているためエフェクトが実行され、選択可能なareaのリストを取得する
エフェクト分割前
const ShippingForm = ({ country }) => {
  const [cities, setCities] = useState(undefined);
  const [city, setCity] = useState(undefined);
  const [areas, setAreas] = useState(undefined);

  useEffect(() => {
    let ignore = false;
    // citiesの取得
    const fetchedCities = fetchCities(country);
    if (!ignore) {
      setCities(fetchedCities);
    }

    if (city) {
      // areasの取得
      const fetchedAreas = fetchAreas(city);
      if (!ignore) {
        setAreas(fetchedAreas);
      }
    }

    return () => {
      ignore = true;
    };
  }, [country, city]);

  // ...

See the Pen react_0328dcc1fabe1302f6e9_4 by che-ryu (@hfsadoll-the-vuer) on CodePen.

ここで問題なのがユーザーがcityを選択した後エフェクトが実行され、選択可能なareaのリストを取得するの部分です。

上記のエフェクトはcityと(cityが存在すれば)areaの両方のリストを取得するようになっているため、正しくは
ユーザーがcityを選択した後エフェクトが実行され、選択可能なcityとareaの両方のリストを取得するという実装になっており、
cityを選択した後に再びcityのリストを取得する」という不要な処理が発生しています。

(また、エフェクト内のコードの可読性も低下してしまいます1。)

エフェクト分割後
const ShippingForm({ country }) => {
  const [cities, setCities] = useState(undefined);
  const [city, setCity] = useState(undefined);
  const [areas, setAreas] = useState(undefined);

  // citiesの取得
  useEffect(() => {
    let ignore = false;
    const fetchedCities = fetchCities(country);
    if (!ignore) {
      setCities(fetchedCities);
    }

    return () => {
      ignore = true;
    };
  }, [country]);

  // areasの取得
  useEffect(() => {
    if (city) {
      let ignore = false;
      const fetchedAreas = fetchAreas(city);
      if (!ignore) {
        setAreas(fetchedAreas);
      }
      return () => {
        ignore = true;
      };
    }
  }, [city]);

  // ...

See the Pen react_0328dcc1fabe1302f6e9_4 by che-ryu (@hfsadoll-the-vuer) on CodePen.

上記のようにエフェクトを分割することで、「cityを選択した後にcityのリストを取得する」という不要な処理を取り除くことができました。
また、各エフェクトの依存配列もよりシンプルになりました。

useEffect 内で state の更新をする場合は「更新用関数」を渡すことを検討する

React 公式ドキュメント「エフェクトから依存値を取り除く - state の読み取りは次の state を計算するためか?

以下のようなフォームのコンポーネントがあるとします。
フォームの初期値を設定したのち、userIdにあわせてフォームのnicknameの値を更新しています。

また、エフェクトの依存配列にはエフェクト内で使用している変数を全て含めるのが理想であるため、formValueも依存配列に入れています。

ダメな例
const initialFormValue = {
  nickname: "",
  memo: "",
  date: new Date().toISOString().split("T")[0]
};

const Form = ({ userId }) => {
  const [formValue, setFormValue] = useState(initialFormValue);

  useEffect(() => {
    const userName = getUserName(userId);
    // setFormValue(新しい値) の書き方
    setFormValue({
      ...formValue,
      nickname: userName
    });
  }, [userId, formValue]);
  // ...

しかしよく見ると、上記のコードは無限レンダリングを引き起こします。
(マウントおよびuserIdの変更→エフェクトの実行→setFormValueformValueの更新→エフェクトの実行→...)

これを解消するために、エフェクトの依存配列からformValueを取り除いてあげたいところです。
そのためにはエフェクト内でformValueを使用しないように修正する必要があります。

良い例
const initialFormValue = {
  nickname: "",
  memo: "",
  date: new Date().toISOString().split("T")[0]
};

const Form = ({ userId }) => {
  const [formValue, setFormValue] = useState(initialFormValue);

  useEffect(() => {
    const userName = getUserName(userId);
    // setFormValue(更新用関数) の書き方
    setFormValue((prev) => ({
      ...prev,
      nickname: userName
    }));
  }, [userId]);
  // ...

setFormValueの引数でformValueを使用する代わりに、
setFormValueの引数に更新用関数を渡しました。

こうすることで、formValueをエフェクト内で使用する必要が無くなり、依存配列からも消すことができました。

<input>valuenullundefinedを渡さない

inputvalueを空にしたい場合は、空文字を渡すべきだそうです。

制御されたコンポーネントに渡す value は、undefined や null であってはなりません。初期値を空にしたい場合(例えば、以下の firstName フィールドのような場合)、state 変数を空の文字列 ('') で初期化してください。

コンポーネントに value を渡す場合、そのライフサイクル全体を通じて文字列型でなければなりません。
最初に value={undefined} を渡しておき、後で value="some string" を渡すようなことはできません。なぜなら、React はあなたがコンポーネントを非制御コンポーネントと制御されたコンポーネントのどちらにしたいのか分からなくなるからです。制御されたコンポーネントは常に文字列の value を受け取るべきであり、null や undefined であってはいけません。
あなたの value が API や state 変数から来ている場合、それが null や undefined に初期化されているかもしれません。その場合、まず空の文字列('')にセットするか、value が文字列であることを保証するために value={someValue ?? ''} を渡すようにしてください。
同様に、チェックボックスに checked を渡す場合は、常にブーリアン型であることを確認してください。

React 公式ドキュメント「input - state 変数を使用して入力要素を制御する

React 公式ドキュメント「input - A component is changing an uncontrolled input to be controlled というエラーが発生する

ダメな例
export const MyInput = () => {
  const [value, setValue] = useState<undefined | string>(undefined);

  return (
    <div>
      <input
        value={value} // valueにundefinedが渡される
        onChange={(e) => setValue(e.target.value)}
      />
    </div>
  );
};
正しい例
export const MyInput = () => {
  const [value, setValue] = useState<undefined | string>(undefined);

  return (
    <div>
      <input
        value={value ?? ""} // valueにはstringのみ渡されるようにする
        onChange={(e) => setValue(e.target.value)}
      />
    </div>
  );
};

参考文献

  1. コードの可読性が低下するだけでなく、エフェクト内の状態管理も難しくなってしまいます。
    CodePenのデモでは、エフェクト実行時に選択中のcityのリセットをしていないため、例えば「東京」を選択したあとに「アメリカ」を選択してもareaの選択肢がそのままになっています。
    エフェクト実行時に選択中のcityのリセットを行うように修正すると、今度はcityを変更したときにもエフェクトが実行され、cityがリセットされてしまいます(つまりcityが変更できない)。

4
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?