📝

[React] 新規作成画面と編集画面の実装で気をつけていること

2024/11/28に公開
2

SaaS の管理画面を開発していると新規作成画面と編集画面を実装することがよくあります。

これらの画面は一見似ているので共通のコンポーネントで実装できそうですが、意外と多くの違いがあります。

この記事では新規作成画面と編集画面を実装するときに気をつけていることをまとめてみます。

https://speakerdeck.com/medley/deep-dive-into-react-component-design-for-medical-systems?slide=2

コンポーネント設計について

シンプルな例でも新規作成画面と編集画面には違いがありました。
これらを1つの共通コンポーネントで実装するとコンポーネント内でIF分岐が発生し可読性が下がったり、再利用性が低くなったりします。

では両者を完全に別コンポーネントで実装したら良いのかというとそれも微妙です。新規作成、編集の入力項目は仕様的に同じであり、バリデーションも同じであることが多いです。 ここを別に実装してしまうと仕様が変わったときに変更する箇所が多くなってしまいます。
なのでフォーム部分(入力とバリデーション)は共通化しておきたいです。

そこで以下のようなコンポーネント構成にします。

共通の Form をコンポーネントに切り出して、新規作成と編集コンポーネントがそれぞれ Form コンポーネントを子に持つ構成です。Form コンポーネントに共通の処理を書き、新規作成と編集コンポーネントのそれぞれに独自の処理を書きます。

このとき Form コンポーネントは以下のような実装になり、props で defaultValuehandleSubmit 関数を受け取るようにします。 このインターフェースにすることで Form コンポーネントは 初期値と送信時の挙動を外部に委任することができ、新規作成画面と編集画面の両方で使うことができます。

Form.tsx
export const Form = ({
  handleSubmit,
  defaultValues,
}: {
  handleSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
  defaultValues: {
    title: string;
    description: string;
  };
}) => {
  return (
    <form onSubmit={handleSubmit}>
      <input type="text" name="title" defaultValue={defaultValues.title} />
      <textarea name="description" defaultValue={defaultValues.description} />
      <button type="submit">Submit</button>
    </form>
  );
};

続いて新規作成コンポーネントについて見ていきます。初期値には空文字を渡して handleSubmit には新規作成 POST api を叩くようにしています。

Create.tsx
import { Form } from "./Form";

export const Create = () => {
  return (
    <>
    <div>新規作成</div>
    <Form
      handleSubmit={(event) => {
        event.preventDefault();
        // ここで POST api を実行する
      }}
      defaultValues={{
        title: "",
        description: "",
      }}
    />
   </>
  );
};

編集コンポーネントではAPIから取得されたデータを Form コンポーネントに渡しています。(データフェッチには TanStack Query)を使用しています。data が APIから取れたあとに Form コンポーネントをレンダリングするようにしています。

Edit.tsx
import { Form } from "./Form";
import { useQuery } from '@tanstack/react-query'

export const Edit = () => {
  const { data, isPending } = useQuery({
  queryKey: ["book"],
  queryFn: () => axios.get("/book").then(res => res.data)
  })

  return (
     <>
      <div>編集</div>
      {data && (
        <Form
          handleSubmit={(event) => {
            event.preventDefault();
            // ここで PUT api を実行する
          }}
          defaultValues={data}
        />
      )}
    </>
    )
  );
};

結果的に Container/Presentational パターンに似た実装になりました。

React Hook Form を使っているとき

Form の実装に React Hook Form を使うことはよくあると思います。 React Hook Form で非同期でデフォルト値をセットするときは注意が必要です。

React Hook Form では最初のレンダリングでdefaultValuesの値をキャッシュします。
なので非同期で取得した値をデフォルト値としてセットしたい場合、

  1. reset APIを使って form の値を上書きする
  2. defaultValue に fetch 非同期関数を渡す

のどちらかの方法をとる必要があります。ただ、この処理は初期値が入る編集画面にのみ必要な処理です。この機能を Form コンポーネントの中で使ってしまうと、Form コンポーネントの共通化ができなくなってしまいます。

しかしながら、データ取得を親コンポーネントで行って、データ取得を待って Form コンポーネントをレンダリングすればこの問題は発生しません。

なので、今回のようにコンポーネントを2階層に分けて親でデータフェッチをしてデータが取得できたタイミングで Form コンポーネントがレンダリングされるようにすればこの問題は回避できます。
なので、今回のアーキテクチャは特に React Hook Form との相性が良いと思っています。

この React Hook Form の設計パターンは以下の記事にとても丁寧にまとめられています。
https://zenn.dev/counterworks/articles/react-hook-form-async-default-values

また、この遅延処理を suspense を使えば分岐処理がなくなり、より完結に書くことができます。

Edit.tsx
import { Form } from "./Form";
import { useSuspenseQuery } from '@tanstack/react-query'

export const Edit = () => {
 const { data } = useSuspenseQuery({
    queryKey: ['book'],
    queryFn: () => axios.get("/book").then(res => res.data)
  })

  // suspense により data が undefined でないことが保証され分岐がなくなる
  return (
     <>
      <div>編集</div>
      <Form
          handleSubmit={(event) => {
            event.preventDefault();
            // ここで PUT api を実行する
          }}
          defaultValues={data}
        />
    </>
    )
  );
};

Next.js App Router を使っているとき

App Router を使っている場合、データフェッチ をサーバーコンポーネントで行うという原則に則ると、自然と今回の設計になると思います。 今回の例だと、Create コンポーネント と Edit コンポーネントがサーバーコンポーネント、Form コンポーネントがクライアントコンポーネントです。

サーバーコンポーネント、クライアントコンポーネントと区別して設計していくと自然と良い実装になるのが App Router 利点の1つですね。

まとめ

今回の例に限らず、一見似ているコンポーネントでもよく考えると違う挙動を持っていることはよくあります。 そのときは仕様上共通な要素を抜き出して、その部分をうまく共通化することが大切です。
なんとなく見た目が似ているからという理由で共通化してしまうと、共通化の罠にハマってしまう可能性があるので今後も気を付けて開発をしていこうと思います。

最後に

AI Shiftではエンジニアの採用に力を入れています!
少しでも興味を持っていただけましたら、カジュアル面談でお話しませんか?
(オンライン・19時以降の面談も可能です!)

【面談フォームはこちら】
https://hrmos.co/pages/cyberagent-group/jobs/1826557091831955459

AI Shift Tech Blog

Discussion

gotingotin

React hook from となってしまってる箇所があるのでformに直しておけるとよさそうです、

kultkult

ご指摘ありがとうございます。
修正しました!