React Hooksを使う上で意識したいことまとめ
- #React
- #Web Frontend
はじめに
Reactに関して最低限意識したいけどできてなかったなと思うところがあったので、
わかりやすいと感じた記事を参考に自分なりに噛み砕き、この1記事にまとめました。
useStateの使い方に注意する
https://zenn.dev/mocomichi/articles/47c95425bc28d5
上記の記事がとてもわかりやすく、すごい学びになりました!
参考記事から学びになった部分を項目ごとにピックアップして書いていきます。
関連する状態はまとめることを検討する
よくあるパターンでは、ログイン情報などだと思っています。
例えば、ログインにはemail
とpassword
がセットでログインするのはあるあるですよね。
なので、以下のようになりますね!
↓書き換え前
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const handleChangeEmail = (value: stirng) => {
setEmail(value)
}
const handleChangePassword = (value: string) => {
setPassowrd(value)
}
↓書き換え後
const [loginInfo, setLoginInfo] = useState({
email: "",
password: ""
})
const handleChangeEmail = (key: keyof typeof loginInfo, value: string) => {
setLoginInfo(prev => ({
...prev,
[key]: value,
}))
}
矛盾した状態の宣言を避ける
これはコンポーネントの状態を増やしまくってしまったら意図せず起こりそうですね。
(自分が参画した地獄の案件でも矛盾した状態だらけでした。。)
参考記事では、isSending
とisSent
という、
送信中と送信後の場合は同時に`true`になり得ないので、
別々の状態で管理するのではなく、status: “SENDING” | “SENT”
のような型の状態を
定義することで1つにまとまるという感じでした。
自分はモーダルでこのようなuseState
を定義することが多いです。モーダルは同じタイミングで2つ以上開くことはないと思っているからです。
例えば以下のようになります。
↓書き換え前
const [modalA, setModalA] = useState(false)
const [modalB, setModalB] = useState(false)
const toggleModalA = () => {
setModalA(b => !b)
}
const toggleModalB = () => {
setModalB(b => !b)
}
↓書き換え後
const [selectedModal, setSelectedModal] = useState<"" | "modalA" | "modalB">("")
const toggleModalA = () => {
setSelected(prev => prev !== "modalA" ? "modalA" : "")
}
const toggleModalB = () => {
setSelected(prev => prev !== "modalB" ? "modalB" : "")
}
ちなみに、トグルのハンドラを以下のように書くこともできますが、
引数を受け取るのような関数にすると、
propsで受け渡す時に() ⇒ toggleModal(”modalA”)
のように書くことになるので好きじゃないです。
const toggleModal = (value: "modalA" | "modalB") => {
setSelected(prev => prev !== value ? value : "")
}
冗長な使い方をしない
こちらは参考記事の内容では、firstName
とlastName
に加えて、
fullName
のための状態を持ってしまっているのは良くないということでした。
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [fullName, setFullName] = useState("");
const handleChangeFirstName = (e) => {
setFirstName(e.target.value);
setFullName(e.target.value + ' ' + lastName); // ←これが勿体無い
}
const handleChangeLastName = (e) => {
setLastName(e.target.value);
setFullName(firstName + ' ' + e.target.value); // ←これが勿体無い
}
上記の記述は、firstName
とlastName
からfullName
を計算できるにも関わらず、
状態として保持してしまうのはとても勿体無いし、
firstName
やlastName
の更新時と同時にわざわざ更新しないとなのも勿体無いですよね。
なので、以下のように修正できます。
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
fullName = firstName + " " + lastName;
const handleChangeFirstName = (e) => {
setFirstName(e.target.value);
}
const handleChangeLastName = (e) => {
setLastName(e.target.value);
}
もっというと、firstName
とlastName
は関連している情報なので、
userName
のような一つの状態でまとめられるよねってことでした。
まとめると、既存の状態から計算できる値は状態で持つのではなく、
定数として定義して、レンダリングのたびに計算させるようにするということですね。
記事内でもう一つ紹介されていたのは、propsから渡ってきた値を状態で持つのも避けるべきということでした。
以下のような状態のことです。
function Text({ children, color }) {
const [textColor] = useState(color);
return <h1 style={{ color: textColor }}>{children}</h1>;
}
export default function Example() {
const [color, setColor] = useState("red");
return (
<div>
<p>
色を選択
<select value={color} onChange={(e) => setColor(e.target.value)}>
<option value="red">Red</option>
<option value="blue">Blue</option>
<option value="green">Green</option>
</select>
</p>
<Text color={color}>色が変わります</Text>
</div>
);
}
これだと、レンダリングの際にのみuseStateが初期化されるため、
propsに変更が起きてもtextColor
は変化せず、色が変わらないようになってしまいます。
なので、以下のように書き換えます。
function Text({ children, color }) {
const textColor = color;
return <h1 style={{ color: textColor }}>{children}</h1>;
}
こうすると、propsが変更されるたびにtextColor
が変更されて色が正しく変わりますよね。
上記のような例が参考記事では紹介されていました。
自分的にはレンダリングに同期して値が変えたくないなどがない限りは、値を変更しないuseStateは必要ないと思います。
また、これらの例の通りuseStateを使わずに随時再計算をするような記述になると、
その計算が大きくなればなるほど、メモ化を検討するなどレンダリングを意識した実装を心がけるべきだと思っています。
重複した状態の宣言は避ける
早速、以下の記述を見ていただきます。
const initialItems = [
{ id: 1, title: "taskA" },
{ id: 2, title: "taskB" },
{ id: 3, title: "taskC" },
];
const [tasks, setTasks] = useState(initialItems);
const [selectedTask, setSelectedTask] = useState(tasks[0]);
function handleTaskChange(taskId, e) {
setTasks(tasks.map((task) => (task.id === taskId ? { ...task, title: e.target.value } : task)));
setSelectedTask((task) => (task.id === taskId ? { ...task, title: e.target.value } : task));
}
function handleSelectedTaskChange(task) {
setSelectedTask(task);
}
この記述のうち、useState
として宣言されているものがtasks
とselectedTask
の二つとなっています。
この記述の何が問題かというと、
task
とselectedTask
という明らかに同じ内容のもののためにuseState
を2つ定義していることです。
それによって、状態を更新する時にはタスクのリストに加え、
選択されたタスクも更新する必要があり、2度同じような更新の処理を書く必要があります。
つまるところ、ここが冗長だというわけですね。
こういった記述を改善したものが以下になります。
const [tasks, setTasks] = useState(initialItems);
const [selectedTaskId, setSelectedTaskId] = useState(0);
function handleTaskChange(taskId, e) {
setTasks(tasks.map((task) => (task.id === taskId ? { ...task, title: e.target.value } : task)));
}
function handleSelectedTaskIdChange(taskId) {
setSelectedTaskId(taskId);
}
const selectedTask = tasks.find((task) => task.id === selectedTaskId);
要するに、選択されたタスクを状態として持つのではなく、
選択されたタスクのid
を状態として持つことで、更新時に冗長になってしまう問題を解決できます。
そして選択されたタスクを定数としてレンダリング時に再計算する形で記述するといい感じになりました。
とてもわかりやすい例ですよね。元となる配列の情報から任意のものを1つ指定して扱いたい場合に、
useState
を使って指定した情報そのものを新しく定義してしまいがちです。
しかしその場合は、指定した情報そのものではなく、
それを指すユニークな値を状態として持つと冗長な記述を回避できるよってことですね。
useCallbackをとにかく使う
https://blog.uhy.ooo/entry/2021-02-23/usecallback-custom-hooks/
上記の記事、すごく確かに!って思いました。
React.memo
だとかuseMemo
だとかuseCallback
だとか、よく聞くし、
パフォーマンス最適化というめちゃくちゃかっこよくて使いたい!!ってなりますよね笑
ただ実際相当のことがないと使わないと思いますし、
そもそもこれらのフックの呼び出しにオーバーヘッドがあり、
アプリケーションのパフォーマンスの低下を観測してからパフォーマンスチューニングしながら使うものだと思っています。
本題に入る前に、一旦React.memo
、useMemo
、useCallback
の3つのついて、以下の記事等を参考にしていただければと思います。
https://beta.reactjs.org/apis/react/memo
https://beta.reactjs.org/apis/react/useMemo
https://beta.reactjs.org/apis/react/useCallback
https://zenn.dev/nus3/articles/1978a344cfaa4d3359c1
超簡単にこの記事内でも説明しておきます。
React.memo
:
親→子コンポーネントに渡されているpropsに変更がない限りは、親コンポーネントのレンダリング時に子コンポーネントを再レンダリングしないようにできる。React.memo
を使っていない場合は親コンポーネントと一緒にレンダリングされる。
useMemo
:
関数の計算結果をメモ化する。依存配列に変更がない限り、同じ値を返し続ける。
useCallback
:
関数自体をメモ化する。不要に関数インスタンスを作成するのを抑制し、前回の関数と同値(a === b
の関係)の関数を返却するので、
React.memo
を使用したコンポーネントに関数を受け渡す場合などに便利。
もしuseCallback
を使わなかった場合、関数オブジェクトなのでpropsに変更があったということになり、
React.memo
を使用しなかった場合も再レンダリングされます。
React.memo
、useMemo
、useCallback
に関して理解したところで、
「useCallbackをとにかく使う」という主張の具体的な根拠についてまとめていきます。
そもそも「useCallbackをとにかく使う」とはどういったことかについてお話すると、カスタムフックを作る際に、なんでもかんでもuseCallback
で囲むのはありかどうかという論点について、囲むべきという主張です。
カスタムフックを作る理由は、普通の関数を作る理由と全く同じであり、すなわち責務の分離とか
カプセル化
です。一度カスタムフックとして分離された以上、インターフェースの内側のことはカスタムフック内で完結すべきです。 カスタムフックを使う側はカスタムフックの内側のことを知るべきではなく、その逆も然りです。
確かにと思いました。参考記事でも言及されているのですが、
使う場所によって「ここはReact.memo
を使っているからuseCallback
を使おう」だとか、
「ここはuseCallback
が必要ないし、オーバーヘッドが気になるから使わないでおこう」
のような使う側の都合に合わせていたら、
再利用性や独立性に欠けるものコンポーネントと化してしまいます。
となると、果たしてカスタムフックとしての責務を担えているのかという問題になります。
それに、そもそもuseCallback
を使用することのオーバーヘッドってそんな辛いんだっけってことから考えても、最初からuseCallback
を使用するようにしておくべきということです。
Reactでは値が同じであることにロジック上の意味が与えられるので、同じ意味の関数を返すときは極力オブジェクトとして同じ関数を返すべき
こちらに関してもとても納得しました。
そもそも同値かそうでないかは、React自体に大きな意味を持つものです。なので、同値かそうでないを明確化することはロジック上も大きな意味を持つものであるということですね!
これらの理由から、自分も「useCallbackをとにかく使う」という結論に至りました。
useEffectはなるべく使わない
https://beta.reactjs.org/learn/you-might-not-need-an-effect
この主張に関しては、公式docsにおける上記を元に言及しています。
どんな時にuseEffect
は必要ないのかについて説明していきます。
まずは、データの変更に伴って別のデータの変更を行う場合にuseEffect
が必要ないパターンです。
以下、参考記事の例です。
↓書き換え前
function Form() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
// 🔴 Avoid: redundant state and unnecessary Effect
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(firstName + ' ' + lastName);
}, [firstName, lastName]);
// ...
}
↓書き換え後
function Form() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
// ✅ Good: calculated during rendering
const fullName = firstName + ' ' + lastName;
// ...
}
解説すると、まず書き換え前はfirstName
またはlastName
に変更があった場合にuseEffect
を使用してfullName
の状態を更新するといった処理を記述しています。
確かにfullName
の値を正しく更新できてはいますが、非効率ですよね。
なぜなら書き換え後のように、レンダリング時に定数として計算した値を格納すれば問題ないからです。
これは「useStateの使い方に注意する」の部分でも記述したものと同じように、useState
の値に変更が起こるタイミングで再計算が起こります。
なので、値の計算によって取得できる値を状態に持つことは余分であり、ましてやuseEffect
を使うのは明らかに不必要で、むしろ場合によっては無駄にレンダリングを起こしてしまう原因になります。
では、もしその再計算が重い処理であった場合はどうすべきかということを考えます。
以下のような処理があったとします。
function TodoList({ todos, filter }) {
const [newTodo, setNewTodo] = useState('');
// 🔴 Avoid: redundant state and unnecessary Effect
const [visibleTodos, setVisibleTodos] = useState([]);
useEffect(() => {
setVisibleTodos(getFilteredTodos(todos, filter));
}, [todos, filter]);
// ...
}
一旦getFilteredTodos
が軽い処理であったと仮定して記述を見てみると、先述した通り、useEffect
が不要なので以下のように書き換えられます。
function TodoList({ todos, filter }) {
const [newTodo, setNewTodo] = useState('');
// ✅ This is fine if getFilteredTodos() is not slow.
const visibleTodos = getFilteredTodos(todos, filter);
// ...
}
これまでと同様、レンダリング時に再計算をする形で十分ですよね。
それでは、getFilteredTodos
が重い場合はどう書くのかというと、以下のようになります。
import { useMemo, useState } from 'react';
function TodoList({ todos, filter }) {
const [newTodo, setNewTodo] = useState('');
const visibleTodos = useMemo(() => {
// ✅ Does not re-run unless todos or filter change
return getFilteredTodos(todos, filter);
}, [todos, filter]);
// ...
}
上記で記述したとおり、メモ化を活用していくと依存配列の値(ここではtodos
とfilter
)に変更がない限り再計算をせず、前回の値を使いまわしてくれます。
なので、もし重たい処理を何度も再計算してパフォーマンスが低下したとしても、useMemo
を活用することで解決することができます。(メモ化については「useCallbackをとにかく使う」でも軽く触れてます)
次のuseEffect
が必要ないパターンは、propsの変更によって状態をリセットするパターンです。
まずは以下の記述をご覧ください。
export default function ProfilePage({ userId }) {
const [comment, setComment] = useState('');
// 🔴 Avoid: Resetting state on prop change in an Effect
useEffect(() => {
setComment('');
}, [userId]);
// ...
}
こちらはユーザーごとのプロフィールをuserId
のpropsでユーザーを判別しています。
またcomment
という値によって、そのuserId
に対応するユーザーのプロフィールにコメントができるというロジックが書いてあったとします。
その場合、userId
が別のものになった時に前回のuserId
に対応するユーザーのプロフィールで入力中でcomment
の値に格納されている値はリセットされないといけないです。
そのため、useEffect
によってuserId
に変更があった場合にcomment
の値を空の文字列にしてリセットしています。
もしリセットするためのuseEffect
がなかった場合、Reactの同じコンポーネントかつ同じ場所にレンダリングされたコンポーネントの状態は保持し続けるので、comment
の値が前回ユーザーのプロフィールで入力されていたものが残ってしまいます。
しかしこの場合もuseEffectを使用せずに対応することができます。
そのためには、以下のように書き換えます。
export default function ProfilePage({ userId }) {
return (
<Profile
userId={userId}
key={userId}
/>
);
}
function Profile({ userId }) {
// ✅ This and any other state below will reset on key change automatically
const [comment, setComment] = useState('');
// ...
}
変わったところというと、状態を持っていた部分を切り離し、keyにuserId
を指定して対処しています。
これでなぜ対応できるのかというと、先ほど述べた「Reactの同じコンポーネントかつ同じ場所にレンダリングされたコンポーネントの状態は保持し続ける」という特徴をによるものです。
そもそも同じものとしてReactに認識されていたことが原因で状態がリセットされていなかったので、Reactにこれは違うものだということを教えてあげているのですね。
そのためには、keyにユニークなものを指定することでそういった対応が可能になります。
Reactでのkeyは、map
などにも使うように特別な役割を果たしているのですね。
詳しくは以下の記事を見ていただけると、keyについて理解を深められるのではと思います。
https://progtext.net/programming/react-key/
一応、ここで簡単に説明しておくと、keyはReactがその項目と紐づけるためのものです。
keyはユニークな値でなければならず、その項目と1対1の関係になります。
要するにオブジェクトの{ key: value }
のようなことです。
それでは一旦本題に戻ると上記のような場合にkeyを使うとuseEffect
を使用する必要がなくなることがわかりました。
では、もし2つ以上の状態のうち1つのみをリセットしたい場合はどうすべきでしょうか。
先ほどのやり方だと、全ての状態をリセットしてしまいます。
例によって、以下のような記述があるとします。
function List({ items }) {
const [isReverse, setIsReverse] = useState(false);
const [selection, setSelection] = useState(null);
// 🔴 Avoid: Adjusting state on prop change in an Effect
useEffect(() => {
setSelection(null);
}, [items]);
// ...
}
このようにitemsの変更に伴ってselectionの値をリセットする形を取ることができます。
しかしもちろんこれにも問題があります。どこが問題かというと、レンダリングが明らかに余分に起きてしまいます。
具体的にどういったことかというと、items
に変更ができた時に1回レンダリングされ、その後useEffect
内のsetSelection(null)
でselection
をリセットした場合にもう一度レンダリングされてしまいます。
なので、以下のような記述に書き換えてみます。
function List({ items }) {
const [isReverse, setIsReverse] = useState(false);
const [selection, setSelection] = useState(null);
// Better: Adjust the state while rendering
const [prevItems, setPrevItems] = useState(items);
if (items !== prevItems) {
setPrevItems(items);
setSelection(null);
}
// ...
}
前回の値を状態として持ち上記のように扱うことで、
先ほどのようなitems
の変更につられて2度変更が起きるようなことは無くなりました。
ところが、これは可読性的に微妙だったりするので、もっといい方法がありそうです。
例えば以下のように変更してみればどうでしょうか?
function List({ items }) {
const [isReverse, setIsReverse] = useState(false);
const [selectedId, setSelectedId] = useState(null);
// ✅ Best: Calculate everything during rendering
const selection = items.find(item => item.id === selectedId) ?? null;
// ...
}
id
を状態として持つことによって、レンダリングを選択されている要素を指す値が変わったタイミングのみに絞ることができます。
次に紹介したいのが、アプリケーションを初期化する場合にuseEffect
を使ってしまうパターンです。
例えば以下のような記述があるとします。
function App() {
// 🔴 Avoid: Effects with logic that should only ever run once
useEffect(() => {
loadDataFromLocalStorage();
checkAuthToken();
}, []);
// ...
}
こちらは最初のレンダリング時のみにローカルストレージをロードし、トークンのチェックを行う関数を実行しています。
ただこのuseEffect
も削減できます。
以下のように書き換えることで実現できます。
if (typeof window !== 'undefined') { // Check if we're running in the browser.
// ✅ Only runs once per app load
checkAuthToken();
loadDataFromLocalStorage();
}
function App() {
// ...
}
上記のようにコンポーネントの中に書くのではなく、外側に記述することでファイルを読み込む1度きりの実行にすることができ、わざわざuseEffect
を書かなくてもうまく実行することができます。
しかもReact18だと、開発環境でuseEffect
が2回実行されてしまうので、それによる想定外の動作も防ぐことができます。
まとめ
ここまでReact Hooksに関するとてもわかりやすい記事と共に自分が理解しやすいように噛み砕いて説明していますが、やはりそれぞれの元記事を一度読んでみるともっと理解が深まると思うので一度見てみて下さい!
参考記事
https://zenn.dev/mocomichi/articles/47c95425bc28d5
https://blog.uhy.ooo/entry/2021-02-23/usecallback-custom-hooks/
https://beta.reactjs.org/apis/react/memo
https://beta.reactjs.org/apis/react/useMemo
https://beta.reactjs.org/apis/react/useCallback
https://zenn.dev/nus3/articles/1978a344cfaa4d3359c1