はじめに
フロントの勉強がてらまとめます
やったことベースに記載し、わからないことはスルーします
極力注釈に残し、勉強材料にします
やりたいこと
-
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は公式がほとんど有用ではない、みたいなことを言ってます
であればドキュメントの通りの思想で開発していけばあまり意識しなくてもいいのか?と思ってます
ほかにもあると思います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
プログレッシブエンハンスメント、という単語を初めて知りました
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配下にコンポーネントがもらえます
ただReact Routerのインストール直後はapp配下でソースを管理してるので3、components配下をappに移動させます
その後はプロバイダーの設定をします
前の工程でスニペットを追加した中にProviderが存在するので、それを使います
ただこれを追加すると下記のように、ブラウザでエラーが吐かれちゃいます
Warning: Extra attributes from the server: class,style...
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
そうすると不思議なことに、黒のボタンになってほしいところが白のままです
これはroot.tsxでインポートしてるapp.cssを消すと直ります
こいつがなにものかというと、Tailwind CSSの初期設定と背景色の設定、ダークモードの適用をやってるようにみえます ひとまずなくてもいいやという気持ちになったので消します5 Tailwind CSSもそこまで使わないことから消してしまいます
コマンドをたたいて、tailwind.config.tsを消してpostcss.config.jsからtailwindcssのkeyを消します
$ npm uninstall tailwindcss --save-dev
Conform
フォームを作るときに使うので今はここまでです
$ npm install @conform-to/react @conform-to/zod --save
Storybook
Storybookも追加していきます
まずはインストールします
https://storybook.js.org/docs/get-started/install
$ npx storybook@latest init
インストールが完了すると、自動的に立ち上がりますが、以下のようなエラーがでます
原因はここに記載されています
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を以下のように書き換えます
これで再度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
import { ChakraProvider, defaultSystem } from "@chakra-ui/react"
を使用していますが、これだと"next-themes"のThemeProviderを使用しない書き方になるような気がしてます
ここは問題ないのでしょうか?ちょっとわかりません6
最後にもう一度立ち上げてみます
最初から用意されているサンプルは問題なく閲覧出来てそうです
また、Chakra UIのサンプルも見えるようになっています
フォーム画面の作成
今回は仮定として、人間の情報を登録するためのフォーム画面を作成します
なので以下の入力を必要と想定します
- 氏名
- 生年月日
- 都道府県
以下を作成しました
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しているわけでもないので単純に同ファイルに追加しました
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に入力内容を表示するようにしました
登録を押した際にconsoleに表示されるのも確認できました
実装の説明
loader
この部分です
今回の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で受け取ります
ちょっとここからが謎ですが、loaderDataがundefinedと推論されています
ただデータ自体はちゃんと存在しています
Remixの時はちょっと読み取り方が異なっており、useLoaderDataというhooksで受け取っていました
この時は型がちゃんと推論されていたのですが、React Router v7からはちょっとお作法が変わった?ということもあってもうちょっと調べる必要がありそうです7
https://remix.run/docs/ja/main/route/loader#type-safety
schema
ここはzodのお作法です
氏名と誕生日、都道府県だけなのでstringとdateで済ませました
https://zod.dev/?id=strings
https://zod.dev/?id=dates
住所は何も選択しなくても北海道が選ばれるので必須ではありますが、必須の際のエラーを設定していません
コンポーネント
default export されているコンポーネント、ここではHomeを指します
useForm
この部分です
これは特にConfomのチュートリアルから変更していません
ここをいじることでバリデーションのタイミングを変更できるようです
https://ja.conform.guide/api/react/useForm
JSX
returnの中身です
cssを書かずにChakraUIのコンポーネントだけでそれなりにできたので簡単でした
Form(ReactRouter)に入力内容を入れてsubmitすることでactionにいきます
結構シンプルなんじゃないかな?と感じました
Confromにはヘルパー関数が用意されており、シンプルな入力フォームなら本当に簡単に実装できる印象を覚えました
react-selectなど、ちょっと変わったやつでもラッパーを作ったりヘルパー関数を自作することで対応することができそうです
action
formからsubmitされてから、入力データを受け取る場所です
parseWithZodがConformから提供されている部品で、これを使ってサーバーサイドでバリデーションを行っています
先に述べたuseFormの中でもこれを指定しており、クライアント側と両方で検証をしています
こうすることで、プログレッシブエンハンスメントを尊重しているのかなという理解です
(仮にjavascriptを無効化していても、サーバー側で検証できるのでok、でも無効化していない人は即時に検証されることでユーザー体験が向上する)
Storyを作る
ちょっとここまでで満足しちゃった感がありますが、一応Storyも作ります
例えば送信ボタンの色を固定しちゃいたいときとか、カスタマイズされた部品を用意して使いまわす想定でいきます
ということでスニペットのボタンをコピーして、colorPalette={"teal"}
だけを追加したform-buttonというものを作成します
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も作成します
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: "送信",
},
};
そしてmain.tsを修正します
どうやらここのstoriesで読み込むパスを指定しているようです
ちゃんとデフォルトのカラーがtealになっていることが確認できました
Storyを作ることで実際の画面に配置することなく見た目が確認できるのは良いと思いました
ほかにもこのように
sizeを変更したStoryを追加したり
渡すpropsのパターンごとにStoryを作っておき、プロジェクトで展開すれば開発効率が向上するように感じられました
このようなものをUIカタログ、というのでしょうか?こういうのがあると「これを見て実装してください!」といえば統一ができてうれしいのかなと思います
また、metaのところにあるtagsに["autodocs"]と指定すると、自動でドキュメントを作成してくれるようです
実際に使用する際のコードも表示されるようになるので、使い勝手は良いのではないでしょうか
終わりに
わからないことだらけでした
なんとか形にはなってるのでよしとしました
勉強していきます
React Router v7はまだプレリリースなので今後も変わるかもしれないです
作ったソースは以下に置きました
https://github.com/stopod/rr-chakra-conform-sample
結局cssは何をどう使っていけばいいのかわかりませんねえ
追記
2024年11月23日
RRv7 7.0.1が公開されていたので適用したらloaderDataに型が付きました
ついでに公開したソースをしれっと手直ししました
-
ほかにもあると思います ただそこまで詳しく理解できてないので明言を避けます ↩
-
真偽は不明です ↩
-
app配下にスニペットを追加する方法が知りたい ↩
-
雰囲気でそうなんだ、で理解するのをやめてます ↩
-
あとから困るかもしれないし、根本解決ではない気がする ↩
-
よくわかってないですが、今のところこれで問題ないようにみえてます ↩
-
loaderDataがundfeindだからとりあえずで三項演算子を使ってみましたが、当然ですがneverになってTypeScriptもよくわかんねえなという気持ちになっています 当記事においては重要ではないのでスルーしました 業務でこんなことをしてはいけないと思う ↩
-
なんて呼ぶのが正しいのかがわかりません ↩