9
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

アイレット株式会社Advent Calendar 2024

Day 22

React 初心者がハマりがちな罠とその回避法 7 選

Last updated at Posted at 2024-12-21

はじめに

React で開発を進める上で、ハマりがちな罠や非効率な書き方、そしてパフォーマンス低下に繋がるようなアンチパターンは数多く存在します。
本記事では、React を始めた初心者の方がハマりがちな罠とその回避法を Bad/Good 例とともに解説します。
目次を載せておきますので、気になるところがあればぜひジャンプして見てみてください🖐️

その1:state はすぐに更新されるわけではない

Bad

ボタンを押下すると +1 されるシンプルなカウンタアプリです。

const Counter = () => {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    setCount(count + 1);
  };

  return (
    <>
      <button className="bg-blue-300 p-3 rounded-md" onClick={handleClick}>
        Count Up
      </button>
      <p>{count}</p>
    </>
  );
};

ハンドラの中で複数回状態を更新したいケースがあるとします。
count を更新する処理を 2 回行いました。

const Counter = () => {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    setCount(count + 1);
    setCount(count + 1);
  };

  return (
    <>
      <button className="bg-blue-300 p-3 rounded-md" onClick={handleClick}>
        Count Up
      </button>
      <p>{count}</p>
    </>
  );
};

しかし、ボタンを押下したら +2 されることを期待しますが、実際は +1 しかされません。

counter.gif

これは、state はすぐに更新されるわけではないからです。

state をセットしても、それが実際に更新されるのは次回のレンダーです。
ボタンを初めて押下する時、state の初期値は 0 なのでハンドラにおいて、setCount(count + 1) が呼ばれた後もその時点ではまだ count の値は 0 のままです。

const handleClick = () => {
    setCount(0 + 1);
    setCount(0 + 1);
};

Good

こういう時は、更新用関数 (updater function) を使用すれば改善できます。更新用関数は、n => n + 1 の形式で記載します。

先ほどのハンドラを下記のように修正します。

const handleClick = () => {
    setCount((prev) => prev + 1);
    setCount((prev) => prev + 1);
};

これで、ボタンを押下したら +2 されるようになりました。

counter_good.gif

その2:&& の左辺に数値を置く

Bad

下記のようなコンポーネントがあるとします。

const Message: React.FC<{ id: number }> = ({ id }) => {
  return id && <p>{id} New messages</p>;
};

呼び出し側で、id に 0 を渡すとどうなるでしょう。

<Message id={0} />

0 は falty な値なので何も表示されないことを期待したかもしれませんが、実際には 0 が表示されます。

message_bad.png

これは、React 的に 0 が有効な子要素になる ためです。

JavaScriptでは、&& 演算子を評価する際に、左辺が falsy であれば、右辺を評価せずにそのまま左辺の値を返します

Reactでは、returnされた値が null や undefined や false の場合、何もレンダリングしませんが、0 の場合、数値なので React はレンダリング可能な子要素と認識し、DOMにレンダリングします。

Good

0 の場合、何も表示させたくない場合は、下記のように条件を修正します。

const Message: React.FC<{ id: number }> = ({ id }) => {
  return id > 0 && <p>{id} New messages</p>;
};

すると、何も表示されなくなりました。

message_good.png

その3:複数の state を用意する

Bad

名前、メールアドレス、パスワードを入力するフォームがあるとします。
その際、それぞれを state で用意するのは管理する state が増えますし冗長です。

const UserForm = () => {
  // 名前用の state
  const [name, setName] = useState("");
  // メール用の state
  const [email, setEmail] = useState("");
  // パスワード用の state
  const [password, setPassword] = useState("");

  // 名前用のハンドラ
  const handleChangeName = (e: ChangeEvent<HTMLInputElement>) => {
    setName(e.target.value);
  };

  // メール用のハンドラ
  const handleChangeEmail = (e: ChangeEvent<HTMLInputElement>) => {
    setEmail(e.target.value);
  };

  // パスワード用のハンドラ
  const handleChangePassword = (e: ChangeEvent<HTMLInputElement>) => {
    setPassword(e.target.value);
  };

  return (
    <form className="flex flex-col gap-3">
      <input
        className="border p-3"
        value={name}
        type="text"
        name="name"
        placeholder="name"
        onChange={handleChangeName}
      />
      <input
        className="border p-3"
        value={email}
        type="email"
        name="email"
        placeholder="email"
        onChange={handleChangeEmail}
      />
      <input
        className="border p-3"
        value={password}
        type="password"
        name="password"
        placeholder="password"
        onChange={handleChangePassword}
      />
      <input className="bg-blue-300 p-3 rounded-md" type="submit" />
    </form>
  );
};

