4
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

React Router v7でフォーム画面を作る

Last updated at Posted at 2024-11-10

はじめに

フロントの勉強がてらまとめます
やったことベースに記載し、わからないことはスルーします
極力注釈に残し、勉強材料にします

やりたいこと

利用スタック

概要説明

React Router v7(旧 Remix)

Reactのフレームワークです
https://ja.react.dev/learn/start-a-new-react-project#remix

良いなと思っている特徴は以下です

  • Web標準を尊重していること
    Web標準は下記の認識です
    https://developer.mozilla.org/ja/docs/Learn/Getting_started_with_the_web/The_web_and_web_standards
    これを勉強したらRemixでなくても使えるのでお得では?という気持ちになっています

  • フルスタックデータフロー
    https://remix.run/docs/en/main/discussion/data-flow
    Lodaders, Component, Actionをくるくるしてデータの行き来をするイメージを持ってます
    わかりやすいです

  • 基本的にはSSRをサポートしている(気がする)
    SPAも使えます、SSGはない認識です
    NextだとSSGもあって少し考えることが増えてしまうので、
    SSGを使わないならこっちのほうがいいかなくらいの感じです

  • 状態管理
    https://remix.run/docs/en/main/discussion/state-management
    以前Reactを触っていたときに状態管理はReduxを使用していたのですが、とても面倒でした
    Remixは公式がほとんど有用ではない、みたいなことを言ってます
    であればドキュメントの通りの思想で開発していけばあまり意識しなくてもいいのか?と思ってます

ほかにもあると思います1

そしてこのRemixがv2からv3になったらReact Router v7として統合されるっぽいです
2024-11-10 現在、まだプレリリースなのでドキュメントは整備中みたいです
公開されたらちゃんと読み込んでいきたいと思います

Chakra UI

UIコンポーネントライブラリです

v2からv3にバージョンが上がってshadcnのようなスニペットを追加していく感じになりました
https://www.chakra-ui.com/blog/00-announcing-v3
https://www.chakra-ui.com/blog/01-chakra-v2-vs-v3-a-detailed-comparison

スタイリングが簡単なイメージを持っています
cssを書かなくても、コンポーネントのpropsに値を渡すだけでなんとかなる印象です
https://github.com/chakra-ui/chakra-ui#features

比較対象としてMUIとかもあると思います
以下を読みます(読むだけ)
https://www.uxpin.com/studio/jp/blog-jp/chakra-ui-vs-material-ui-ja/

Conform

Conform は、Web 標準に基づいて HTML フォームを段階的に強化し、 Remix や Next.js のようなサーバーフレームワークを完全にサポートする、型安全なフォームバリデーションライブラリです。

このように公式ページに書いてありました
Web標準に基づいて、のあたりがReactRouterと親和性がありそうな予感がします
実際Remixのほうの公式ページにはConformのページがあります
https://remix.run/resources/conform

他にも特徴として下記が挙げられています
https://ja.conform.guide/#%E7%89%B9%E5%BE%B4
image.png
プログレッシブエンハンスメント、という単語を初めて知りました
Remixの公式でもこの単語が登場した記憶があります

プログレッシブエンハンスメントとはなんぞや、というのは下記を読みます
https://zenn.dev/cybozu_frontend/articles/think-about-pe

wikipediaの該当ページを読んでみると、ここに目が行きました

すべてのユーザーが任意のブラウザーまたはインターネット接続を用いてウェブページの基本的なコンテンツと機能性にアクセスできることと、より高度なブラウザーソフトウェアまたはより広帯域の接続を有するユーザーには同じページの拡張バージョンを提供できることである

ユーザーの設定によってはブラウザのjavascriptを無効にしている人もいます
そういった方にも閲覧・操作ができるようにすることということでしょうか
昨今の広告ブロッカーをoffにしないとページが読めない、というのはこれに反しているということなのでないか?とちょっと思ってしまいました2

Storybook

Storybook は、UI コンポーネントとページを個別に構築するためのフロントエンド ワークショップです。何千ものチームが UI 開発、テスト、ドキュメント作成に使用しています。オープンソースで無料です。

今回はUI開発、ドキュメント作成用途を主として扱います
テストもできるらしく、「すげ~」って浅い感想をもちました

