ISID テックブログ

ISIDが運営する技術ブログ

React Hooks を理解しよう

本記事は電通国際情報サービス Advent Calendar 2021の 13 日目の記事です。
執筆者は 2021 年新卒入社の XI 本部 AI トランスフォーメンションセンター所属の山田です。

はじめに

本記事では React Hooks の代表的なフックについて、その使い方とユースケースをサンプルコードとともに紹介します。 サンプルコードは TypeScript で記述しています。

React Hooks とは

React Hooks は React 16.8(2019 年 2 月リリース)で追加された機能です。 2021 年現在において React でアプリケーションを構築するためには理解が必須の機能といっても過言ではないでしょう。

React Hooks を使うことによって React の関数型コンポーネントで状態(state)を持つことやコンポーネントのライフサイクルに応じた処理を記述できます。

以下では、 React で提供される基本的な React Hooks をそのユースケースとともに紹介します。

  • useState
  • useEffect
  • useContext
  • useReducer
  • useMemo
  • useCallback

useState

useStateは関数型コンポーネントで状態(state)を扱うためのフックです。

以下はuseStateを利用する場合の基本的なコードです。

// 返り値はstateの変数とstateを更新するための関数
const [state, setState] = useState<T>(initStateValue);

useStateは状態(state)の変数と状態(state)を更新するための関数を返します。
状態(state)の更新をする際は必ず更新用の関数を介して行う必要があります。

useState を使うユースケース

useStateが必要となるのは、利用者とインタラクティブにやり取りをする値を保持する必要がある場合です。
利用者とインタラクティブにやり取りをするという場面の最も典型的な例はフォームです。

ここではログインフォームを題材にしてコードを紹介します。
作成するログインフォームは画像のように input 要素としてユーザー ID とパスワードを持つものを想定します。

初めにログインフォームで扱うデータの型(SampleLoginForm)を定義しておきます。 今回の例ではuserIdpasswordだけをプロパティに持つオブジェクトとします。

interface SampleLoginForm {
  userId: string;
  password: string;
}

作った SampleLoginForm 型の変数formDatauseStateを使って定義します。

const [formData, setFormData] = useState<SampleLoginForm>({
  userId: "",
  password: "",
});

あとは input 要素の value 属性に対応するformDataの変数を渡します。 さらに input 要素の onChange イベントからsetFormDataを呼び出してformDataの状態を更新します。

<div>
  <label htmlFor="userId">ユーザーID</label>
  <input
    id="userId"
    type="text"
    name="userId"
    placeholder="ユーザーID"
    value={formData.userId}
    onChange={(e) => setFormData({ ...formData, userId: e.target.value })}
  />
</div>

これにより、ユーザーがフォームに入力した文字(値)を変数formDataに保持できます。

▶︎ クリックしてコード全文を見る

// components/LoginForm.tsx
import React, { useState } from "react";

interface SampleLoginForm {
  userId: string;
  password: string;
}

export default function LoginForm(): JSX.Element {
  const [formData, setFormData] = useState<SampleLoginForm>({
    userId: "",
    password: "",
  });

  const submitHandler = (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    console.log("ログインボタン押下", formData);
  };

  return (
    <form onSubmit={submitHandler}>
      <div>
        <label htmlFor="userId">ユーザーID</label>
        <input
          id="userId"
          type="text"
          name="userId"
          placeholder="ユーザーID"
          value={formData.userId}
          onChange={(e) => setFormData({ ...formData, userId: e.target.value })}
        />
      </div>

      <div>
        <label htmlFor="password">パスワード</label>
        <input
          id="password"
          type="password"
          name="password"
          placeholder="パスワード"
          value={formData.password}
          onChange={(e) =>
            setFormData({ ...formData, password: e.target.value })
          }
        />
      </div>

      <div>
        <button type="submit">ログイン</button>
      </div>
    </form>
  );
}

useEffect

useEffectは関数型コンポーネントで副作用を実行するためのフックです。 副作用と聞くと仰々しいですが コンポーネント内での「外部データの取得」「DOM の手動での更新」などの処理を、React では副作用と呼びます。