Good

state の値をオブジェクトにし、1つで管理できるようにします。
こうすることで管理する state が1つになり、シンプルになりました。

const UserForm = () => {
  // フォーム共通の state
  const [userForm, setUserForm] = useState({
    name: "",
    email: "",
    password: "",
  });

  // 名前用のハンドラ
  const handleChangeName = (e: ChangeEvent<HTMLInputElement>) => {
    setUserForm((prev) => ({
      ...prev,
      name: e.target.value,
    }));
  };

  // メール用のハンドラ
  const handleChangeEmail = (e: ChangeEvent<HTMLInputElement>) => {
    setUserForm((prev) => ({
      ...prev,
      email: e.target.value,
    }));
  };

  // パスワード用のハンドラ
  const handleChangePassword = (e: ChangeEvent<HTMLInputElement>) => {
    setUserForm((prev) => ({
      ...prev,
      password: e.target.value,
    }));
  };

  return (
    <form className="flex flex-col gap-3">
      <input
        className="border p-3"
        value={userForm.name}
        type="text"
        name="name"
        placeholder="name"
        onChange={handleChangeName}
      />
      <input
        className="border p-3"
        value={userForm.email}
        type="email"
        name="email"
        placeholder="email"
        onChange={handleChangeEmail}
      />
      <input
        className="border p-3"
        value={userForm.password}
        type="password"
        name="password"
        placeholder="password"
        onChange={handleChangePassword}
      />
      <input className="bg-blue-300 p-3 rounded-md" type="submit" />
    </form>
  );
};

また、ハンドラがそれぞれのフォームに対して用意されており冗長なので、こちらも改善したいです。

ハンドラで更新しているオブジェクトの key をハードコードするのではなく、e.target.name を指定し、input 要素の name 属性を受け取るようにします。

const UserForm = () => {
  // フォーム共通の state
  const [userForm, setUserForm] = useState({
    name: "",
    email: "",
    password: "",
  });

  // フォーム共通のハンドラ
  const handleChangeForm = (e: ChangeEvent<HTMLInputElement>) => {
    setUserForm((prev) => ({
      ...prev,
      [e.target.name]: e.target.value,
    }));
  };

  return (
    <form className="flex flex-col gap-3">
      <input
        className="border p-3"
        value={userForm.name}
        type="text"
        name="name"
        placeholder="name"
        onChange={handleChangeForm}
      />
      <input
        className="border p-3"
        value={userForm.email}
        type="email"
        name="email"
        placeholder="email"
        onChange={handleChangeForm}
      />
      <input
        className="border p-3"
        value={userForm.password}
        type="password"
        name="password"
        placeholder="password"
        onChange={handleChangeForm}
      />
      <input className="bg-blue-300 p-3 rounded-md" type="submit" />
    </form>
  );
};

これで、ハンドラを1つに集約することができました。

なんでもかんでも集約すれば良いということではないですが、無駄な state をできるだけ排除することでコードをシンプルに保つことができます。

その4:不要なエフェクトを用意する

Bad

ボタンを押下して商品をカートに追加し、商品1つにつき +2000 円し合計を算出するケースがあるとします。
商品数に変化があれば、それを検知して合計値を計算する useEffect を用意したくなるかもしれません。

const PRICE_PER_ITEM = 2000;

const Cart = () => {
  const [cart, setCart] = useState(0);
  const [total, setTotal] = useState(0);

  const handleClick = () => {
    setCart(cart + 1);
  };

  useEffect(() => {
    setTotal(cart * PRICE_PER_ITEM);
  }, [cart]);

  return (
    <>
      <button className="bg-blue-300 p-3" onClick={handleClick}>
        Add Item
      </button>
      <p>cart: {cart}</p>
      <p>totalPrice: {total}</p>
    </>
  );
};

やりたいことは実現できてそうです。

cart_bad.gif