結構頻繁にこの名前を聞くので少しは扱えたほうがいいのかな?という危機感を持ったので今回触ります

環境構築

各種ライブラリのインストールを行います
node等は入っている前提です

React Router

React Routerをいれます、<>には任意のパスを指定します
インストール後にnpm run devしてブラウザから初期画面が見られたらOK

$ npm create react-router@pre <projectDir>

Chakra UI

Remixのページが公開されていますが、このページはRemix v2想定っぽいので無視します
Overview > Installationから始めます

下記を実行します

$ npm i @chakra-ui/react @emotion/react

スニペットを追加します
Chakra UI v3からはshadcn-uiみたいな感じになったようです

$ npx @chakra-ui/cli snippet add

これやるとこんな感じでsrc配下にコンポーネントがもらえます
image.png

ただReact Routerのインストール直後はapp配下でソースを管理してるので3、components配下をappに移動させます

その後はプロバイダーの設定をします
前の工程でスニペットを追加した中にProviderが存在するので、それを使います
image.png

ただこれを追加すると下記のように、ブラウザでエラーが吐かれちゃいます
image.png

Warning: Extra attributes from the server: class,style...

これを追加すると警告は消えます
image.png

suppressHydrationWarningはサーバーとクライアントで属性が異なるときの警告を無視するものらしいです4
https://react.dev/reference/react-dom/client/hydrateRoot#suppressing-unavoidable-hydration-mismatch-errors

ここまででChakra UIのインストールは完了です


Tailwind CSS(の削除)

Chakra UIをインストールできたので、動作確認をしてみます
試しにindexを以下のように書き換えます
https://www.chakra-ui.com/docs/components/button#sizes
image.png

そうすると不思議なことに、黒のボタンになってほしいところが白のままです
image.png

これはroot.tsxでインポートしてるapp.cssを消すと直ります
image.png

こいつがなにものかというと、Tailwind CSSの初期設定と背景色の設定、ダークモードの適用をやってるようにみえます ひとまずなくてもいいやという気持ちになったので消します5 Tailwind CSSもそこまで使わないことから消してしまいます
image.png

コマンドをたたいて、tailwind.config.tsを消してpostcss.config.jsからtailwindcssのkeyを消します

$ npm uninstall tailwindcss --save-dev

無事表示されるようになりました
image.png


Conform

フォームを作るときに使うので今はここまでです

$ npm install @conform-to/react @conform-to/zod --save

Storybook

Storybookも追加していきます

まずはインストールします
https://storybook.js.org/docs/get-started/install

$ npx storybook@latest init

インストールが完了すると、自動的に立ち上がりますが、以下のようなエラーがでます
image.png

原因はここに記載されています
https://remix.run/docs/en/main/guides/vite#plugin-usage-with-other-vite-based-tools-eg-vitest-storybook

Remix Vite プラグインは、アプリケーションの開発サーバーと本番ビルドでのみ使用することを目的としています。Vitest や Storybook など、Vite 構成ファイルを使用する他の Vite ベースのツールもありますが、Remix Vite プラグインはこれらのツールで使用するようには設計されていません。現在、他の Vite ベースのツールで使用する場合は、プラグインを除外することをお勧めします。

なのでvite.config.tsを以下のように書き換えます
image.png

これで再度npm run storybookをすると立ち上がることが確認できます

次にChakra UIとの連携作業が発生します
https://www.chakra-ui.com/docs/get-started/frameworks/storybook

アドオン等をインストール

$ npm i @storybook/addon-themes @chakra-ui/react @emotion/react

preview.tsxを修正
image.png
手順通りにやると

import { ChakraProvider, defaultSystem } from "@chakra-ui/react"

を使用していますが、これだと"next-themes"のThemeProviderを使用しない書き方になるような気がしてます
ここは問題ないのでしょうか?ちょっとわかりません6

最後にもう一度立ち上げてみます
image.png
最初から用意されているサンプルは問題なく閲覧出来てそうです
また、Chakra UIのサンプルも見えるようになっています

フォーム画面の作成

今回は仮定として、人間の情報を登録するためのフォーム画面を作成します
なので以下の入力を必要と想定します

  • 氏名
  • 生年月日
  • 都道府県