useEffectを使うための基本的なコードは以下のとおりです。

// 副作用を含む処理を記述した関数を記述する
useEffect(() => {
  // 副作用処理
  // …
  return () => {
    // クリーンアップ処理
  };
}, []);

useEffectでは副作用となる処理を関数内で記述します。 returnで関数を返すことによってクリーンアップ処理を記述できます。
通常、useEffectによる副作用処理はコンポーネントレンダリング毎に実行されます。
副作用処理を毎回行わないためには、第2引数の依存配列によって制御できます。
useEffectの詳しい説明については以下の参考リンクをご覧ください。

副作用フックの利用法 https://ja.reactjs.org/docs/hooks-effect.html
useEffect完全ガイド https://overreacted.io/ja/a-complete-guide-to-useeffect/

useEffect を使うユースケース

useEffectが必要となる代表的なユースケースとしてはコンポーネントを呼び出したタイミングで外部 API からリソースを取得したい場合などです。

ここではサンプルの外部 API として JSONPlaceholder を利用してコンポーネントを呼び出したタイミングでデータを取得してみましょう。

JSONPlaceholder, https://jsonplaceholder.typicode.com/

少し JSONPlaceholder について補足します。 JSONPlaceholder は 6 種類の構造のダミーデータを取得できます。
今回はタスク管理アプリケーションで一般的な ToDo リスト形式のデータを取得します。 取得したデータは先ほど紹介したsetStateを使って保持します。

まず取得する ToDo リストの型を定義しておきます。

interface ToDo {
  id: number;
  userId: number;
  title: string;
  completed: boolean;
}

そして先ほどのuseStateフックを使って取得する ToDo リスト形式を状態管理します。

const [todoItemss, setToDos] = useState<ToDo[]>([]);

そしてuseEffectを使って実際に外部 API を呼び出し、状態を更新します。 外部 API の呼び出しにはfetchを利用します。

useEffect(() => {
  const f = async () => {
    const res: Response = await fetch(
      "https://jsonplaceholder.typicode.com/todos"
    );
    const json: ToDo[] = await res.json();
    setToDos(json);
  };
  f();
}, []);

注意点としてuseEffectに渡す関数は同期的です。 そのため非同期関数(async/await)を使うには関数内で定義する必要があります。
補足ですが、次期アップデートの React v18 よりReact.Suspenseを使った非同期のデータ取得がサポートされます。 アップデート後はこちらがベストプラクティスになっていく可能性も高いため、公式ドキュメントの「React.Suspense」と「サスペンスを使ったデータ取得」についても、ぜひチェックをしてみてください。

React の最上位 API - React.Suspense, https://ja.reactjs.org/docs/react-api.html#reactsuspense
サスペンスを使ったデータ取得(実験的機能), https://ja.reactjs.org/docs/concurrent-mode-suspense.html

取得した ToDo リスト形式のデータはスタイルを少し当ててArray.prototype.map()を使えば以下のように描画できます。

▶︎ クリックしてコード全文を見る

// components/ToDoList.tsx
import React, { useEffect, useState } from "react";

interface ToDo {
  id: number;
  userId: number;
  title: string;
  completed: boolean;
}

export default function ToDoList(): JSX.Element {
  const [todoItems, setToDos] = useState<ToDo[]>([]);

  useEffect(() => {
    const f = async () => {
      const res: Response = await fetch(
        "https://jsonplaceholder.typicode.com/todos"
      );
      const json: ToDo[] = await res.json();
      setToDos(json);
    };
    f();
  }, []);

  return (
    <div style={{ textAlign: "left" }}>
      {todoItems.map((todoItem) => (
        <div
          key={todoItem.id}
          style={{
            width: "250px",
            border: "solid",
            margin: "8px",
            padding: "8px",
          }}
        >
          <h4>{todoItem.title}</h4>
          <p style={{ textAlign: "right" }}>
            {todoItem.completed ? "✅ 完了" : "未実施"}
          </p>
        </div>
      ))}
    </div>
  );
}

useContext

