🔥

【7 + 1】React Hooks を紹介

2023/05/07に公開

初めに

今回は、React Hooks のうち 7 つと、それらを組み合わせて独自の Hooks を作成する Custom Hook を合わせた合計8つを説明します。
React で開発をするのであれば Hooks は必要不可欠なものなので、ぜひ覚えていってください。

今回紹介するものは

  1. useState
  2. useReducer
  3. useContext
  4. useRef
  5. useEffect
  6. useMemo
  7. useCallback
  8. Custom Hook
    となります。

React Hooks とは

React Hooks は、React16.8 に導入されたもので、関数コンポーネントにステートを持たせたり、ライフサイクルに応じた処理をすることができます。少し難しいかもしれませんが、開発する際に便利な機能だと思っておけばよいと思います。

useState

はじめは、useStateです React Hooks の中でも特に多用する Hook です。

import { useState } from "react";

const Home = () => {
  const [state, setState] = useState<string>("");
  const [age, setAge] = useState<number>(0);
  return (
    <>
      <input
        type="text"
        value={state}
        onChange={(e) => setState(e.target.value)}
      />
      <p>{state}</p>
    </>
  );
};
import { useState } from "react";

const Home = () => {
  const [number, setNumber] = useState<number>(0);
  return (
    <>
      <button onClick={() => setNumber((prevState) => prevState + 1)}>
        increment
      </button>
      <p>{number}</p>
    </>
  );
};

useStateの返り値には、ステートと、更新用関数があります。useStateの引数に指定した値がステートの初期値になります。
今回の例の場合、フォームに入力された値を onChange でステートを更新するものと、ボタンを押すと数字が1ずつ増える機能を実装しています。
更新用関数の、setAgeprevStateには現在のstateが入っています。
また、useStateはジェネリクスで型を指定することができます。
更新用関数の命名は、慣例的に set○○(ステートの変数名)となります。

useReducer

useReduceruseStateと似ており、ステートの管理をすることができる Hook です。

import { useReducer } from "react";

type State = {
  name: string;
  age: number;
};

type Action =
  | {
      type: "incremented_age";
    }
  | {
      type: "changed_name";
      payload: {
        nextName: string;
      };
    };

const reducer = (state: State, action: Action) => {
  switch (action.type) {
    case "incremented_age": {
      return {
        name: state.name,
        age: state.age + 1,
      };
    }
    case "changed_name": {
      return {
        name: action.payload.nextName,
        age: state.age,
      };
    }
  }
};

const Home = () => {
  const [state, dispatch] = useReducer(reducer, { name: "tanaka", age: 42 });

  return (
    <>
      <button onClick={() => dispatch({ type: "incremented_age" })}>
        Increment age
      </button>
      <input
        type="text"
        value={state.name}
        onChange={(e) =>
          dispatch({
            type: "changed_name",
            payload: { nextName: e.target.value },
          })
        }
      />
      <p>
        Hello, {state.name}. You are {state.age}.
      </p>
    </>
  );
};

useStateに比べて説明することが多いので順番に説明していきます。

const [state, dispatch] = useReducer(reducer, { name: "tanaka", age: 42 });

useReducerの第一引数が更新に関する reducer 関数で、第二引数はステートの初期値です。

const reducer = (state: State, action: Action) => {
  switch (action.type) {
    case "incremented_age": {
      return {
        name: state.name,
        age: state.age + 1,
      };
    }
    case "changed_name": {
      return {
        name: action.payload.nextName,
        age: state.age,
      };
    }
  }
};

reducer 関数の中身を見てみましょう。第一引数には、現在のステートが入り、第二引数には、typepayloadが入ります。

<button onClick={() => dispatch({ type: "incremented_age" })}>
    Increment age
</button>
<input
    type="text"
    value={state.name}
    onChange={(e) =>
    dispatch({
    type: "changed_name",
    payload: { nextName: e.target.value },
      })
    }
/>

useStateだと、イベントを発火する側で、どのように更新するかの処理を書いていましたが、useReducerの場合、reducer 関数でどのような処理をするのかを書き、イベントを発火する側では、それを指定するという方法で更新をします。
useReducerを使うメリットは、ステートとそれをどのように更新するかをひとまとめにできるという点です。

