こぺろロゴ

Coopello Blog

React Hooksを使う上で意識したいことまとめ

  • #React
  • #Web Frontend

はじめに

Reactに関して最低限意識したいけどできてなかったなと思うところがあったので、

わかりやすいと感じた記事を参考に自分なりに噛み砕き、この1記事にまとめました。

useStateの使い方に注意する

https://zenn.dev/mocomichi/articles/47c95425bc28d5

上記の記事がとてもわかりやすく、すごい学びになりました!

参考記事から学びになった部分を項目ごとにピックアップして書いていきます。

関連する状態はまとめることを検討する

よくあるパターンでは、ログイン情報などだと思っています。

例えば、ログインにはemailpasswordがセットでログインするのはあるあるですよね。

なので、以下のようになりますね!

↓書き換え前

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,

  }))

}

矛盾した状態の宣言を避ける

これはコンポーネントの状態を増やしまくってしまったら意図せず起こりそうですね。

(自分が参画した地獄の案件でも矛盾した状態だらけでした。。)

参考記事では、isSendingisSent という、

送信中と送信後の場合は同時に`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 : "")

}

冗長な使い方をしない

こちらは参考記事の内容では、firstNamelastNameに加えて、

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); // ←これが勿体無い

}

上記の記述は、firstNamelastName からfullName を計算できるにも関わらず、

状態として保持してしまうのはとても勿体無いし、

firstNamelastNameの更新時と同時にわざわざ更新しないとなのも勿体無いですよね。

なので、以下のように修正できます。

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);

}

もっというと、firstNamelastNameは関連している情報なので、

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として宣言されているものがtasksselectedTaskの二つとなっています。

この記述の何が問題かというと、

taskselectedTaskという明らかに同じ内容のもののために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.memouseMemouseCallbackの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.memouseMemouseCallback に関して理解したところで、

「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]);

  // ...

}

上記で記述したとおり、メモ化を活用していくと依存配列の値(ここではtodosfilter)に変更がない限り再計算をせず、前回の値を使いまわしてくれます。

なので、もし重たい処理を何度も再計算してパフォーマンスが低下したとしても、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

https://beta.reactjs.org/learn/you-might-not-need-an-effect

https://progtext.net/programming/react-key/