みなさん、こんにちは。
Ateam Brides Inc. Advent Calendar 2021の18日目は@ttkが担当します!
18に関する一言ネタを書こうと思いググってみたところ18は悪魔の数字のようです👿
🤔これは何じゃ
この約半年間業務でReactを触ってきて、その間に学んできたことを、TODOに反映させてみました。
機能こそベターですが、設計や型定義などちょっとこだわりをもって作りました。
TODOは言語やフレームワークを学んだときに試しのアプリとして作ることが多く、そこまでこだわって作ることはないかと思いますが、ポイントをお伝えするのには向いているのかなと思い作りました。
まだまだ至らぬ点も多いですが、今後の誰かの参考になると嬉しいです。
✨完成形
フォームにTODOを入力したら、未完了のリストに登録されて、完了したらステータスを変更し、不必要になったら削除をすることができるごくごく一般的なTODOです。
スタイルは当たっていません。cssかけるようになりたい、、

コードはこちらです。
簡易DBとしてJSON Serverを使っています。
🧐コードの紹介
開発をする中でちょっとこだわってみた点を上の階層から紹介していきます。
その前に、まずはディレクトリ構成からご紹介します。
src
|
+-api
|  |_ todo.js
|
+-components
|  |_ index.ts
|  |_ Todo.tsx
|  |_ Todo
|      |_ CompleteTodos
|         |_ index.ts
|         |_ CompleteTodos.tsx
|         |_ hooks.ts
|      |_ IncompleteTodos
|      |_ Providers
|      |_ TodoInputForm
|
|
+-libs
|  |_ components
|      |_ index.ts
|      |_ TitleLabel
|          |_ index.ts
|          |_ TitleLabel.tsx
|      |_ ...
|  |_ hooks
|      |_ index.ts
|      |_ use-delete-todo.ts
|      |_ ...
|
+-types
   |_ todo.d.ts