以下を作成しました

app\components\ui\field.tsx
export interface ConfromFieldProps
  extends Omit<ChakraField.RootProps, "label"> {
  id?: string;
  label?: React.ReactNode;
  helperText?: React.ReactNode;
  errorId?: string;
  errorText?: React.ReactNode;
  optionalText?: React.ReactNode;
}

export const ConfromField = forwardRef<HTMLDivElement, ConfromFieldProps>(
  function ConfromField(props, ref) {
    const {
      id,
      label,
      children,
      helperText,
      errorId,
      errorText,
      optionalText,
      ...rest
    } = props;
    return (
      <ChakraField.Root ref={ref} {...rest}>
        {label && (
          <ChakraField.Label htmlFor={id}>
            {label}
            <ChakraField.RequiredIndicator fallback={optionalText} />
          </ChakraField.Label>
        )}
        {children}
        {helperText && (
          <ChakraField.HelperText>{helperText}</ChakraField.HelperText>
        )}
        {errorText && (
          <ChakraField.ErrorText id={errorId}>
            {errorText}
          </ChakraField.ErrorText>
        )}
      </ChakraField.Root>
    );
  }
);

もともと存在していたスニペット、FieldにhtmlForのためのidとerrorのidを受け取れるように修正したものです
これはConfomのチュートリアルを参考にしています
https://ja.conform.guide/tutorial

とりあえずConformFieldという名前にしました
Chakraのスニペットをカスタマイズする際はどこに記載していくのが正しいのかちょっとわかりませんが、default exportしているわけでもないので単純に同ファイルに追加しました

app\routes\home.tsx
import {
  Container,
  HStack,
  Input,
  VStack,
  createListCollection,
} from "@chakra-ui/react";
import {
  getFormProps,
  getInputProps,
  getSelectProps,
  useForm,
} from "@conform-to/react";
import { getZodConstraint, parseWithZod } from "@conform-to/zod";
import { ConfromField } from "app/components/ui/field";

import { Form, redirect, type MetaFunction } from "react-router";
import { z } from "zod";
import { Button } from "~/components/ui/button";
import { Route } from "../+types.root";

import {
  NativeSelectField,
  NativeSelectRoot,
} from "app/components/ui/native-select";

export const meta: MetaFunction = () => {
  return [
    { title: "New React Router App" },
    { name: "description", content: "Welcome to React Router!" },
  ];
};

const schema = z.object({
  firstName: z.string({ required_error: "名は必須です" }),
  lastName: z.string({ required_error: "姓は必須です" }),
  birthday: z.date({ required_error: "誕生日は必須です" }),
  prefecture: z.string(),
});

type Prefecture = {
  id: number;
  code: string;
  name: string;
  area_id: number;
  created_ad: string;
  updated_at: string;
};

export async function loader(): Promise<Prefecture[]> {
  const res = await fetch(
    "https://apis.apima.net/k2srm05wzm1pdl3xk0sv/v1/prefectures/"
  );

  const data: Prefecture[] = await res.json();
  return data;
}

export async function action({ request }: Route.ActionArgs) {
  const formData = await request.formData();
  const submission = parseWithZod(formData, { schema });

  console.log(submission.payload);
  return redirect("/");
}