useContextコンポーネント間で横断的に利用したい状態を管理するためのフックです。

通常、コンポーネントでは状態(データ)を props を通して親から子に渡します。 これを図に起こすと以下のようになります。

一方、コンテキストを使うと以下のように props を通さずにデータをやり取りできます。

コンテキストではContext.Providerコンポーネントを通して横断的に利用したい状態を配信します。
そして必要なコンポーネントuseContextを使うことによって状態を購読します。

useContextを使ってコンポーネント内でコンテキストから配信される値を購読する基本的なコードは以下のようになります。

// 返り値はコンテキストから配信される値
// useContextの第1引数には`React.createContext`によって作成したコンテキストオブジェクトを渡す
const value = useContext(MyContext);

useContextでは購読するコンテキストのオブジェクトを渡し、コンテキストから配信される値を受け取ります。

useContext を使うユースケース

ここまでで述べてきたようにuseContextを使うのはコンポーネント間で横断的に利用したい状態がある場面です。
代表的な場面として認証情報の管理などがあります。

ここではコンテキストを使ってユーザー ID を管理することを例に説明します。

管理するユーザー ID はuseStateを用いて宣言し、その状態と更新用の関数をコンテキストを使って配信します。

// コンテキストで配信する値
const [userId, setUserId] = useState<number>(-1);

配信する値が決まったので、コンテキストで配信する値の型を定義します。

interface Context {
  userId: number;
  setUserId: Dispatch<SetStateAction<number>>;
}

createContextを使ってコンテキストオブジェクトを作成します。型引数には先ほど定義した型を指定し、第 1 引数には初期値を与えます。

const AuthContext = createContext<Context>({
  userId: -1,
  setUserId: () => {},
});

次に Context の Provider を作成します。 Provider の value プロパティにコンテキストで配信する値を指定します。

const AuthProvider: React.FC = ({ children }) => {
  // コンテキストで配信する値
  const [userId, setUserId] = useState<number>(-1);

  return (
    <AuthContext.Provider value={{ userId, setUserId }}>
      {children}
    </AuthContext.Provider>
  );
};

// コンテキストオブジェクトとProviderをexportする
export { AuthContext, AuthProvider };

createContextで作成したAuthContextAuthProviderを外部に公開(export)することでコンテキストを利用しやすくしています。

▶︎ クリックしてコード全文を見る

// contexts/auth.tsx
import React, {
  createContext,
  Dispatch,
  SetStateAction,
  useState,
} from "react";

interface Context {
  userId: number;
  setUserId: Dispatch<SetStateAction<number>>;
}

const AuthContext = createContext<Context>({
  userId: -1,
  setUserId: () => {},
});

const AuthProvider: React.FC = ({ children }) => {
  const [userId, setUserId] = useState<number>(-1);

  return (
    <AuthContext.Provider value={{ userId, setUserId }}>
      {children}
    </AuthContext.Provider>
  );
};

// コンテキストオブジェクトとProviderをexportする
export { AuthContext, AuthProvider };

作成したAuthProviderApp.tsxに記述します。 これによりアプリケーション内のどのコンポーネントでもuseContextを使って AuthContext から値を購読できます。

// App.tsx
import React from "react";
import { AuthProvider } from "./contexts/auth";
import LoginForm from "./components/LoginForm";
import ToDoList from "./components/ToDoList";

export default function App(): JSX.Element {
  return (
    <AuthProvider>
      <div style={{ padding: "8px", textAlign: "center" }}>
        <LoginForm />
        <ToDoList />
      </div>
    </AuthProvider>
  );
}

実際にLoginFormToDoListコンポーネントでコンテキストを使ってみましょう。
まずLoginFormコンポーネント内でフォーム送信時にコンテキストのuserIdを更新してみます。

// AuthContextからuserIdを更新する関数setUserIdを購読
const { setUserId } = useContext(AuthContext);

// form要素のsubmitイベントを処理する関数
const submitHandler = (e: FormEvent<HTMLFormElement>) => {
  e.preventDefault();
  console.log("ログインボタン押下", formData);
  // AuthContextで配信される値userIdを更新
  setUserId(1);
};

