プログラミング

【React】Context APIの使い方

この記事は約20分で読めます。

作成してきたメモアプリにグローバル状態管理を導入します。


これまでの学習内容については以下の記事にまとめています。

グローバルな状態管理の利点

それぞれのコンポーネントは状態という「データ」を持つことができます。
しかし、コンポーネントが複雑になるにつれて状態管理も複雑になります。
特に、propsの「バケツリレー」はコードの可読性とメンテナンス性を低下させます。

この課題を解決するために登場したのがグローバルな状態管理です。
クラウドのように一元管理することでコンポーネントが直接データを取得できます。
これによりpropsを何層も深く渡す必要がなくなり、可読性とメンテナンス性が向上します。

Reactの状態管理を行う代表的なものとしてReduxContext APIが存在します。
この記事ではこちらの記事を参考に、アプリケーションの規模が小さい場合にオススメのContext APIを使用します。

Context APIの使い方

では、作成中のメモアプリに対してContext APIを導入していきます。
メモアプリの作成過程についてはこちらにまとめてあります。

Context APIはReactに標準で含まれているので別途インストールする必要はありません。

Context APIを使用するために行う変更は以下の3つです。

  1. ContextProviderコンポーネントを作成する
  2. Contextを使用する範囲をContextProviderで囲う
  3. useContextでContextを取得する

ContextProviderコンポーネントを作成する

ContextProviderコンポーネントは、propsで渡していたデータや関数を定義して提供します。
元々MemoContainerにあったstateや関数を引っ越しします。

まず、src/context/MemoContext.tsxファイルを作成し、以下のコードを追加します。

src/context/MemoContext.tsx
import { ReactNode, createContext, useContext, useEffect, useRef, useState } from "react";
import { Memo } from "../Types";

// Contextオブジェクトの型
interface MemoContextProps {
  originalMemos: Memo[];
  currentMemos: Memo[];
  newMemo: Memo;
  setNewMemo: React.Dispatch<React.SetStateAction<Memo>>;
  textareaRefs: React.MutableRefObject<HTMLTextAreaElement[]>;
  fetchMemos: () => void;
  handleMemoUpdate: (targetMemo: Memo) => void;
  handleMemoDelete: (id: number) => void;
  handleMemoEdit: (e: React.ChangeEvent<HTMLTextAreaElement>, id: number) => void;
  handleRowClick: (tableId: number) => void;
  handleKeyDown: (e: React.KeyboardEvent, targetMemo: Memo, tableId: number) => void;
}

const MemoContext = createContext<MemoContextProps | undefined>(undefined);

const MemoProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
  const [originalMemos, setOriginalMemos] = useState<Memo[]>([]); // データベースから取得したメモの一覧
  const [currentMemos, setCurrentMemos] = useState<Memo[]>([]); // フロントエンドで変更したメモの一覧
  const initialMemo: Memo = { id: -1, content: "", createdAt: "", updatedAt: "" };
  const [newMemo, setNewMemo] = useState<Memo>(initialMemo); // 新規メモ
  const textareaRefs = useRef<HTMLTextAreaElement[]>([]); // 対象のテキストエリアにfocusするために使用

  useEffect(() => {
    fetchAndSetMemos();
  }, []);

  // GET:メモ一覧を取得
  const fetchMemos = async () => {
    try {
      const response = await fetch(`${process.env.REACT_APP_API_BASE_URL}/api/memos`);
      if (!response.ok) throw new Error("Failed to fetch memos");
      const memos = await response.json();
      return memos;
    } catch (error) {
      console.error("Error fetching memos:", error);
    }
  };

  const fetchAndSetMemos = async () => {
    const memos = await fetchMemos();
    setOriginalMemos(memos);
    setCurrentMemos(JSON.parse(JSON.stringify(memos)));
  };

  // PUT・POST:メモを更新・作成
  const handleMemoUpdate = async (targetMemo: Memo) => {
    if (targetMemo?.content.trim() === "") {
      setCurrentMemos(originalMemos);
      return;
    }

    const url = targetMemo.createdAt
      ? `${process.env.REACT_APP_API_BASE_URL}/api/memos/${targetMemo.id}` // PUT
      : `${process.env.REACT_APP_API_BASE_URL}/api/memos`; // POST
    const method = targetMemo.createdAt ? "PUT" : "POST";

    try {
      const response = await fetch(url, {
        method,
        headers: { "Content-Type": "application/json" },
        body: targetMemo.content,
      });
      if (!response.ok)
        throw new Error(`Failed to ${method === "PUT" ? "update" : "add"} memo`);
      setNewMemo(initialMemo);
      fetchAndSetMemos();
    } catch (error) {
      console.error(`Error ${method === "PUT" ? "updating" : "adding"} memo:`, error);
    }
  };

  // DELETE:メモを削除
  const handleMemoDelete = async (id: number) => {
    try {
      const response = await fetch(
        `${process.env.REACT_APP_API_BASE_URL}/api/memos/${id}`,
        {
          method: "DELETE",
        }
      );
      if (!response.ok) throw new Error(`Failed to delete memos id: ${id}`);
      fetchAndSetMemos();
    } catch (error) {
      console.error("Error deleting memo:", error);
    }
  };

  const handleMemoEdit = (e: React.ChangeEvent<HTMLTextAreaElement>, id: number) => {
    // 編集中の内容を更新
    setCurrentMemos((prevMemos) =>
      prevMemos.map((memo) =>
        memo.id === id ? { ...memo, content: e.target.value } : memo
      )
    );
  };

  // 列のどこかをクリックしたら、対象のテキストエリアにfocusする
  const handleRowClick = (tableId: number) => {
    const textarea = textareaRefs.current[tableId];
    if (textarea) {
      textarea.focus();
      textarea.setSelectionRange(textarea.value.length, textarea.value.length);
    }
  };

  // キーを押したときの処理
  const handleKeyDown = (e: React.KeyboardEvent, targetMemo: Memo, tableId: number) => {
    if (e.key === "Enter" && !e.shiftKey && e.keyCode !== 229) {
      // 変換確定(229)を除くEnterキー押下時の処理
      e.preventDefault();
      handleMemoUpdate(targetMemo);
      if (tableId < currentMemos.length) {
        handleRowClick(tableId + 1); // 次のメモに移動(あれば)
      }
    } else if (e.key === "Escape") {
      // escキーが押された場合の処理
      const textarea = textareaRefs.current[tableId];
      if (textarea) textarea.blur();
    }
    // Enterのデフォルト(改行)をキャンセルしているので自動的にshift+Enterで改行される
  };

  return (
    <MemoContext.Provider
      value={{
        originalMemos,
        currentMemos,
        newMemo,
        setNewMemo,
        textareaRefs,
        fetchMemos,
        handleMemoUpdate,
        handleMemoDelete,
        handleMemoEdit,
        handleRowClick,
        handleKeyDown,
      }}
    >
      {children}
    </MemoContext.Provider>
  );
};

// useMemoContextフックを追加
const useMemoContext = () => {
  const context = useContext(MemoContext);
  if (!context) {
    throw new Error("useMemoContext must be used within a MemoProvider");
  }
  return context;
};

export {MemoProvider, useMemoContext};
【useMemoContext】
useContextをカプセル化することで、コンテキストの詳細な実装を隠蔽し、コンポーネント間での状態管理がより一貫して安全に行えます。
このアプローチにより、コンテキストの利用者はuseMemoContextフックを介してのみコンテキストの値や関数にアクセスでき、直接MemoContextオブジェクトに依存することを防ぎます。これにより、アプリケーションの構造がよりクリーンで保守しやすくなります。

Contextを使用する範囲をContextProviderで囲う

次に、MemoProviderでアプリケーション全体をラップします。
これにより、MemoProvider内で定義されたContextがアプリ全体で利用可能になります。

src/App.tsx
import React from "react";
import "./App.css";
import MemoContainer from "./components/MemoContainer/MemoContainer";
import { MemoProvider } from "./context/MemoContext";

const App: React.VFC = () => {
  return (
    <div className="App">
      <h1>Simple Memo</h1>
      <MemoProvider>
        <MemoContainer />
      </MemoProvider>
    </div>
  );
};

export default App;

useContextでContextを取得する

コンポーネントでContextの値を使用するには、useMemoContextフックを作成し、それを使用します。

currentMemos(フロントエンドで変更したメモの一覧)のみ取得します。

src/components/MemoContainer/MemoTable.tsx
import React from "react";
import "../../App.css";
import MemoRow from "./MemoRow";
import { useMemoContext } from "../../context/MemoContext";

const MemoTable: React.VFC = () => {
  const {currentMemos} = useMemoContext();
  return (
    <>
      {currentMemos.map((memo, tableId) => (
        <MemoRow
          memo={memo}
          tableId={tableId}
          key={memo.id}
        />
      ))}
    </>
  );
};