export default function Home({ loaderData }: Route.ComponentProps) {
  const [form, fields] = useForm({
    constraint: getZodConstraint(schema),
    shouldValidate: "onBlur",
    shouldRevalidate: "onInput",
    onValidate({ formData }) {
      return parseWithZod(formData, { schema });
    },
  });

  const options = loaderData
    ? loaderData.map((item) => ({ value: item.id, label: item.name }))
    : undefined;

  return (
    <Form method="post" {...getFormProps(form)}>
      <Container>
        <VStack gap={6} mt={5}>
          <HStack w={"full"}>
            <ConfromField
              label=""
              required
              id={fields.lastName.id}
              errorId={fields.lastName.errorId}
              errorText={fields.lastName.errors}
              invalid={!!fields.lastName.errors?.length}
            >
              <Input
                placeholder=""
                variant="outline"
                {...getInputProps(fields.lastName, { type: "text" })}
              />
            </ConfromField>

            <ConfromField
              label=""
              required
              id={fields.firstName.id}
              errorId={fields.firstName.errorId}
              errorText={fields.firstName.errors}
              invalid={!!fields.firstName.errors?.length}
            >
              <Input
                placeholder=""
                variant="outline"
                {...getInputProps(fields.firstName, { type: "text" })}
              />
            </ConfromField>
          </HStack>

          <ConfromField
            label="生年月日"
            required
            id={fields.birthday.id}
            errorId={fields.birthday.errorId}
            errorText={fields.birthday.errors}
            invalid={!!fields.birthday.errors?.length}
          >
            <Input
              placeholder="生年月日"
              variant="outline"
              {...getInputProps(fields.birthday, { type: "date" })}
            />
          </ConfromField>

          <ConfromField
            label="都道府県"
            required
            id={fields.prefecture.id}
            errorId={fields.prefecture.errorId}
            errorText={fields.prefecture.errors}
            invalid={!!fields.prefecture.errors?.length}
          >
            <NativeSelectRoot>
              <NativeSelectField {...getSelectProps(fields.prefecture)}>
                {options.map((option) => (
                  <option value={option.value} key={option.value}>
                    {option.label}
                  </option>
                ))}
              </NativeSelectField>
            </NativeSelectRoot>
          </ConfromField>

          <Button colorPalette={"teal"} type="submit">
            登録
          </Button>
        </VStack>
      </Container>
    </Form>
  );
}

loaderで都道府県を取得して、actionでconsoleに入力内容を表示するようにしました

このような画面になります
image.png

バリデーションも効いているようです
image.png

登録を押した際にconsoleに表示されるのも確認できました
image.png

実装の説明

loader

image.png
この部分です
今回のWebAPIは以下を拝借しました
https://www.apima.net/apis/k2srm05wzm1pdl3xk0sv/

loaderの説明は以下を読みます
https://reactrouter.com/dev/framework/start/data-loading#server-data-loading
loaderという命名にしておくことでReact Routerが自動的に呼び出しを行うようです
これはコンポーネントのレンダリング前に実行される認識をしています

そしてそのloaderが返したデータはdefault exportされているコンポーネントのloaderDataで受け取ります
image.png
ちょっとここからが謎ですが、loaderDataがundefinedと推論されています
ただデータ自体はちゃんと存在しています
Remixの時はちょっと読み取り方が異なっており、useLoaderDataというhooksで受け取っていました
この時は型がちゃんと推論されていたのですが、React Router v7からはちょっとお作法が変わった?ということもあってもうちょっと調べる必要がありそうです7
https://remix.run/docs/ja/main/route/loader#type-safety

schema

ここはzodのお作法です
image.png
氏名と誕生日、都道府県だけなのでstringとdateで済ませました
https://zod.dev/?id=strings
https://zod.dev/?id=dates
住所は何も選択しなくても北海道が選ばれるので必須ではありますが、必須の際のエラーを設定していません

コンポーネント

default export されているコンポーネント、ここではHomeを指します

useForm

image.png
この部分です
これは特にConfomのチュートリアルから変更していません
ここをいじることでバリデーションのタイミングを変更できるようです
https://ja.conform.guide/api/react/useForm

JSX

returnの中身です
cssを書かずにChakraUIのコンポーネントだけでそれなりにできたので簡単でした
Form(ReactRouter)に入力内容を入れてsubmitすることでactionにいきます
結構シンプルなんじゃないかな?と感じました

Confromにはヘルパー関数が用意されており、シンプルな入力フォームなら本当に簡単に実装できる印象を覚えました
react-selectなど、ちょっと変わったやつでもラッパーを作ったりヘルパー関数を自作することで対応することができそうです

action

image.png
formからsubmitされてから、入力データを受け取る場所です
parseWithZodがConformから提供されている部品で、これを使ってサーバーサイドでバリデーションを行っています
先に述べたuseFormの中でもこれを指定しており、クライアント側と両方で検証をしています
こうすることで、プログレッシブエンハンスメントを尊重しているのかなという理解です
(仮にjavascriptを無効化していても、サーバー側で検証できるのでok、でも無効化していない人は即時に検証されることでユーザー体験が向上する)