useContext

次は、useContextです。これは、親要素から子要素、孫要素、子孫要素 のようなネストの深い階層にあるコンポーネントの下層に値を渡したい場合、その値を使用しないのに、プロップスとして子要素や孫要素 にも記載する必要があり、コードの可読性が低下してしまうことを防ぐための Hook です。ちなみにこれをプロップスのバケツリレーともいわれています。

まずは、useContextを使用しない場合のコードを見てみましょう。

pages/index.tsx
import Children1 from "@/components/Children1";
import Children2 from "@/components/Children2";
import { useState } from "react";

export type User = {
  name: string;
  age: number;
};

export const Home = () => {
  const [user, setUser] = useState<User>({
    name: "yamada",
    age: 18,
  });
  return (
    <>
      <div className="flex">
        <Children1 name={user.name} setUser={setUser} />
        <Children2 age={user.age} setUser={setUser} />
      </div>
    </>
  );
};
// components/Children1.tsx
import { User } from "@/pages/context";
import React from "react";
import GrandChild1 from "./GrandChild1";

const Children1 = (props: {
  name: string;
  setUser: React.Dispatch<React.SetStateAction<User>>;
}) => {
  const { name, setUser } = props;
  return (
    <div>
      <p>{name}</p>
      <GrandChild1 setUser={setUser} name={name} />
    </div>
  );
};

export default Children1;

// components/GrandChild1.tsx
import { User } from "@/pages/context";
import React from "react";

const GrandChild1 = (props: {
  name: string;
  setUser: React.Dispatch<React.SetStateAction<User>>;
}) => {
  const { name, setUser } = props;
  return (
    <>
      <h1>GrandChild1</h1>
      <input
        className="text-black"
        type="text"
        value={name}
        onChange={(e) =>
          setUser((prev) => {
            return {
              ...prev,
              name: e.target.value,
            };
          })
        }
      />
    </>
  );
};

export default GrandChild1;
// components/Children2.tsx
import { User } from "@/pages/context";
import React from "react";
import GrandChild2 from "./GrandChild2";

const Children2 = (props: {
  age: number;
  setUser: React.Dispatch<React.SetStateAction<User>>;
}) => {
  const { age, setUser } = props;

  return (
    <div>
      <h1>Children2</h1>
      <p>{age}</p>
      <GrandChild2 setUser={setUser} />
    </div>
  );
};

export default Children2;

// components/GrandChild2.tsx
import { User } from "@/pages/context";
import React from "react";

const GrandChild2 = (props: {
  setUser: React.Dispatch<React.SetStateAction<User>>;
}) => {
  const { setUser } = props;

  return (
    <>
      <h1>GrandChild2</h1>
      <button
        onClick={() =>
          setUser((prev) => {
            const age = prev.age + 1;
            return {
              ...prev,
              age,
            };
          })
        }
      >
        increment
      </button>
    </>
  );
};

export default GrandChild2;

少し長いですが、index.tsxからChildren1,2にステートと、更新用関数を渡し、Children1,2から、GrandChild1,2に更新用関数を渡しています。
これを、useContextを使用して書き換えてみましょう。

context/contex.tsx
import React, { createContext, useState } from "react";

export type User = {
  name: string;
  age: number;
};
const defaultUser: User = {
  name: "",
  age: 0,
};
const defaultSetUser: React.Dispatch<React.SetStateAction<User>> = () => {};

export const UserContext = createContext<User>(defaultUser);
export const UserDispatchContext =
  createContext<React.Dispatch<React.SetStateAction<User>>>(defaultSetUser);

export const UserProvider = (props: { children: React.ReactNode }) => {
  const { children } = props;
  const [user, setUser] = useState<User>({
    name: "yamada",
    age: 18,
  });
  return (
    <UserContext.Provider value={user}>
      <UserDispatchContext.Provider value={setUser}>
        {children}
      </UserDispatchContext.Provider>
    </UserContext.Provider>
  );
};

export const useUser = () => {
  return useContext(UserContext);
};
export const useUserDispatch = () => {
  return useContext(UserDispatchContext);
};