しかし、合計値は useEffect を使用しなくても計算可能です。

Good

useEffect を排除し、合計値を レンダー中に計算 します。

const PRICE_PER_ITEM = 2000;

const Cart = () => {
  const [cart, setCart] = useState(0);
  const total = cart * PRICE_PER_ITEM;

  const handleClick = () => {
    setCart(cart + 1);
  };

  return (
    <>
      <button className="bg-blue-300 p-3" onClick={handleClick}>
        Add Item
      </button>
      <p>cart: {cart}</p>
      <p>totalPrice: {total}</p>
    </>
  );
};

こうすることで、(無駄な useEffect を排除できたので)早く、シンプルになり、バグも起きにくくなりました。

その5:カスタムフックを使用しない

Bad

ネットワークのオンライン/オフラインを検知し、画面に表示するケースがあるとします。

グローバルの online および offline イベントにリスナを登録し、state を更新するエフェクトを用意します。

const Home = () => {
  const [isOnline, setIsOnline] = useState(true);

  useEffect(() => {
    // ネットワークがオンラインになった時に発火
    const handleOnline = () => setIsOnline(true);

    // ネットワークがオフラインになった時に発火
    const handleOffline = () => setIsOnline(false);

    window.addEventListener("online", handleOnline);
    window.addEventListener("offline", handleOffline);

    // アンマウント時に online と offline イベントリスナーを削除してメモリリークを防ぐ
    return () => {
      window.removeEventListener("online", handleOnline);
      window.removeEventListener("offline", handleOffline);
    };
  }, []);

  return <h1>{isOnline ? "Online" : "Offline"}</h1>;
};

ここで、別のコンポーネントでも同じロジックを使用したくなったとします。

ボタンコンポーネントに、ネットワークがオンラインの間は “Save” を、オフラインの間は “Reconnecting…” と表示されて無効になるような保存ボタンを実装したいとします。

その際、上記ですでに実装したロジックが使えるので下記のようにするかもしれません。

const SaveButton = () => {
  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("button clicked");

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

しかし、似たようなロジックを複数箇所で記載するのは冗長ですし、DRYの原則に違反しています。

Good

重複しているロジックを移動して、カスタムフックを作成しましょう。
カスタムフック名は use から始める必要があります。

const useNetworkStatus = () => {
  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;
};

これで、共通するロジックを1つのカスタムフックに共通化できました。
先ほどのコンポーネントから useNetworkStatus を呼びせばOKです。

const Home = () => {
  const isOnline = useNetworkStatus();

  return <h1>{isOnline ? "オンライン" : "オフライン"}</h1>;
};
const SaveButton = () => {
  const isOnline = useNetworkStatus();

  const handleSaveClick = () => console.log("button clicked");

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

こうすることで、コンポーネント間のロジックの重複が減り、ブラウザ API とのやり取りに関する詳細を隠蔽することができました。

その6:クロージャが最新状態を見失う

Bad

1秒ごとにカウントアップするケースを考えます。
useEffect 内で 1 秒ごとに +1 するコードを書きます。

const StaleClosure = () => {
  const [count, setCount] = useState(0);

  useEffect(() => {
    setInterval(() => {
      setCount(count + 1);
    }, 1000);
  }, []);

  return <p>Count: {count}</p>;
};

しかし、初回に +1 されて以降、変化がありません。

stale-closure_bad.gif

これは、依存配列に [] を渡しているので、コンポーネントがマウントされるときに一度だけ useEffect の第一引数に渡した関数が実行されるためです。マウント時に一度だけなので、関数が再作成されることはありません

countの初期値は 0 なので、この状態だと、1 秒ごとに実行されるのはずっと 0 + 1 になります。

setInterval(() => {
  setCount(0 + 1);
}, 1000);

クロージャ内で新しい状態を参照するには、関数を再作成させる必要があります

Good

依存配列に count を追加し、countの値に変化があれば関数を再作成するようにします。

const StaleClosure = () => {
  const [count, setCount] = useState(0);

  useEffect(() => {
    setInterval(() => {
      setCount(count + 1);
    }, 1000);
  }, [count]);

  return <p>Count: {count}</p>;
};

こうすることで、1秒ごとにカウントアップすることができます。

stale-closure_good_1.gif

うまく動いていそうな気がしますが、少し経つと挙動がおかしくなります。

stale-closure_good_2.gif

これは、count に変化があるたびに、別の setInterval を登録し続けているためです。
新しい setInterval を作成する前に、前回の setInterval をキャンセル必要があります。

具体的には、setIntervalで返されるid を保持しておいて、クリーンアップ関数内でクリアするようにします。

const StaleClosure = () => {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1);
    }, 1000);

    return () => {
      clearInterval(id);
    };
  }, [count]);

  return <p>Count: {count}</p>;
};