components
componentsにはページを構成するコンポーネントを置いていきます。
基本『コンポーネントA』フォルダの中には『コンポーネントA』.tsxとindex.tsがあり必要があればhooks.tsの3ファイルを置きます。
もし、コンポーネントAが子コンポーネントA-1を持つ場合、『コンポーネントA-1』フォルダを『コンポーネントA』フォルダの中に置きます。
今回でいうところのCompleteTodosフォルダやTodoInputFormフォルダがそれに該当します。
Providerについては構成コンポーネントではないのでcomponentsではないかと思う一方、汎用的に使い回すものでもないのでlibsでもないなとも思い、苦し紛れにここに置いています。
今後いい配置場所を考えていきます。
libs
libsにはcomponents内で複数回呼ばれるものをcomponentsとhooksに分けて配置しています。
Formやlabelなど部品として扱うことが多いコンポーネントはここに置くイメージです。
index.ts
index.tsが何回か出てきたかと思いますが、こちらの中身は以下のようになっています。
export * from './コンポーネントA';
こうすることで、コンポーネントAを呼び出したい時にhoge/コンポーネントA/コンポーネントA.tsxと冗長的にかかずにhoge/コンポーネントAでインポートをすることができます。
index.tsはコンポーネントに限らず、フォルダに対してやhooksのファイルに対しても使えます。
🏃♂️それではコンポーネントの中身を見ていきましょう。
🔹Todo.tsx
import { VFC } from "react";
import { TitleLabel } from "@/libs/components";
import { CompleteTodos } from "./CompleteTodos";
import { IncompleteTodos } from "./IncompleteTodos";
import { TodoInputForm } from "./TodoInputForm";
import { TodoProvider } from "./Providers";
export const Todo: VFC = () => {
  return (
    <TodoProvider>
      <TitleLabel text="TODO管理" tag="h1" />
      <TodoInputForm />
      <IncompleteTodos />
      <CompleteTodos />
    </TodoProvider>
  );
};
📝POINT: importのエイリアスを設定
本来であればTodo.tsxから見た相対パスで考えると以下のようにかかなければいけません。
import { TitleLabel } from "../../libs/components";
今回ぐらいならまだしも、もっと階層が深いところからlibsのものをインポートするとなったらパスの指定が大変になってしまいます。
そこで、おすすめしたいのがインポートのエイリアスの設定です。
cracoというパッケージをインストールしてtsconfigをいじるだけで簡単に設定ができます。
▼参考にさせていただいたやり方
📝POINT: TODOデータと更新関数を配布するTodoProviderでラップ
ページを構成するコンポーネント群がTodoProviderでラップされているのがわかるかと思います。
これは内部のコンポーネントのどこからでもTODOリストを取得したり、更新したりできるようにするために設置しています。
import {
  createContext,
  Dispatch,
  ReactNode,
  SetStateAction,
  useContext,
  useEffect,
  useState,
  VFC,
} from "react";
import { Todo } from "@/types/todo";
import { getAllTodos } from "@/api/todo";
type ContextValue = {
  todos: Todo[];
  setTodos: Dispatch<SetStateAction<Todo[]>>;
};
export const Context = createContext<ContextValue>({} as ContextValue);
type Props = {
  children: ReactNode;
};
export const TodoProvider: VFC<Props> = ({ children }) => {
  const [todos, setTodos] = useState<Todo[]>([]);
  useEffect(() => {
    getAllTodos().then((todos) => setTodos(todos));
  }, []);
  return (
    <Context.Provider value={{ todos, setTodos }}>{children}</Context.Provider>
  );
};
export const useTodos = () => {
  return useContext(Context);
};
APIから取得してきたTODOをステートにセットし、そのステートtodosと、ステートを更新するための関数setTodosをcreateContext、useContextを使って配布するようにします。
ここでのポイントは必要になったタイミングでuseContextをするのではなく、このファイル内でカスタムフックとして提供するようにします。
そうすることで毎度useContextとContextをインポートする必要がなく、カスタムフック一つのインポートで済みますし、Contextの名前を気にする必要がなくなるのでContextとして定義できます。
🔹TodoInputForm.tsx
import { VFC } from "react";
import { TitleLabel } from "@/libs/components";
import { useTodoForm } from "./hooks";
import { ErrorMessage } from "@hookform/error-message";
export const TodoInputForm: VFC = () => {
  const { register, handleSubmitTodo, errors } = useTodoForm();
  return (
    <>
      <TitleLabel text="TODO入力" />
      <form onSubmit={handleSubmitTodo}>
        <textarea {...register("content", { required: "This is required." })} />
        <input type="submit" value="追加" />
        <ErrorMessage errors={errors} name="content" />
      </form>
    </>
  );
};
📝POINT: React Hook Formを利用
フォームまわりの処理は複雑故に何かと実装に時間がかかるものですが、Reactでフォームを実装するときはReact Hook Formを使うと便利です。
フォーム化したいタグにregisterを埋め込むだけでバリデーションを指定できたり、onChangeやonBlurで処理を走らせる、submit処理を楽に行える等、フォーム周りの面倒な処理を請け負ってくれます。
今回はフォームが一つしかなかったのですが、例えば同じフォームを複数箇所で使いたいとった場合などは、ジェネリクスを使って汎用的なフォームコンポーネントをつくることもできます。
React Hook FormはTypeScriptのサポートも充実しており、メソッド等の型も配布してくれています。
📝POINT: フォームで必要なメソッドはカスタムフックで定義
import { Todo } from "@/types/todo";
import { useForm } from "react-hook-form";
import { ulid } from "ulid";
import { useTodos } from "../Providers";
import { addTodo } from "@/api/todo";
export const useTodoForm = () => {
  const {
    register,
    handleSubmit,
    reset,
    formState: { errors },
  } = useForm<Todo>();
  const { todos, setTodos } = useTodos();
  const handleSubmitTodo = handleSubmit((data: Pick<Todo, "content">) => {
    const newTodo: Todo = { ...data, id: ulid(), done: false };
    addTodo(newTodo).then((addedTodo) => {
      setTodos([addedTodo, ...todos]);
      reset();
    });
  });
  return { register, handleSubmitTodo, errors };
};
必要なメソッドや処理は、カスタムフック内で呼び出し、記述しちゃいましょう。
useFormでReact Hook Formのメソッドを呼び出しています。ここでちゃんと型定義ができるので安心ですね。
TodoProviderのおかげでtodosとsetTodosもここで呼び出すことができます。
ビュー側に最低限必要なメソッドやステートだけを渡すことでビュー側の可読性が向上します。
🔹CompleteTodos.tsx (IncompleteTodos.tsx)
import { VFC } from "react";
import {
  TitleLabel,
  DeleteButton,
  ChangeStatusButton,
} from "@/libs/components";
import { useCompleteTodos } from "./hooks";
export const CompleteTodos: VFC = () => {
  const { completeTodos } = useCompleteTodos();
  return (
    <>
      <TitleLabel text="完了のTODO" />
      <ul>
        {completeTodos.map(({ id, content, done }) => (
          <li key={id}>
            {content}
            <DeleteButton id={id} />
            <ChangeStatusButton id={id} label={done ? "未完了へ" : "完了へ"} />
          </li>
        ))}
      </ul>
    </>
  );
};
📝POINT: TODOの削除とステータスの更新ボタンをコンポーネント化
CompleteTodos.tsxは完了ステータスのTODOを表示させるコンポーネントです。
削除ボタンと未完了へステータスを更新するためボタンはコンポーネント化されています。
そうすることで、このコンポーネントではTODOの削除と更新の関心を持つ必要がなくなり、TODOを表示する役割だけを果たせばよくなります。
また、コンポーネント化することで、例えば削除ボタンが不要になったので消したいといった際は<DeleteButton id={id} />を剥がすだけで対応が終わるので、メンテナンスのしやすさにも効果的です。
import { VFC } from "react";
import { useDeleteTodo } from "@/libs/hooks";
import { Todo } from "@/types/todo";
type Props = {
  id: Todo["id"];
};
export const DeleteButton: VFC<Props> = ({ id }) => {
  const { handleDeleteTodo } = useDeleteTodo();
  return <button onClick={() => handleDeleteTodo(id)}>削除</button>;
};
DeleteButton.tsxについても考え方はCompleteTodos.tsxと同じで、「ここではボタンが押されたらPropsとして受け取ったidを元にTODOを削除します。他のことは知りません。」といった感じで自分の与えられた役割のみを全うするようになっています。
import { useTodos } from "@/components/Todo/Providers";
import { deleteTodo } from "@/api/todo";
import { Todo } from "@/types/todo";
import { useCallback } from "react";
export const useDeleteTodo = () => {
  const { todos, setTodos } = useTodos();
  const handleDeleteTodo = useCallback(
    (id: Todo["id"]) => {
      deleteTodo(id).then((deletedTodoId) => {
        const newTodos = todos.filter(({ id }) => id !== deletedTodoId);
        setTodos(newTodos);
      });
    },
    [setTodos, todos]
  );
  return { handleDeleteTodo };
};
useDeleteTodo内で定義されているhandleDeleteTodoはuseCallbackでメモ化されています。
useCallbackでメモ化された関数はpropsとして渡すReact要素がReact.memo化されているときに力を発揮します。
今回ではbutton要素に渡しており、button要素はReact.memo化されていません。
それなのにメモ化した理由は、handleDeleteTodoは使われる側のことなんて気にする必要がないからです。
使う側でメモ化したいから、関数もメモ化しようではなく、どんな状況で使われるかは知らないけど、どんな状況でも使えるようにしておくことがポイントです。
👨💻おわりに
以上、ちょっとこだわってつくったTODOアプリの紹介でした。
こだわったとは言ったものの、まだまだ設計だったり、コードの記述の仕方でもっと最適化できる部分があるかと思います。
今後もReactを触っていくことになりそうなので、より力をつけていけるよう精進して参ります。
本記事を書くにあたり、こちらの本を参考に使わせていただきました。
jsの基礎からReactのカスタムフックまで初学者でもとっつきやすく幅広くReactについて学習ができる一冊となっています。
是非読んでみてはいかがでしょうか。
明日のAteam Brides Inc. Advent Calendar 2021は@mkinさんが担当します。
お楽しみに!!🥳
