プログラミング

【React/TypeScript】コンポーネント指向【ステップ解説】

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

前回まで最低限のアプリケーションをAWSにデプロイする内容で更新してきましたが、今回はフロントエンドのトピックです。

今回は、実際のプログラムを使ってコンポーネントの整理を行ってみたいと思います。

手順はReact公式「React の流儀」を参考にしています。
React の流儀 – React
The library for web and native user interfaces

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

UIをコンポーネントに分割する 

以下の画面を対象にします。

分かりやすくするためにスタイルを外します
(ゴミ箱アイコンも文字に変えています)

この画面を使ってコンポーネントを考えていきます。
コンポーネントは、画面を1つの機能単位に分解したものと表現できるため、今回の例では以下のように分解しました。

コンポーネントが特定できたら、階層構造に整理します。
この階層構造は、実装においても同様の階層構造で配置します。

コンポーネント構造を実装する

既に作成済みのコードを再利用しながらコンポーネントに分けていきます。

まずは、JSXの静的な構造のみ実装します。
データはprops経由で子コンポーネントに渡します。

propsは親から子へとデータを渡す手段です。
データは1方向で流れるため、propsで渡すだけでは子コンポーネントで変更はできません。
src/App.tsx
import { Memo } from "./Types";

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

const MemoContainer: React.VFC<{ memos: Memo[] }> = ({memos}) => {
  return (
    <table className="memo__table">
      <tbody>
        <MemoTable memos={memos} />
        <NewMemoRow />
      </tbody>
    </table>
  );
};

const MemoTable: React.VFC<{ memos: Memo[] }> = ({memos}) => {
  return (
    <>
      {memos.map((memo, tableId) => (
        <MemoRow memo={memo} key={memo.id} />
      ))}
    </>
  );
};

const MemoRow: React.VFC<{ memo: Memo }> = ({ memo }) => {
  return (
    <tr className="memo__row" key={memo.id}>
      <td>
        <textarea className="memo__textarea" value={memo.content} />
      </td>
      <td className="memo__updatedAt">{memo.updatedAt}</td>
      <td>
        <button className="memo__delete">削除</button>
      </td>
    </tr>
  );
};

const NewMemoRow: React.VFC = () => {
  return (
    <tr className="memo__row">
      <td>
        <textarea className="memo__textarea" />
      </td>
      <td className="memo__updatedAt"></td>
      <td>
        <button className="memo__delete"></button>
      </td>
    </tr>
  );
};

const memos = [
  {
    id: 1,
    content: "サンプル1",
    createdAt: "2024-06-07T07:46:36",
    updatedAt: "2024-06-07T07:46:36",
  },
  {
    id: 2,
    content: "サンプル2",
    createdAt: "2024-06-07T08:02:53",
    updatedAt: "2024-06-07T08:02:53",
  },
];

export default App;

src/App.tsx
const App: React.VFC = () => {
  return (
    <div className="App">
      <h1>Simple Memo</h1>
      <table className="memo__table">
        <tbody>
          {/*.メモの一覧を表示 */}
          {currentMemos.map((memo, tableId) => (
            <tr className="memo__row" key={memo.id}>
              <td>
                <textarea className="memo__textarea" value={memo.content} />
              </td>
              <td className="memo__updatedAt">{memo.updatedAt}</td>
              <td>
                <button className="memo__delete">
                  削除
                </button>
              </td>
            </tr>
          ))}
          {/* 新規追加フォーム */}
          <tr className="memo__row">
            <td>
              <textarea className="memo__textarea" />
            </td>
            <td className="memo__updatedAt"></td>
            <td>
              <button className="memo__delete"></button>
            </td>
          </tr>
        </tbody>
      </table>
    </div>
  );
};

const currentMemos = [
  {
    id: 1,
    content: "サンプル1",
    createdAt: "2024-06-07T07:46:36",
    updatedAt: "2024-06-07T07:46:36",
  },
  {
    id: 2,
    content: "サンプル2",
    createdAt: "2024-06-07T08:02:53",
    updatedAt: "2024-06-07T08:02:53",
  },
];

export default App;

propsで渡すデータの流れは以下のようになっています

stateに保持するデータを決める

アプリケーションで使われるデータからstateで保持すべきデータを絞り込みます。
stateは各コンポーネントが持つデータなので必要最低限にすることが推奨されています。

React公式「React の流儀」ではstateの見分け方を以下のように紹介しています。(ユニークですね)

  • 時間が経っても変わらないものですか? そうであれば、state ではありません。
  • 親から props 経由で渡されるものですか? そうであれば、state ではありません。
  • コンポーネント内にある既存の state や props に基づいて計算可能なデータですか? そうであれば、それは絶対に state ではありません!

残ったものがおそらく state です。