export default UserProvider;
pages/index.tsx
import Children1 from "@/components/Children1";
import Children2 from "@/components/Children2";
import Children3 from "@/components/Children3";
import UserProvider from "@/context/context";

export const Home = () => {
  return (
    <UserProvider>
        <div className="flex">
          <Children1 />
          <Children2 />
        </div>
    </UserProvider>
  );
};
// components/Children1.tsx
import GrandChild1 from "./GrandChild1";
import { useUser } from "@/context/context";

const Children1 = () => {
  const user = useUser();
  return (
    <div>
      <h1>Children1</h1>
      <p>{user.name}</p>
      <GrandChild1 />
    </div>
  );
};

export default Children1;

// components/GrandChild1.tsx
import { useUser, useUserDispatch } from "@/context/context";

const GrandChild1 = () => {
  const user = useUser();
  const setUser = useUserDispatch();
  return (
    <>
      <h1>GrandChild1</h1>
      <input
        className="text-black"
        type="text"
        value={user.name}
        onChange={(e) =>
          setUser((prev) => {
            return {
              ...prev,
              name: e.target.value,
            };
          })
        }
      />
    </>
  );
};

export default GrandChild1;
// components/Children2.tsx
import GrandChild2 from "./GrandChild2";
import { useUser } from "@/context/context";

const Children2 = () => {
  const user = useUser();

  return (
    <div>
      <h1>Children2</h1>
      <p>{user.age}</p>
      <GrandChild2 />
    </div>
  );
};

export default Children2;

// components/GrandChild2.tsx
import { useUserDispatch } from "@/context/context";

const GrandChild2 = () => {
  const setUser = useUserDispatch();

  return (
    <>
      <h1>GrandChild2</h1>
      <button onClick={() =>
          setUser((prev) => {
            const age = prev.age + 1;
            return {
              ...prev,
              age,
            };
          })
        }
        increment
      </button>
    </>
  );
};

export default GrandChild2;

注目してほしいのはcontext.tsxです。
まずはcreateContextで コンテキスト というものを作成します。引数には初期値を入れます。

export const UserContext = createContext<User>(defaultUser);
export const UserDispatchContext =
  createContext<React.Dispatch<React.SetStateAction<User>>>(defaultSetUser);

作成したコンテキストで children をラップします。
value には値(useState)を入れています。
なぜステートと更新用関数を別のコンテキストにしているのかについては、後程説明します。

<UserContext.Provider value={user}>
  <UserDispatchContext.Provider value={setUser}>
    {children}
  </UserDispatchContext.Provider>
</UserContext.Provider>

children というのは、UserProviderを使用する際に囲んだ中身のことです。今回の場合、div タグと Children1,Children2 が children になります。

pages/index.tsx
<UserProvider>
    <div className="flex">
        <Children1 />
        <Children2 />
    </div>
</UserProvider>
export const useUser = () => {
  return useContext(UserContext);
};
export const useUserDispatch = () => {
  return useContext(UserDispatchContext);
};

このコードは値を取得するための関数です。各ページでuseContext(UserContext)と書くのは面倒なので、context.tsxであらかじめ定義をしています。

そして、以下のように各コンポーネントで値を使用しています。

const user = useUser();
const setUser = useUserDispatch();

こうすることでプロップスのバケツリレーを防ぐことができます。


ステートと更新用関数を別のコンテキストにしている理由

ステートと更新用関数を同じコンテキストにまとめてしまっていると、更新用関数しか使用していないコンポーネントでもステートが変更されたときに再レンダリングされてしまいます。
ためしに更新用関数のみをインポートした Children3 を作成してみましょう。
各コンポーネントにもログを置いています。

components/Children3.tsx
import { useUser, useUserDispatch } from "@/context/context";
import React, { useState } from "react";

const Children3 = () => {
  console.log("children3");
  const setUser = useUserDispatch();

  return (
    <div>
    Children3
    </div>
  );
};

export default Children3;
pages/context.tsx
<UserProvider>
   <div className="flex">
       <Children1 />
        <Children2 />
        // 追加
        <Children3 />
    </div>
</UserProvider>