Storyを作る

ちょっとここまでで満足しちゃった感がありますが、一応Storyも作ります
例えば送信ボタンの色を固定しちゃいたいときとか、カスタマイズされた部品を用意して使いまわす想定でいきます

ということでスニペットのボタンをコピーして、colorPalette={"teal"}だけを追加したform-buttonというものを作成します

app\components\form-button\form-button.tsx
import type { ButtonProps as ChakraButtonProps } from "@chakra-ui/react";
import {
  AbsoluteCenter,
  Button as ChakraButton,
  Span,
  Spinner,
} from "@chakra-ui/react";
import { forwardRef } from "react";

interface FormButtonLoadingProps {
  loading?: boolean;
  loadingText?: React.ReactNode;
}

export interface ButtonProps
  extends ChakraButtonProps,
    FormButtonLoadingProps {}

export const FormButton = forwardRef<HTMLButtonElement, ButtonProps>(
  function FormButton(props, ref) {
    const { loading, disabled, loadingText, children, ...rest } = props;
    return (
      <ChakraButton
        disabled={loading || disabled}
        ref={ref}
        colorPalette={"teal"}
        {...rest}
      >
        {loading && !loadingText ? (
          <>
            <AbsoluteCenter display="inline-flex">
              <Spinner size="inherit" color="inherit" />
            </AbsoluteCenter>
            <Span opacity={0}>{children}</Span>
          </>
        ) : loading && loadingText ? (
          <>
            <Spinner size="inherit" color="inherit" />
            {loadingText}
          </>
        ) : (
          children
        )}
      </ChakraButton>
    );
  }
);


stories8も作成します

form-button.stories.tsx
import type { Meta, StoryObj } from "@storybook/react";

import { FormButton } from "./form-button";

const meta: Meta<typeof FormButton> = {
  component: FormButton,
};

export default meta;
type Story = StoryObj<typeof FormButton>;

export const Primary: Story = {
  args: {
    children: "送信",
  },
};

ひとまずこんな感じの配置にしました
image.png

そしてmain.tsを修正します
どうやらここのstoriesで読み込むパスを指定しているようです
image.png

その後起動します
立ち上げた画面が以下です
image.png

ちゃんとデフォルトのカラーがtealになっていることが確認できました
Storyを作ることで実際の画面に配置することなく見た目が確認できるのは良いと思いました

ほかにもこのように
image.png
sizeを変更したStoryを追加したり

image.png
loading中のものが作れたり

渡すpropsのパターンごとにStoryを作っておき、プロジェクトで展開すれば開発効率が向上するように感じられました
このようなものをUIカタログ、というのでしょうか?こういうのがあると「これを見て実装してください!」といえば統一ができてうれしいのかなと思います

また、metaのところにあるtagsに["autodocs"]と指定すると、自動でドキュメントを作成してくれるようです
image.png

このようなドキュメントページが生まれます
image.png

実際に使用する際のコードも表示されるようになるので、使い勝手は良いのではないでしょうか
image.png

終わりに

わからないことだらけでした
なんとか形にはなってるのでよしとしました
勉強していきます

React Router v7はまだプレリリースなので今後も変わるかもしれないです

作ったソースは以下に置きました
https://github.com/stopod/rr-chakra-conform-sample

結局cssは何をどう使っていけばいいのかわかりませんねえ

追記

2024年11月23日
RRv7 7.0.1が公開されていたので適用したらloaderDataに型が付きました
ついでに公開したソースをしれっと手直ししました

  1. ほかにもあると思います ただそこまで詳しく理解できてないので明言を避けます

  2. 真偽は不明です

  3. app配下にスニペットを追加する方法が知りたい

  4. 雰囲気でそうなんだ、で理解するのをやめてます

  5. あとから困るかもしれないし、根本解決ではない気がする

  6. よくわかってないですが、今のところこれで問題ないようにみえてます

  7. loaderDataがundfeindだからとりあえずで三項演算子を使ってみましたが、当然ですがneverになってTypeScriptもよくわかんねえなという気持ちになっています 当記事においては重要ではないのでスルーしました 業務でこんなことをしてはいけないと思う

  8. なんて呼ぶのが正しいのかがわかりません

4
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?