今回使用するデータは以下の3つです。

  • originalMemos
    • データベースから取得したメモの一覧
    • API通信すべきか判断するための差分取得に使用
  • currentMemos
    • フロントエンドで変更したメモの一覧
    • 表示しているメモの内容を格納
  • newMemo
    • 新規追加するメモの詳細
    • 一時的に保持するために使用

メモの内容は更新されるため3つともstateとして保持しても問題ないと考えられます。

state を保持すべき場所を特定する

次は各stateをどのコンポーネントで所有するかを決めます。

stateは、stateを利用するすべてのコンポーネントの共通の親に所有します。

stateが利用されるコンポーネントは以下のようになります。

したがって、3つのstateはMemoContainerが所有することにします。

src/App.tsx
import { useState } from "react";
import { Memo } from "./Types";

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

const MemoContainer: React.VFC = () => {
  const [originalMemos, setOriginalMemos] = useState<Memo[]>(memos); // データベースから取得したメモの一覧
  const [currentMemos, setCurrentMemos] = useState<Memo[]>(memos); // フロントエンドで変更したメモの一覧
  const initialMemo: Memo = {
    id: -1,
    content: "",
    createdAt: "",
    updatedAt: "",
  };
  const [newMemo, setNewMemo] = useState<Memo>(initialMemo); // 新規メモ

  return (
    <table className="memo__table">
      <tbody>
        <MemoTable currentMemos={currentMemos} />
        <NewMemoRow newMemo={newMemo} />
      </tbody>
    </table>
  );
};

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

const MemoRow: React.VFC<{ memo: Memo }> = ({ memo }) => {
  return (
    <tr className="memo__row" key={memo.id}>
      <td>
        <textarea className="memo__textarea" value={memo.content} />
      </td>
      <td className="memo__updatedAt">{memo.updatedAt}</td>
      <td>
        <button className="memo__delete">削除</button>
      </td>
    </tr>
  );
};

const NewMemoRow: React.VFC<{ newMemo: Memo }> = ({ newMemo }) => {
  return (
    <tr className="memo__row">
      <td>
        <textarea className="memo__textarea" value={newMemo.content} />
      </td>
      <td className="memo__updatedAt"></td>
      <td>
        <button className="memo__delete"></button>
      </td>
    </tr>
  );
};

const memos = [
  {
    id: 1,
    content: "サンプル1",
    createdAt: "2024-06-07T07:46:36",
    updatedAt: "2024-06-07T07:46:36",
  },
  {
    id: 2,
    content: "サンプル2",
    createdAt: "2024-06-07T08:02:53",
    updatedAt: "2024-06-07T08:02:53",
  },
];

export default App;

逆方向の処理を記述する

ここまでで、親コンポーネントから子コンポーネントへのデータの受け渡しが完了しました。
最後に、子コンポーネントから親コンポーネントへのデータの受け渡しとバックエンドとの処理を実装します。

子コンポーネントで発生した処理を親コンポーネントに伝播させ、バックエンドとの通信を行います。
(ここでファイルも分割します。)

src/App.tsx
import React from "react";
import "./App.css";
import MemoContainer from "./components/MemoContainer/MemoContainer";

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

export default App;
src/components/MemoContainer/MemoContainer.tsx
import React, { useEffect, useRef, useState } from "react";
import { Memo } from "../../Types";
import "../../App.css";
import MemoTable from "./MemoTable";
import NewMemoRow from "./NewMemoRow";