画面をリロードすると以下のようになります。
最初はすべてのコンポーネントを読み込んでいるためすべてのログが出力されます。
画像
その後 increment のボタンを何度か押したときのログです。ステートと更新用関数を別のコンテキストにしているため、children3 だけログがないことがわかります。
画像

useRef

useRefuseStateと似ていて、値を保持できますが、useRefの値が変更されても再レンダリングされないという特徴があります。
useRefで保持する値は DOM ノードへの参照を格納するために使用されることが多いです。
まずは、再レンダリングされないということを確認してみましょう。

import { useRef } from "react";

const Home = () => {
  let ref = useRef(0);
  console.log("ref render");

  const handleClick = () => {
    ref.current = ref.current + 1;
    console.log(ref.current);
  };

  return <button onClick={handleClick}>Click me!</button>;
};

export default Home;

このページでは、ボタンを押すたびに変数 ref に格納された数字を+1します。
画像
ボタンをクリックしてみると、変数 ref の値は増えていっていますが、"ref render"は画面が読み込まれたときのログのみとなっており、再レンダリングされていないことがわかります。


次は、DOM ノードへの参照を格納してみましょう。

import { useRef } from "react";

const Home = () => {
  let divRef = useRef<HTMLDivElement>(null!);

  const handleClick = () => {
    const divWidth = divRef.current;
    console.log(divWidth.clientWidth);
  };

  return (
    <>
      <button onClick={handleClick}>click</button>
      <div className="bg-red-300" ref={divRef}>
        divタグ
      </div>
    </>
  );
};

export default Home;

div タグに ref 属性を付与しています。
ボタンをクリックした際に、div タグの横幅を取得し、ログに出力しています。他にも DOM 要素にフォーカスを当てたり、スクロールしたり、サイズや位置を計測することができます。

useEffect

次は、useEffectです。この Hook は React が DOM を更新した後(レンダリング後)で追加のコードを実行したい場合に使用します。
使い方を見てみましょう。

import { useEffect, useState } from "react";

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

  useEffect(() => {
    console.log("useEffect");
  }, []);
  console.log(count);

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>increment</button>
    </div>
  );
};

export default Home;
useEffect(() => {
  console.log("useEffect");
}, []);

第一引数に関数、第二引数には依存配列を渡しています。依存配列については後程説明します。
基本的には、第一引数に渡した関数がコンポーネントが読み込まれたタイミングで呼ばれます。なので、ためしにリロードをしてみるとログが出力されますが、increment のボタンを押しても count のログは出力されていますが、useEffect とログには出力されていません。

依存配列の使い方

依存配列とは先ほど説明したuseEffectの第一引数に渡した関数をコンポーネントが読み込まれたタイミング以外にも発火させるための物です。
ためしに、今書いたコードにもう一つuseEffectを追加してみます。こちらには第二引数に count を入れておきます。

useEffect(() => {
  console.log("update");
}, [count]);

まずはリロードをしてログを確認すると rendering,0,update が出力されています。
次に、increment のボタンを押してみましょう。先ほど通り、count のログが出力されていますが、update のログも出力されていることが確認できます。
これは第二引数に指定した、count の値が変更されたため、関数が再度発火されているのです。

注意
useEffectの依存配列に持たせているステートの値を更新してはいけません。

useEffect(() => {
  console.log("update");
  setCount(count + 1);
}, [count]);

こうしてしまうと、

  1. setCount により count の値が変わる。
  2. count の値が変わったのでまた setCount が呼ばれる。
    といったように無限ループに陥ってしまいます。

クリーンアップ処理

クリーンアップ処理とはコンポーネントが消える際に行われる処理のことです。

import React, { useEffect, useState } from "react";

const Effect = () => {
  useEffect(() => {
    console.log("rendering");
    return () => {
      console.log("cleanup");
    };
  }, []);

  return <h1>Effect</h1>;
};

const Home = () => {
  const [isVisible, setIsVisible] = useState<boolean>(true);

  return (
    <>
      <button onClick={() => setIsVisible((prev) => !prev)}>
        {isVisible ? "非表示" : "表示"}
      </button>
      {isVisible && <Effect />}
    </>
  );
};

