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

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


# やりたいこと
- React Router v7(旧Remix)を触ってみる
https://remix.run/blog/merging-remix-and-react-router

- Chakra UI v3を使ってみる
https://www.chakra-ui.com/blog/00-announcing-v3

- Storybookを使用してUIカタログを作ってみる

# 利用スタック
- React Router
https://reactrouter.com/dev
プレリリースのものを使います

- chakra-ui
https://www.chakra-ui.com/
v3を使用します

- Conform
https://ja.conform.guide/

- Storybook
https://storybook.js.org/
テストのためには使用しません

# 概要説明
### 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は公式がほとんど有用ではない、みたいなことを言ってます
であればドキュメントの通りの思想で開発していけばあまり意識しなくてもいいのか？と思ってます

ほかにもあると思います[^4]

そしてこの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

https://ja.conform.guide/
> 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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3468047/2861e27d-f7df-89f6-d91a-4873ef6434b4.png)
プログレッシブエンハンスメント、という単語を初めて知りました
Remixの公式でもこの単語が登場した記憶があります

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

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

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

### Storybook
https://storybook.js.org/

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

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

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

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

### React Router
https://reactrouter.com/dev/framework/start/installation#starter-template

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

---
### Chakra UI
https://www.chakra-ui.com/docs/get-started/installation

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

下記を実行します
```bash
$ npm i @chakra-ui/react @emotion/react
```

スニペットを追加します
Chakra UI v3からはshadcn-uiみたいな感じになったようです
```bash
$ npx @chakra-ui/cli snippet add
```
これやるとこんな感じでsrc配下にコンポーネントがもらえます
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3468047/146d4778-fa62-692f-3204-259cbede7f86.png)

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

その後はプロバイダーの設定をします
前の工程でスニペットを追加した中にProviderが存在するので、それを使います
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3468047/b34e7100-5628-dd83-e522-315daebd1a1e.png)

ただこれを追加すると下記のように、ブラウザでエラーが吐かれちゃいます
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3468047/814e1970-a94e-1ef1-0828-ceba70c93d31.png)

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

これを追加すると警告は消えます
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3468047/e6dc0c8e-91da-e698-a3e2-5801e6fcd6ec.png)

`suppressHydrationWarning`はサーバーとクライアントで属性が異なるときの警告を無視するものらしいです[^2]
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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3468047/a44ab93e-236a-fe49-8c58-5847a902eaa8.png)

そうすると不思議なことに、黒のボタンになってほしいところが白のままです
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3468047/ee1fb5c5-01ba-23ee-8d6f-24e66561d17c.png)

これはroot.tsxでインポートしてるapp.cssを消すと直ります
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3468047/a6cf5e66-2aec-5c4e-5219-d85dcf5b441a.png)

こいつがなにものかというと、Tailwind CSSの初期設定と背景色の設定、ダークモードの適用をやってるようにみえます ひとまずなくてもいいやという気持ちになったので消します[^3] Tailwind CSSもそこまで使わないことから消してしまいます
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3468047/471cfd4b-f439-71db-0a5d-643fb3c6f46f.png)

コマンドをたたいて、tailwind.config.tsを消してpostcss.config.jsからtailwindcssのkeyを消します
```bash
$ npm uninstall tailwindcss --save-dev
```

無事表示されるようになりました
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3468047/389203a2-83e0-b8c5-eaca-6ff138c95682.png)


---
### Conform

フォームを作るときに使うので今はここまでです
```bash
$ npm install @conform-to/react @conform-to/zod --save
```

---
### Storybook

Storybookも追加していきます

まずはインストールします
https://storybook.js.org/docs/get-started/install
```bash
$ npx storybook@latest init
```

インストールが完了すると、自動的に立ち上がりますが、以下のようなエラーがでます
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3468047/84731190-cd67-8384-2c48-a11ee0f282e2.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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3468047/0320712b-eee1-dbe2-4ec6-e9c813e50abc.png)

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

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