次にToDoListコンポーネントでコンテキストからuserIdを購読します。 そしてuseEffectuserIdの状態を監視し、初期値(-1)でない場合に外部 API からリソースを取得するようにします。

// AuthContextからuserIdを購読
const { userId } = useContext(AuthContext);

useEffect(() => {
  const f = async () => {
    const res: Response = await fetch(
      "https://jsonplaceholder.typicode.com/todos"
    );
    const json: ToDo[] = await res.json();
    setToDos(json);
  };
  // userId が初期値でない場合に外部APIコール
  if (userId !== -1) {
    f();
  }
}, [userId]);

以上でログインフォームのログインボタンを押下することで AuthContext の userIdを更新し、その変更を検知して ToDo リストの情報を外部 API から取得する処理が実現できます。

useReducer

useReduceruseStateよりも複雑な状態を管理するためのフックです。
公式ドキュメントでは「useStateの代替品」として位置づけられています。

useReducerを使うための基本的なコードは以下のとおりです。

// `useState`の代替品。返り値はstateの変数とstateを更新するためのDispatch関数
const [state, dispatch] = useReducer(reducer, initialArg, init);

useReducerを理解するためには 4 つの要素を理解する必要があります。

  • State … 状態
  • Reducer … State を更新するための関数
  • Action … State を更新するのに必要なデータ
  • Dispatch … Action を Reducer に届ける関数

この 4 つの要素は図のような関係になります。

useReducer を使うユースケース

アプリケーション開発を進めていくと処理が複雑になるにつれて、管理しなければならない状態(state)が増えていきます。 また実際には、相互に関連する状態を更新しなければならない場面も増えます。 そのような場面で力を発揮するのがuseReducerフックです。

例えば、先ほどのuseEffectフックでを使った外部 API からのリソース取得を例に考えてみましょう。

外部リソースの取得では取得までに時間を要しますので読み込み中か否かをisLoadingのような形で状態管理する必要があるでしょう。 さらにデータ取得時のエラーハンドリングを考えるとエラーが発生したかをerrorのような変数で状態管理する必要があります。

これらをuseStateフックで管理する場合は以下のようになります。