export default Home;

isVisibleが true のときだけ Effect コンポーネントを表示しています。
isVisibleが false になったタイミングがクリーンアップ処理の行われるタイミングです。

useEffect(() => {
  console.log("rendering");
  return () => {
    console.log("cleanup");
  };
}, []);

Effect コンポーネント内に、クリーンアップ処理を書いています。
ログを確認すると、まず、rendering のみが出力されており、非表示のボタンを押すと cleanup が出力されます。
また、表示のボタンを押すと、ログに rendering が出力されます。

useEffect の使いどころ

useEffectは使いどころが難しい Hook です。今回は、カウンターを紹介しますが、詳しくは公式のリファレンスに書かれています。

import React, { useEffect, useState } from "react";

const Effect = (props: {
  setCount: React.Dispatch<React.SetStateAction<number>>;
}) => {
  const { setCount } = props;
  useEffect(() => {
    const intervalId = setInterval(() => {
      setCount((c) => c + 1);
    }, 1000);
    return () => clearInterval(intervalId);
  }, []);

  return <h1>Effect</h1>;
};

const Home = () => {
  const [isVisible, setIsVisible] = useState<boolean>(true);
  const [count, setCount] = useState<number>(0);

  return (
    <>
      <button onClick={() => setIsVisible((prev) => !prev)}>
        {isVisible ? "非表示" : "表示"}
      </button>
      <button onClick={() => setCount(0)}>reset</button>
      <p>{count}</p>
      {isVisible && <Effect setCount={setCount} />}
    </>
  );
};

export default Home;

Home コンポーネントから setCount を渡し、Effect コンポーネント側で、1秒ごとにカウントをアップさせる処理を書いています。Effect コンポーネントが表示されている間は、カウントがアップしていますが、非表示にすると、カウントが止まります。
これは、クリーンアップ処理でclearInterval(intervalId)を実行しているためです。

useMemo

useMemoは計算結果を保持し、関係のないステートが更新され、コンポーネントが再レンダリングされても、再計算をせずに保持していた計算結果を返すための Hook です。
まずはuseMemoを使わなかった際の挙動を見てみましょう。

import React, { useState } from "react";

const Home = () => {
  const [number, setNumber] = useState<number>(10);
  const [text, setText] = useState<string>("");
  const calcNum = () => {
    console.log("長い処理");
    return number * 10;
  };

  return (
    <>
      <button onClick={() => setNumber(number + 1)}>increment</button>
      <p>number {number}</p>
      <p>calcNum {calcNum()}</p>
      <input
        type="text"
        className="text-black"
        value={text}
        onChange={(e) => setText(e.target.value)}
      />
    </>
  );
};

export default Home;

calcNum は number の値を 10 倍して返しています。「長い処理」とはその名の通り処理に長い時間がかかる処理のことです。今回はログで代用しています。
まず、increment のボタンを押してみると calcNum のログが出力されています。
number の値が更新されているので問題ありません。
しかし、input に文字を入力し、text のステートを更新しても、calcNum のログが出力されています。number と text はそれぞれ関係のないステートなのに、text を更新するたびに calcNum の長い処理が行われていてはパフォーマンスに影響が出てしまいます。
useMemoを使って書き換えてみましょう。

const calcNum = useMemo(() => {
  console.log("長い処理");
  return number * 10;
}, [number]);

calcNum をuseMemoでラップしました。
第一引数に関数を渡し、第二引数に依存配列を渡しており、useEffectと似ていますね。使い方に関してはほぼ同じです。
再度確認してみましょう。
increment のボタンを押し、number を更新した場合は、依存配列に指定しているので、処理が走ります。しかし、input に文字を入力したタイミングでは、calcNum のログが出力されません。
これにより、依存配列に含めていない変数が更新されても再度処理が走らないことがわかります。

useCallback

useCallbackuseMemoと使い方が同じですが、ラップする対象が違います。
useMemoは計算結果をメモ化するのに対し、useCallbackは関数自体をメモ化するために使用されます。

pages/index.tsx
import Increment from "@/components/Increment";
import React, { useCallback, useEffect, useState } from "react";