const MemoContainer: React.VFC = () => {
  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(() => {
    fetchMemos();
  }, []);

  // 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();
      setOriginalMemos(memos);
      setCurrentMemos(JSON.parse(JSON.stringify(memos)));
    } catch (error) {
      console.error("Error fetching memos:", error);
    }
  };

  // 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);
      fetchMemos();
    } catch (error) {
      console.error(`Error ${method === "PUT" ? "updating" : "adding"} memo:`, error);
    }
  };

  // DELETE:メモを削除
  const handleMemoDelete = async (id: number) => {
    console.log(`${process.env.REACT_APP_API_BASE_URL}/api/memos/${id}`);
    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}`);
      fetchMemos();
    } 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 (
    <table className="memo__table">
      <tbody>
        <MemoTable
          currentMemos={currentMemos}
          handleRowClick={handleRowClick}
          handleMemoUpdate={handleMemoUpdate}
          handleKeyDown={handleKeyDown}
          handleMemoDelete={handleMemoDelete}
          handleMemoEdit={handleMemoEdit}
          textareaRefs={textareaRefs}
        />
        <NewMemoRow
          newMemo={newMemo}
          handleRowClick={handleRowClick}
          currentMemos={currentMemos}
          setNewMemo={setNewMemo}
          handleMemoUpdate={handleMemoUpdate}
          handleKeyDown={handleKeyDown}
          textareaRefs={textareaRefs}
        />
      </tbody>
    </table>
  );
};

export default MemoContainer;

MemoContainerは共通の親コンポーネントなので処理部分が多くなっています。
以降の子コンポーネントの処理をすべて定義しています。

処理の内訳は、API通信用のGET/POST・PUT/DELETEの3つと状態更新用が1つ、操作系が2つです。

src/components/MemoContainer/MemoTable.tsx
import React from "react";
import { MemoTableProps } from "../../Types";
import "../../App.css";
import MemoRow from "./MemoRow";

const MemoTable: React.VFC<MemoTableProps> = ({
  currentMemos,
  handleRowClick,
  handleMemoUpdate,
  handleKeyDown,
  handleMemoDelete,
  handleMemoEdit,
  textareaRefs,
}) => {
  return (
    <>
      {currentMemos.map((memo, tableId) => (
        <MemoRow
          memo={memo}
          tableId={tableId}
          key={memo.id}
          handleRowClick={handleRowClick}
          handleMemoUpdate={handleMemoUpdate}
          handleKeyDown={handleKeyDown}
          handleMemoDelete={handleMemoDelete}
          handleMemoEdit={handleMemoEdit}
          textareaRefs={textareaRefs}
        />
      ))}
    </>
  );
};

export default MemoTable;
src/components/MemoContainer/MemoRow.tsx
import React from "react";
import { MemoRowProps } from "../../Types";
import "../../App.css";
import DeleteIcon from "@mui/icons-material/Delete";

const MemoRow: React.VFC<MemoRowProps> = ({
  memo,
  tableId,
  handleRowClick,
  handleMemoUpdate,
  handleKeyDown,
  handleMemoDelete,
  handleMemoEdit,
  textareaRefs,
}) => {
  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;
src/components/MemoContainer/NewMemoRow.tsx
import React from "react";
import { NewMemoRowProps } from "../../Types";
import "../../App.css";

const NewMemoRow: React.VFC<NewMemoRowProps> = ({
  newMemo,
  handleRowClick,
  currentMemos,
  setNewMemo,
  handleMemoUpdate,
  handleKeyDown,
  textareaRefs,
}) => {
  return (
    <tr className="memo__row" onClick={() => handleRowClick(currentMemos.length)}>
      <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)}
        />
      </td>
      <td className="memo__updatedAt"></td>
      <td>
        <button className="memo__delete"></button>
      </td>
    </tr>
  );
};

export default NewMemoRow;

Propsの型定義を行っているTypes.tsも記載します。
型定義を行うことで型安全性の向上やドキュメントとしての役割を担うことができます。

src/Types.ts
export interface Memo {
  id: number;
  content: string;
  createdAt: string; // 日付文字列として扱う場合
  updatedAt: string; // 日付文字列として扱う場合
}

export interface MemoTableProps {
  currentMemos: Memo[];
  handleRowClick: (tableId: number) => void;
  handleMemoUpdate: (targetMemo: Memo) => void;
  handleKeyDown: (e: React.KeyboardEvent, targetMemo: Memo, tableId: number) => void;
  handleMemoDelete: (id: number) => void;
  handleMemoEdit: (e: React.ChangeEvent<HTMLTextAreaElement>, id: number) => void;
  textareaRefs: React.MutableRefObject<HTMLTextAreaElement[]>;
}

export interface NewMemoRowProps {
  newMemo: Memo;
  handleRowClick: (tableId: number) => void;
  currentMemos: Memo[];
  setNewMemo: React.Dispatch<React.SetStateAction<Memo>>;
  handleMemoUpdate: (targetMemo: Memo) => void;
  handleKeyDown: (e: React.KeyboardEvent, targetMemo: Memo, tableId: number) => void;
  textareaRefs: React.MutableRefObject<HTMLTextAreaElement[]>;
}

export interface MemoRowProps {
  memo: Memo;
  tableId: number;
  handleRowClick: (tableId: number) => void;
  handleMemoUpdate: (targetMemo: Memo) => void;
  handleKeyDown: (e: React.KeyboardEvent, targetMemo: Memo, tableId: number) => void;
  handleMemoDelete: (id: number) => void;
  handleMemoEdit: (e: React.ChangeEvent<HTMLTextAreaElement>, id: number) => void;
  textareaRefs: React.MutableRefObject<HTMLTextAreaElement[]>;
}

まとめ

以上、コンポーネント指向について実装ベースで整理してみました。

TypeScriptを使用しているため若干記述量は多いですが、役割分担ができ、理解しやすい構造にすることができました。

コンポーネントの粒度は開発者によって異なる点だけ注意が必要です。

参考文献

React の流儀 – React
The library for web and native user interfaces
React初学者が必ず押さえておきたい考え方とは?【コンポーネント指向のフロントエンド】 | in-Pocket インポケット
こんにちは、i3DESIGNエンジニアの田口です。今回は「React初学者が必ず押さえておきたい考え方とは?」というテーマでお話します。 こちらの記事は、下記のような方を対象としています。 プログラミングを学習中でフロン

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

コメント