const [todos, setToDos] = useState<ToDo[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<boolean>(false);

このように複数の値に関連する状態を管理する場面でuseReducerを使うことを考えます。

まずuseReducerで管理する状態の型とその状態の初期値を定義します。

// 管理する状態の型
interface State {
  todoItems: ToDo[];
  isLoading: boolean;
  error: boolean;
}

// 状態の初期値
const initState: State = {
  todoItems: [],
  isLoading: true,
  error: false,
};

次に状態を更新するためのデータとなるアクションの型を定義します。 今回は状態を更新する操作として以下の 2 種類を考えます。

  • SET_TODOS … ToDo リストにアイテムをセットする操作。アクションは ToDo リストにセットするデータを含む。
  • SET_ERROR … エラーが発生した際にエラーフラグをTrueにする操作。アクションはデータを持たない。

これらを型に起こします。

// アクションの種類
type ActionType = "SET_TODOS" | "SET_ERROR";

// アクションの型
interface Action {
  type: ActionType;
  payload?: ToDo[];
}

上で定義した型を使ってreducer関数を作成します。

import { Reducer } from "react";

const reducer: Reducer<State, Action> = (state, action) => {
  switch (action.type) {
    case "SET_TODOS":
      if (!action.payload) {
          // payloadが含まれていなければエラー扱いにする
          return {
            ...state,
            error: true,
            isLoading: false,
           };
      }
      return {
        ...state,
        ...action.payload,
        isLoading: false,
      };
    case "SET_ERROR":
      return {
        ...state,
        error: true,
        isLoading: false,
      };
  }
};

このreducer関数と状態の初期値を使ってuseReducerを宣言します。

const [{ todoItems, error, isLoading }, dispatch] = useReducer(
  reducer,
  initState
);

そして先程のuseEffect内で状態を更新していた部分をdispatchにアクションを渡すことで状態を更新するように書き換えます。

useEffect(() => {
  const f = async () => {
    try {
      const res: Response = await fetch(
        "https://jsonplaceholder.typicode.com/todos"
      );
      const json: ToDo[] = await res.json();
      dispatch({ type: "SET_TODOS", payload: { todoItems: json } });
    } catch (e) {
      console.log(e);
      dispatch({ type: "SET_ERROR" });
    }
  };
  f();
}, []);

この例だともともとがそこまで複雑な状態管理ではなかったため、useReducerを使った記述が冗長だと感じるかもしれません。 どのタイミングでuseReducerを使うのかは、個人/チーム次第ではありますが、うまく使うことで状態管理をわかりやすくできます。

▶︎ クリックしてコード全文を見る

// components/ToDoList.tsx
import React, { Reducer, useEffect, useReducer } from "react";

interface ToDo {
  id: number;
  userId: number;
  title: string;
  completed: boolean;
}

interface State {
  todoItems: ToDo[];
  isLoading: boolean;
  error: boolean;
}

const initState: State = {
  todoItems: [],
  isLoading: true,
  error: false,
};

type ActionType = "SET_TODOS" | "SET_ERROR";

interface Action {
  type: ActionType;
  payload?: Partial<State>;
}

const reducer: Reducer<State, Action> = (state, action) => {
  switch (action.type) {
    case "SET_TODOS":
      if (!action.payload) {
          // payloadが含まれていなければエラー扱いにする
          return {
            ...state,
            error: true,
            isLoading: false,
           };
      }
      return {
        ...state,
        ...action.payload.todoItems,
        isLoading: false,
      };
    case "SET_ERROR":
      return {
        ...state,
        error: true,
        isLoading: false,
      };
  }
};

export default function ToDoList(): JSX.Element {
  const [{ todoItems, error, isLoading }, dispatch] = useReducer(
    reducer,
    initState
  );

  useEffect(() => {
    const f = async () => {
      try {
        const res: Response = await fetch(
          "https://jsonplaceholder.typicode.com/todos"
        );
        const json: ToDo[] = await res.json();
        dispatch({ type: "SET_TODOS", payload: { todoItems: json } });
      } catch (e) {
        console.log(e);
        dispatch({ type: "SET_ERROR" });
      }
    };
    f();
  }, []);

  return (
    <>
      {isLoading ? (
        <p>ロード中です…</p>
      ) : error ? (
        <p>エラーが発生しました。</p>
      ) : (
        <div style={{ textAlign: "left" }}>
          {todoItems.map((todoItem) => (
            <div
              key={todoItem.id}
              style={{
                width: "250px",
                border: "solid",
                margin: "8px",
                padding: "8px",
              }}
            >
              <h4>{todoItem.title}</h4>
              <p style={{ textAlign: "right" }}>
                {todoItem.completed ? "✅ 完了" : "未実施"}
              </p>
            </div>
          ))}
        </div>
      )}
    </>
  );
}

useMemo

useMemoは関数の返り値をメモ化するフックです。
メモ化はプログラムの最適化技法の 1 つで、計算結果を再利用するために保持して、再計算を防ぐものです。
そのためuseMemoは最適化のためのフックという位置付けです。

useMemoを使うための基本的なコードは以下のとおりです。

// 返り値は関数の計算結果をメモ化した値
// 第2引数の依存配列に含まれる値が変更された時に再計算される
const memoizedValue = useMemo<T>(() => computeExpensiveValue(a, b), [a, b]);

useMemo を使うユースケース

基本的には最適化のためのフックですが、例えば配列を保持するstateで配列を走査する処理が頻繁に必要な場合などに役立ちます。

ToDo リストの例で、一覧から完了済みのアイテムをuseMemoによって取得することを考えてみましょう。

const [todoItems, setToDos] = useState<ToDo[]>([]);

const completedItems = useMemo<ToDo[]>(() => {
  return todoItems.filter((todoItem) => todoItem.completed);
}, [todos]);

useMemoでは依存配列に渡されたstateが更新された時にメモ化していた値を再計算します。

useCallback

useCallbackは関数をメモ化するフックです。
useCallbackは最適化のためのフックという位置付けです。
そしてuseCallbackを利用する場合は、基本的にReact.memoと併用する必要があります。

React.memo と useCallback

useCallbackの話をする前に、React.memoについて簡単に説明します。

React の最上位 API - React.memo, https://ja.reactjs.org/docs/react-api.html#reactmemo

すでに述べた通り、React では親コンポーネントから子コンポーネントに props を通してデータを渡します。

通常では、図中の点線で示した子コンポーネントは親コンポーネントが再描画されるタイミングで常に再描画されます。 React.memoはこの親コンポーネントが再描画されるタイミングでの子コンポーネントの再描画を最適化するものです。 React.memoでは子コンポーネントにおいて、親コンポーネントから受け取る props が再描画前の props と等価であれば、再描画をスキップします。つまり親コンポーネントから子コンポーネントに渡す props とその等価性が重要になります。useCallbackは props に渡す関数が等価であることを保証するためのフックです。

useCallbackを使うための基本的なコードは以下のとおりです。

// 返り値はメモ化された関数
// 第2引数の依存配列に含まれる値が変更された時に再計算される
const memoizedCallback = useCallback(() => {
  doSomething(a, b);
}, [a, b]);

これにより子コンポーネントでは、props 受け取った関数がuseCallbackの第2引数の依存配列に含まれる値が変更されていない限りは等価なものとして扱えます。

useCallback を使うユースケース

ここまで説明した通り、useCallbackは最適化の流れで、子コンポーネントの props に関数を渡す必要が生じた際に利用します。
React.memoは使用しませんが、props に関数を渡す場面を先ほどのログインフォームの例で見てみましょう。

まずフォームの状態をuseStateを使って定義していました。

const [formData, setFormData] = useState<SampleLoginForm>({
  userId: "",
  password: "",
});

そしてinput要素のonChangeプロパティに関数を記述しformDataの値を更新していました。

<input
  id="userId"
  type="text"
  name="userId"
  placeholder="ユーザーID"
  value={formData.userId}
  onChange={(e) => setFormData({ ...formData, userId: e.target.value })}
/>

このonChangeプロパティに渡す関数をuseCallbackで記述すると以下のようになります。

// inputタグのonChangeイベントを処理する関数
const onChangeHandler = useCallback((e: ChangeEvent<HTMLInputElement>) => {
  setFormData((prev: SampleLoginForm) => {
    return { ...prev, [e.target.name]: e.target.value };
  });
}, []);

useCallbackでは第 2 引数の依存配列に含まれる値が変更されたタイミングで再度メモ化されるため、依存配列に含まれる値が少なくなるように意識する必要があります。 この例では、更新時にformDataを参照せず、setFormData関数内で直前のformDataの値を受けることよって依存配列が空になるようにしています。
これによりonChangeHandler関数はメモ化が働き、React.memoと併用した最適化ができます。

React Hooks を正しく使うために

フックは一見すると JavaScript の関数ですが、正しく使う際には、ルールに従う必要があります。
特にuseEffectuseMemouseCallbackといった依存配列を含むフックの使用では、依存関係の漏れによってバグを混入する恐れがあります。
フックを正しく利用するために、ESLint のeslint-plugin-react-hooksプラグインを導入しておくことがお勧めです。
exhaustive-depsルールを有効にすれば、依存配列が正しく記述されていない場合に警告を出すこともできます。

eslint-plugin-react-hooks https://www.npmjs.com/package/eslint-plugin-react-hooks

おわりに

本記事では、React で提供される基本的な React Hooks をそのユースケースとともに紹介しました。 ここでは紹介できなかった React Hooks やカスタムフック、テスト方法なども今後、紹介できればと思います。

明日(12/14)は Toshihiro Nakamura さんから「Kotlinでデータベースアクセス」の記事が公開される予定です。 そちらもぜひご覧ください。

執筆:@yamada.y、レビュー:@sato.taichiShodoで執筆されました