はじめに
React で開発を進める上で、ハマりがちな罠や非効率な書き方、そしてパフォーマンス低下に繋がるようなアンチパターンは数多く存在します。
本記事では、React を始めた初心者の方がハマりがちな罠とその回避法を Bad/Good 例とともに解説します。
目次を載せておきますので、気になるところがあればぜひジャンプして見てみてください🖐️
- その1:state はすぐに更新されるわけではない
- その2:&& の左辺に数値を置く
- その3:複数の state を用意する
- その4:不要なエフェクトを用意する
- その5:カスタムフックを使用しない
- その6:クロージャが最新状態を見失う
- その7:useEffect でリフェッチする
その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 しかされません。
これは、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 されるようになりました。
その2:&& の左辺に数値を置く
Bad
下記のようなコンポーネントがあるとします。
const Message: React.FC<{ id: number }> = ({ id }) => {
return id && <p>{id} New messages</p>;
};
呼び出し側で、id に 0 を渡すとどうなるでしょう。
<Message id={0} />
0 は falty な値なので何も表示されないことを期待したかもしれませんが、実際には 0 が表示されます。
これは、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>;
};
すると、何も表示されなくなりました。
その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>
</>
);
};
やりたいことは実現できてそうです。
しかし、合計値は 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 されて以降、変化がありません。
これは、依存配列に []
を渡しているので、コンポーネントがマウントされるときに一度だけ 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秒ごとにカウントアップすることができます。
うまく動いていそうな気がしますが、少し経つと挙動がおかしくなります。
これは、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 が再取得されます。
うまく動作しているように見えますが、ボタンを連続で押下すると表示するテキストがちらつきます。
ネットワークが低速の状態だとわかりやすいです。
これは単純に間髪入れずにボタンを 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>
</>
);
};
こうするとことで、間髪入れずに連続でボタンを押下しても、最後のクリックだけに反応し、テキストが表示されるようになりました。
まとめ
この記事では、React を始めた頃に知っておきたかったことを Bad/Good 例とともに 7 つ紹介しました。
- state の更新: state はすぐに更新されるわけではないので、更新関数を使用する
-
条件式:
&&
演算子の左辺に数値を置く際は、意図しないレンダリングを防ぐために注意が必要 - state の管理: 複数のstateを個別に管理するのではなく、オブジェクトにまとめることでコードをシンプルに保つ
- useEffect の利用: 不要な useEffect は排除し、レンダー中に計算できるものはレンダー中に処理する
- カスタムフック: コンポーネント間で共通のロジックを共有する場合は、カスタムフックを活用する
- クロージャ: クロージャ内で最新の state を参照するには、依存配列を正しく設定し、必要に応じてクリーンアップ関数で処理をキャンセルする
- データの再取得: useEffect でデータを取得する際は、AbortController を使用してリクエストをキャンセルし、不要な再取得を防ぐ
何か1つでも参考になりましたら幸いです!