これで正常に動作するようになります。

と、ここまで長々と解説しましたが、React の更新用関数を渡すことでも問題なく動作します。

const StaleClosure = () => {
  const [count, setCount] = useState(0);

  useEffect(() => {
    setInterval(() => {
      setCount((prev) => prev + 1);
    }, 1000);
  }, []);

  return <p>Count: {count}</p>;
};

その7:useEffect でリフェッチする

Bad

ボタンを押下すると id が更新され、その id に紐づく post を useEffect で再取得する例を考えます。

const RefetchInUseEffect = () => {
  const [id, setId] = useState(1);
  const [text, setText] = useState("");

  const handleClick = () => {
    setId(Math.floor(Math.random() * 10));
  };

  useEffect(() => {
    fetch(`https://jsonplaceholder.org/posts/${id}`)
      .then((response) => response.json())
      .then((data) => setText(data.content));
  }, [id]);

  return (
    <>
      <button className="bg-blue-300 p-3 rounded-md" onClick={handleClick}>
        Show post
      </button>
      <div>{text}</div>
    </>
  );
};

ボタンを押下するたびに id が更新され、id に変化があると useEffect 内で post が再取得されます。

refetch-in-useEffect_bad.gif

うまく動作しているように見えますが、ボタンを連続で押下すると表示するテキストがちらつきます。
ネットワークが低速の状態だとわかりやすいです。

refetch-in-useEffect_bad_2.gif

これは単純に間髪入れずにボタンを 3 回押下すると、id が3回変更され、3回リフェッチされるためです。

このちらつきを防ぐために、前の fetch 呼び出しをキャンセルし、最後のクリックだけを確認するようにする必要があります。

Good

AbortController を作成し、インスタンスの signal プロパティを fetch リクエストに渡します
useEffect が再実行される前にクリーンアップ関数で controller.abort() を呼び、fetch がリクエストをキャンセルするようにします。

const RefetchInUseEffect = () => {
  const [id, setId] = useState(1);
  const [text, setText] = useState("");

  const handleClick = () => {
    setId(Math.floor(Math.random() * 10));
  };

  useEffect(() => {
    const controller = new AbortController();

    fetch(`https://jsonplaceholder.org/posts/${id}`, {
      signal: controller.signal,
    })
      .then((response) => response.json())
      .then((data) => setText(data.content));

    return () => controller.abort();
  }, [id]);

  return (
    <>
      <button className="bg-blue-300 p-3 rounded-md" onClick={handleClick}>
        Show post
      </button>
      <div>{text}</div>
    </>
  );
};

こうするとことで、間髪入れずに連続でボタンを押下しても、最後のクリックだけに反応し、テキストが表示されるようになりました。

refetch-in-useEffect_good.gif

まとめ

この記事では、React を始めた頃に知っておきたかったことを Bad/Good 例とともに 7 つ紹介しました。

  1. state の更新: state はすぐに更新されるわけではないので、更新関数を使用する
  2. 条件式: && 演算子の左辺に数値を置く際は、意図しないレンダリングを防ぐために注意が必要
  3. state の管理: 複数のstateを個別に管理するのではなく、オブジェクトにまとめることでコードをシンプルに保つ
  4. useEffect の利用: 不要な useEffect は排除し、レンダー中に計算できるものはレンダー中に処理する
  5. カスタムフック: コンポーネント間で共通のロジックを共有する場合は、カスタムフックを活用する
  6. クロージャ: クロージャ内で最新の state を参照するには、依存配列を正しく設定し、必要に応じてクリーンアップ関数で処理をキャンセルする
  7. データの再取得: useEffect でデータを取得する際は、AbortController を使用してリクエストをキャンセルし、不要な再取得を防ぐ

何か1つでも参考になりましたら幸いです!

参考

9
6
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
9
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?