export default MemoTable;

メモの更新や削除、編集に必要な関数をContextから取得します。
親コンポーネントのMemoTableからループの要素であるmemoとtableIdをpropsで受け取ります。

アプリケーション全体で共有しているものか、propsで親コンポーネント関連のものか分かりやすいです。
src/components/MemoContainer/MemoRow.tsx
import React from "react";
import { MemoRowProps } from "../../Types";
import "../../App.css";
import DeleteIcon from "@mui/icons-material/Delete";
import { useMemoContext } from "../../context/MemoContext";

const MemoRow: React.VFC<MemoRowProps> = ({
  memo,
  tableId,
}) => {
  const {
    handleMemoUpdate,
    handleMemoDelete,
    handleMemoEdit,
    handleRowClick,
    handleKeyDown,
    textareaRefs,
  } = useMemoContext();
  return (
    <tr className="memo__row" key={memo.id} onClick={() => handleRowClick(tableId)}>
      <td>
        <textarea
          className="memo__textarea"
          value={memo.content}
          onChange={(e) => {
            handleMemoEdit(e, memo.id);
          }}
          onBlur={() => handleMemoUpdate(memo)}
          ref={(textareaDOM) => {
            if (textareaDOM) {
              textareaRefs.current[tableId] = textareaDOM;
            }
          }}
          onKeyDown={(e) => handleKeyDown(e, memo, tableId)}
        />
      </td>
      <td className="memo__updatedAt">{memo.updatedAt}</td>
      <td>
        <button className="memo__delete" onClick={() => handleMemoDelete(memo.id)}>
          <DeleteIcon />
        </button>
      </td>
    </tr>
  );
};

export default MemoRow;

メモの新規追加に必要な関数をContextから取得します。

src/components/MemoContainer/NewMemoRow.tsx
import React from "react";
import "../../App.css";
import { useMemoContext } from "../../context/MemoContext";

const NewMemoRow: React.VFC = () => {
  const {
    currentMemos,
    newMemo,
    setNewMemo,
    handleMemoUpdate,
    handleRowClick,
    handleKeyDown,
    textareaRefs,
  } = useMemoContext();
  return (
    <tr
      className="memo__row"
      onClick={() => handleRowClick(currentMemos.length)}
      data-testid="new-memo-row"
    >
      <td>
        <textarea
          className="memo__textarea"
          value={newMemo.content}
          placeholder="Create Your Memo"
          onChange={(e) =>
            setNewMemo({
              id: -1,
              content: e.target.value,
              createdAt: "",
              updatedAt: "",
            })
          }
          onBlur={() => handleMemoUpdate(newMemo!)}
          ref={(textareaDOM) => {
            if (textareaDOM) {
              textareaRefs.current[currentMemos.length] = textareaDOM;
            }
          }}
          onKeyDown={(e) => handleKeyDown(e, newMemo, currentMemos.length)}
          data-testid="new-memo-textarea"
        />
      </td>
      <td className="memo__updatedAt"></td>
      <td>
        <button className="memo__delete"></button>
      </td>
    </tr>
  );
};

export default NewMemoRow;

また、Contextを取得するにあたって、propsで受け取るデータが減ります。
TypeScriptを使用している場合、Propsの型もコンパクトにすることができます。

src/Types.ts
export interface Memo {
  id: number;
  content: string;
  createdAt: string;
  updatedAt: string;
}

// interface MemoTableProps 不要なので削除

// interface NewMemoRowProps 不要なので削除

export interface MemoRowProps {
  memo: Memo;
  tableId: number;
}

まとめ

今回はグローバルな状態管理をするためのContext APIを導入しました。

propsのバケツリレーを解消できるほか、アプリケーション全体で共有しているものか、propsで親コンポーネント関連のものか分かりやすく感じました。
Contextで取得するものとpropsで取得するものを使い分けることで読みやすいコードになりそうです。

参考文献

React Context APIの使い方 - Qiita
1. この記事についてReact公式の状態管理機能であるContext APIの使い方メモですReactとRemixでの利用について書きます2. Context APIとはprop dril…
React Context APIの概念と使い方 - Qiita
業務のコードでContextが使われているので勉強しようと思いましたが、公式ドキュメントはクラスコンポーネントを知っていないとなかなか難しいものでした。色々試して、業務でもさわって、大分Conte…

コメント