アドオン等をインストール
```bash
$ npm i @storybook/addon-themes @chakra-ui/react @emotion/react
```
preview.tsxを修正
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3468047/aec89430-d66c-1f23-ee5f-c282a157bc58.png)
手順通りにやると
```ts
import { ChakraProvider, defaultSystem } from "@chakra-ui/react"
```
を使用していますが、これだと"next-themes"のThemeProviderを使用しない書き方になるような気がしてます
ここは問題ないのでしょうか？ちょっとわかりません[^5]

最後にもう一度立ち上げてみます
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3468047/ed666e1b-7683-c417-31b6-07c279202112.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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3468047/3792b48a-f861-ba8a-3efa-79110a71d89f.png)

バリデーションも効いているようです
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3468047/fc9eba61-cf61-8508-a740-8ac35d193dc8.png)

登録を押した際にconsoleに表示されるのも確認できました
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3468047/5c2b0bd4-58d3-51c9-c31e-55e70cc03d43.png)

# 実装の説明

### loader
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3468047/4d030d72-c401-c7cc-8bb5-4142e52f163b.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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3468047/2833ae33-03a6-3905-68ee-e207a1c76518.png)
ちょっとここからが謎ですが、loaderDataがundefinedと推論されています
ただデータ自体はちゃんと存在しています
Remixの時はちょっと読み取り方が異なっており、useLoaderDataというhooksで受け取っていました
この時は型がちゃんと推論されていたのですが、React Router v7からはちょっとお作法が変わった？ということもあってもうちょっと調べる必要がありそうです[^6]
https://remix.run/docs/ja/main/route/loader#type-safety

### schema
ここはzodのお作法です
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3468047/3ef6863a-b52a-f203-2a46-b54ed465afcb.png)
氏名と誕生日、都道府県だけなのでstringとdateで済ませました
https://zod.dev/?id=strings
https://zod.dev/?id=dates
住所は何も選択しなくても北海道が選ばれるので必須ではありますが、必須の際のエラーを設定していません

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

#### useForm
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3468047/7094042c-a52b-a521-2082-43544eab96ce.png)
この部分です
これは特にConfomのチュートリアルから変更していません
ここをいじることでバリデーションのタイミングを変更できるようです
https://ja.conform.guide/api/react/useForm

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

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

### action
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3468047/87bf1692-ba68-2639-f11f-0d8b3d8a9728.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>
    );
  }
);


```

stories[^7]も作成します
```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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3468047/5e484b36-05d3-37a2-b8b3-caf607fe95a3.png)

そしてmain.tsを修正します
どうやらここのstoriesで読み込むパスを指定しているようです
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3468047/78c8fab6-6864-33bf-a253-1cfe8ddc15a8.png)

その後起動します
立ち上げた画面が以下です
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3468047/17c3b887-e0eb-ab47-a42b-f52a61067b1e.png)

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

ほかにもこのように
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3468047/55b13958-500e-0c2b-b480-8a56f234afe0.png)
sizeを変更したStoryを追加したり

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3468047/f3d02d7f-fecc-aa1b-4482-867c97f8ab04.png)
loading中のものが作れたり

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

また、metaのところにあるtagsに["autodocs"]と指定すると、自動でドキュメントを作成してくれるようです
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3468047/91e1ecb8-7165-a26e-0aef-6b9409ffb636.png)

このようなドキュメントページが生まれます
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3468047/a5e24ab8-21e6-a146-d840-b32e456d9b62.png)

実際に使用する際のコードも表示されるようになるので、使い勝手は良いのではないでしょうか
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3468047/8f84e6a6-fae1-03a8-892a-f0a6c0950a3e.png)

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

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

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

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

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

[^1]: app配下にスニペットを追加する方法が知りたい
[^2]: 雰囲気でそうなんだ、で理解するのをやめてます
[^3]: あとから困るかもしれないし、根本解決ではない気がする
[^4]: ほかにもあると思います ただそこまで詳しく理解できてないので明言を避けます
[^5]: よくわかってないですが、今のところこれで問題ないようにみえてます
[^6]: loaderDataがundfeindだからとりあえずで三項演算子を使ってみましたが、当然ですがneverになってTypeScriptもよくわかんねえなという気持ちになっています 当記事においては重要ではないのでスルーしました 業務でこんなことをしてはいけないと思う
[^7]: なんて呼ぶのが正しいのかがわかりません
[^8]: 真偽は不明です