const Home = () => {
  const [count1, setCount1] = useState<number>(0);
  const [count2, setCount2] = useState<number>(0);

  const increment1 = useCallback(() => {
    setCount1(count1 + 1);
  }, [count1]);
  const increment2 = () => {
    setCount2(count2 + 1);
  };
  useEffect(() => {
    console.log("increment1が生成");
  }, [increment1]);
  useEffect(() => {
    console.log("increment2が生成");
  }, [increment2]);
  return (
    <div>
      <p>{count1}</p>
      <Increment label="count1" increment={increment1} />
      <p>{count2}</p>
      <Increment label="count2" increment={increment2} />
    </div>
  );
};

export default Home;
components/Increment.tsx
import React from "react";

const Increment = (props: { label: string; increment: () => void }) => {
  const { label, increment } = props;
  console.log(`${label} rendering`);

  return <button onClick={increment}>{label}</button>;
};

export default Increment;

Increment コンポーネントに Home コンポーネントで作成した、インクリメントするための関数を渡しています。
increment1 だけuseCallbackでラップをし、increment1 と increment2 が再生成されたことを確認するためにuseEffectを出ログを出すようにしています。
確認してみましょう。まずは、レンダリングされた際のログです。4つのログが出力されています。
画像
次に increment1 のボタンを押してみましょう。
画像
こちらも、4つのログが出力されています。
次に increment2 のボタンを押してみましょう。
画像
先ほどまでとは違い、「increment1 が生成」のログが出力されていません。
これで、再レンダリングされても、関数 increment1 が再生成されていないことが確認できました。

コンポーネントの再レンダリングも防ごう

ついでにコンポーネントの再レンダリングも防いでみましょう。関数 increment1 の関数が再生成されなかった場合、関数 increment1 を渡したコンポーネントも再レンダリングしてほしくないですよね。

components/Increment.tsx
import React from "react";

const Increment = (props: { label: string; increment: () => void }) => {
  const { label, increment } = props;
  console.log(`${label} rendering`);

  return <button onClick={increment}>{label}</button>;
};

export default React.memo(Increment);

最後の行で Increment をReact.memoでラップしています。Home コンポーネントの関数 increment2 はuseCallbackでラップしておきましょう。
これで、increment1 のボタンを押すと「increment1 rendering」と「increment1 が生成」のログのみが出力されるようになりました。

Custom Hook

最後はCustom Hookです。これは、今までに紹介した Hooks を利用した機能を複数のファイルで共有するための物です。

hooks/useFetch.ts
import { useState, useEffect } from "react";

function useFetch(url: string) {
  const [data, setData] = useState([]);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const fetchData = async () => {
      setIsLoading(true);
      setError(null);
      try {
        const response = await fetch(url);
        if (!response.ok) {
          throw new Error("エラーが発生しました");
        }
        const data = await response.json();
        setData(data);
      } catch (error) {
        setError(error);
      } finally {
        setIsLoading(false);
      }
    };

    fetchData();
  }, [url]);

  return { data, isLoading, error };
}

export default useFetch;

このコードは、API を叩いてデータを fetch した際のローディング状態やエラー状態を表現するための Hook です。このコードをデータを fetch したいすべてのファイルで書くのは面倒ですよね。
使う側を見てみましょう。

pages/index.tsx
import React from 'react';
import useFetch from './useFetch';

const Home = () => {
  const { data, isLoading, error } = useFetch('https://api.example.com/data');

  if (isLoading) return <div>ローディング中...</div>;
  if (error) return <div>エラー: {error.message}</div>;

  return (
    <div>
      <h1>データの一覧</h1>
      <ul>
        {data.map(item => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
    </div>
  );
}

export default Home;

このように、1行だけでデータを取得し、ローディング状態、エラー状態を取得できます。
Custom Hookは多くのパッケージで使用されており、React QueryuseQueryCustom Hookの一つです。

まとめ

7つのReact Hooksとそれらを組み合わせるCustom Hookを紹介しました。
これらの Hooks を理解し、適切に使い分けることで、React アプリケーションの開発が効率的で、メンテナンス性の高いものになります。

参考

https://react.dev/reference/react

Discussion