メインコンテンツまでスキップ

まとめと次の一歩: フォームとデータ取得へ広げる

1 つのダイアログを実装し、カタログに載せ、3 つの層でテストしました。

この章では、4 つの道具が何を受け持ったかを先に振り返ります。そのあとで、ダイアログには出てこなかった 2 つの関心、入力の検証とデータの取得を、同じ分け方で扱います。

この章で学ぶこと​

  • Base UI、Tailwind CSS、Storybook、テストの 4 つが受け持ったこと
  • 4 つのつなぎ目でやり取りしているもの
  • 入力の検証を Zod のスキーマとして外に置き、Base UI の Form につなぐ書き方
  • データの取得をコンポーネントの外に置き、ダイアログへは props で渡す構成
  • 通信を伴う部分をテストするとき、どこを差し替えるか

この章で扱わないこと​

観点参照先
Zod のスキーマの書き方と、型の導出Zod でスキーマバリデーション
TanStack Query のキャッシュと、データの更新データフェッチング(TanStack Query)
読み込み中や失敗の状態を、テストから先に決める進め方非同期とネットワーク境界

4 つの分担を振り返る​

道具受け持ったこと自分で書いたもの扱った章
Base UI開閉の状態、キーボード操作、フォーカスの移動と閉じ込めと復帰、役割と名前、状態を表す data-* 属性パーツを並べる JSX01 から 03
Tailwind CSS色、余白、配置、状態に応じた見た目、開閉の遷移className に渡すクラス02 と 03
Storybook状態ごとの表示、a11y の自動検査、見た目の比較の入口状態を固定する story04 と 07
テスト 3 層分けた結果が保たれていることの確認役割と名前で探すテスト05 から 08

4 つは、互いの内部に触れずにつながっています。つなぎ目でやり取りしているものは、次の 3 つです。

つなぎ目やり取りしているもの
Base UI と Tailwind CSSdata-open や data-starting-style などの data-* 属性
コンポーネントと storyprops。defaultOpen で開いた状態を、notifications で中身を固定します
コンポーネントとテスト役割とアクセシブルネーム

Tailwind CSS のクラスは Base UI の状態を JavaScript で受け取っていません。story はコンポーネントの中の状態を書き換えていません。テストはクラス名を読んでいません。どのつなぎ目も、相手が外へ出しているものだけを使っています。

この形の効果は、見た目を変えてみると確かめられます。ダイアログ本体の角丸、余白、影のクラスを別の値に書き換えて、Vitest の 3 つの project と Playwright のテストをすべて実行しても、落ちるテストは 1 本もありませんでした。見た目の調整と、動作の確認が、互いに干渉しない状態になっています。

フォームに広げる​

ダイアログでは、振る舞いを Base UI に、計算を純粋関数に出しました。フォームでは、外へ出せるものがもう 1 つあります。入力の検証です。

Base UI には、フォーム用の部品として Form と Field があります1。検証には Zod を使うので、先に入れておきます。

npm install zod

題材は、件名と本文を入力して送るフォームです。入力のルールは、コンポーネントとは別のファイルに、スキーマとして置きます。

src/lib/feedback.ts
import { z } from 'zod'

/*
* 入力のルールは、コンポーネントの外にスキーマとして置く。
* 画面を描画しなくても、スキーマだけを単体でテストできる。
*/
export const feedbackSchema = z.object({
title: z.string().trim().min(1, '件名を入力してください'),
body: z.string().trim().min(10, '本文は 10 文字以上で入力してください'),
})

/** スキーマから導いた、検証済みの入力の型。 */
export type Feedback = z.infer<typeof feedbackSchema>

フォームのコンポーネントは、このスキーマを import して検証に使います。

src/components/feedback-form.tsx
import { Field } from '@base-ui/react/field'
import { Form } from '@base-ui/react/form'
import { useState } from 'react'
import { z } from 'zod'
import { type Feedback, feedbackSchema } from '../lib/feedback'

type FeedbackFormProps = {
/** 検証を通った入力を受け取る。送信そのもの (通信) は親の仕事。 */
onSubmit: (feedback: Feedback) => void
}

const controlClass =
'w-full rounded-md border border-slate-300 px-3 py-2 text-sm text-slate-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 data-invalid:border-red-600'

export function FeedbackForm({ onSubmit }: FeedbackFormProps) {
// Form に渡すエラー。キーが Field.Root の name と一致する項目に表示される
const [errors, setErrors] = useState<Form.Props['errors']>({})

return (
<Form
className="flex w-full max-w-sm flex-col gap-4"
errors={errors}
// onFormSubmit には、name をキーにした入力値のオブジェクトが渡る
onFormSubmit={(formValues) => {
const result = feedbackSchema.safeParse(formValues)

if (!result.success) {
// { title: ['件名を入力してください'], ... } の形に直して Form へ渡す
setErrors(z.flattenError(result.error).fieldErrors)
return
}

setErrors({})
onSubmit(result.data)
}}
>
{/* Field.Root の中では、ラベル・入力欄・エラーの関連付け (for や aria-describedby) が自動で付く */}
<Field.Root name="title" className="flex flex-col gap-1">
<Field.Label className="text-sm font-medium text-slate-900">件名</Field.Label>
<Field.Control className={controlClass} />
<Field.Error className="text-sm text-red-700" />
</Field.Root>

<Field.Root name="body" className="flex flex-col gap-1">
<Field.Label className="text-sm font-medium text-slate-900">本文</Field.Label>
{/* render で、描画する要素を input から textarea に差し替える */}
<Field.Control render={<textarea rows={4} />} className={controlClass} />
<Field.Error className="text-sm text-red-700" />
</Field.Root>

<button
type="submit"
className="inline-flex h-9 items-center justify-center self-end rounded-md bg-blue-600 px-4 text-sm font-medium text-white hover:bg-blue-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
送信する
</button>
</Form>
)
}

役割の分け方は、ダイアログのときと同じ形をしています。

受け持つもの置き場
入力のルール (必須かどうか、文字数)Zod のスキーマ feedbackSchema (src/lib/feedback.ts)
ラベルと入力欄とエラーの結びつき。エラーがあるときの属性とフォーカスBase UI の Form と Field
見た目Tailwind CSS のクラス
送信 (通信)親。検証を通った値を onSubmit で受け取ります

Form の errors には、項目の名前をキーにしたエラーのオブジェクトを渡します。Zod の z.flattenError が返す fieldErrors はその形をしているので、そのまま渡せます。Base UI の公式ドキュメントも、Zod と組み合わせる例でこの書き方を使っています。

検証に通らなかったときの DOM​

何も入力せずに送信したときの DOM を書き出すと、Base UI は次のことをしていました。

起きることDOM での現れ方
エラーのある入力欄が、不正な状態として支援技術に伝わる入力欄に aria-invalid="true" が付く
エラーの文言が、入力欄の説明として結ばれる入力欄の aria-describedby が、エラーの要素の id を指す
エラーのある項目に、スタイル用の属性が付くField.Root、Field.Label、Field.Control、Field.Error に data-invalid が付く
最初のエラーの項目へフォーカスが移る件名の入力欄がフォーカスを持つ

件名を入力し直すと、件名の項目からは data-invalid とエラーの文言が消え、data-valid が付きました。

data-invalid は、ダイアログの data-open と同じ使い方ができます。上のコードでは、入力欄の枠を data-invalid:border-red-600 で赤くしています。ここでも、状態を見るための JavaScript は書いていません。

スキーマとフォームを分けてテストする​

ファイルを分けたので、テストも分かれます。スキーマのテストは、値を渡して結果を見るだけです。

src/lib/feedback.test.ts
import { describe, expect, it } from 'vitest'
import { z } from 'zod'
import { feedbackSchema } from './feedback'

describe('feedbackSchema', () => {
// スキーマは純粋な検証なので、描画せずに確かめられる
it('空の件名と短い本文を、項目ごとのエラーにする', () => {
const result = feedbackSchema.safeParse({ title: ' ', body: '短い' })

expect(result.success).toBe(false)
// 通らなかったときは、どの項目がなぜ通らなかったかが error に入っている
const fieldErrors = result.success ? {} : z.flattenError(result.error).fieldErrors
expect(fieldErrors).toEqual({
title: ['件名を入力してください'],
body: ['本文は 10 文字以上で入力してください'],
})
})

it('前後の空白を取り除いた値を返す', () => {
const result = feedbackSchema.safeParse({ title: ' 要望 ', body: '書き出しの形式を増やしてほしいです' })

expect(result.data).toEqual({ title: '要望', body: '書き出しの形式を増やしてほしいです' })
})
})

フォームのテストは、描画して操作します。

src/components/feedback-form.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { FeedbackForm } from './feedback-form'

describe('FeedbackForm', () => {
it('入力が足りないまま送信すると、項目ごとにエラーを出し、親へは渡さない', async () => {
const user = userEvent.setup()
const onSubmit = vi.fn()
render(<FeedbackForm onSubmit={onSubmit} />)

await user.click(screen.getByRole('button', { name: '送信する' }))

expect(await screen.findByText('件名を入力してください')).toBeInTheDocument()
expect(screen.getByText('本文は 10 文字以上で入力してください')).toBeInTheDocument()
// エラーの文言が、入力欄の説明として結びついている
expect(screen.getByRole('textbox', { name: '件名' })).toHaveAccessibleDescription(
'件名を入力してください',
)
expect(onSubmit).not.toHaveBeenCalled()
})

it('正しく入力して送信すると、検証済みの値を親へ渡す', async () => {
const user = userEvent.setup()
const onSubmit = vi.fn()
render(<FeedbackForm onSubmit={onSubmit} />)

await user.type(screen.getByRole('textbox', { name: '件名' }), '要望')
await user.type(screen.getByRole('textbox', { name: '本文' }), '書き出しの形式を増やしてほしいです')
await user.click(screen.getByRole('button', { name: '送信する' }))

expect(onSubmit).toHaveBeenCalledWith({
title: '要望',
body: '書き出しの形式を増やしてほしいです',
})
})
})
対象確かめ方本数
feedbackSchema値を渡して結果を見ます。描画しません2
FeedbackForm描画して操作し、役割と名前で確かめます2

05 章のコラムと同じ分け方です。入力のルールの場合分けはスキーマのテストに寄せ、フォームのテストは「エラーが項目に結びついて出る」と「通ったら親へ渡る」の 2 つに絞っています。項目やルールが増えたときに増えるのは、描画の要らないスキーマのテストです。

フォームの 1 本めは、エラーの文言が画面に出たことに加えて、toHaveAccessibleDescription で、その文言が入力欄の説明として結ばれていることを確かめています。Form に errors を渡すのをやめると、このテストは落ちます。

データ取得に広げる​

03 章の終わりに、NotificationsDialog はデータをどこから取ってくるかを知らない、と書きました。実際のアプリでは、お知らせはサーバーから取得します。その処理は、ダイアログを包む別のコンポーネントに置きます。取得には TanStack Query を使います。

npm install @tanstack/react-query
src/features/notifications-container.tsx
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { NotificationsDialog } from '../components/notifications-dialog'
import { markAllRead, type Notification } from '../lib/notifications'

const queryKey = ['notifications']

type NotificationsContainerProps = {
/** お知らせを取得する関数。本番では API を呼ぶ関数を、テストでは決まった値を返す関数を渡す。 */
fetchNotifications: () => Promise<Notification[]>
}

/**
* データの取得を受け持つ「入れ物」のコンポーネント。
*
* 読み込み中・失敗・成功の出し分けはここで行い、NotificationsDialog には
* 取得できた配列だけを props で渡す。ダイアログの側は通信のことを何も知らない。
*/
export function NotificationsContainer({ fetchNotifications }: NotificationsContainerProps) {
const queryClient = useQueryClient()
const { data, isPending, isError } = useQuery({ queryKey, queryFn: fetchNotifications })

if (isPending) return <p role="status">お知らせを読み込んでいます</p>
if (isError) return <p role="alert">お知らせを読み込めませんでした</p>

return (
<NotificationsDialog
notifications={data}
// 手元のキャッシュを書き換えて、表示をすぐに既読へ変える
onMarkAllRead={() =>
queryClient.setQueryData<Notification[]>(queryKey, (current) =>
current ? markAllRead(current) : current,
)
}
/>
)
}

アプリの根元を QueryClientProvider で包む設定は、データフェッチング(TanStack Query) にあります。2 つのコンポーネントの持ち場は、次のように分かれます。

コンポーネント受け持つもの知らないこと
NotificationsContainer取得。読み込み中と失敗の表示。既読にしたときのデータの更新ダイアログの見た目と開閉
NotificationsDialog並べ方と見た目。操作を親へ伝えることデータの出どころ

NotificationsDialog のファイルは、03 章から 1 行も変えていません。04 章の story も、06 章から 08 章のテストも、手を入れずに通ります。データを props で受け取る形にしておいたので、取得の処理は外側に足すだけで済みました。

onMarkAllRead の中では、手元のキャッシュだけを書き換えています。サーバーへ既読を伝える処理は省きました。実際のアプリでは、ここで更新用の API を呼びます。書き方は データフェッチング(TanStack Query) が useMutation として扱っています。

通信をどこで差し替えるか​

取得する関数を props (fetchNotifications) で受け取っているのは、テストで差し替えるためです。

src/features/notifications-container.test.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it } from 'vitest'
import type { Notification } from '../lib/notifications'
import { NotificationsContainer } from './notifications-container'

const notifications: Notification[] = [
{ id: 'n1', title: 'メンテナンスのお知らせ', read: false },
{ id: 'n2', title: '請求書を発行しました', read: true },
]

/** テストごとに新しい QueryClient で包んで描画する (キャッシュを持ち越さないため)。 */
function renderContainer(fetchNotifications: () => Promise<Notification[]>) {
// 失敗時の再試行は待ち時間が長くなるので、テストでは切る
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })

render(
<QueryClientProvider client={queryClient}>
<NotificationsContainer fetchNotifications={fetchNotifications} />
</QueryClientProvider>,
)
}

describe('NotificationsContainer', () => {
it('読み込み中の表示を出し、取得できたらダイアログのボタンを出す', async () => {
renderContainer(() => Promise.resolve(notifications))

expect(screen.getByRole('status')).toHaveTextContent('お知らせを読み込んでいます')
expect(await screen.findByRole('button', { name: /お知らせ/ })).toBeInTheDocument()
})

it('取得に失敗したら、エラーを表示する', async () => {
renderContainer(() => Promise.reject(new Error('network error')))

expect(await screen.findByRole('alert')).toHaveTextContent('お知らせを読み込めませんでした')
})

it('すべて既読にすると、説明文が切り替わる', async () => {
const user = userEvent.setup()
renderContainer(() => Promise.resolve(notifications))
await user.click(await screen.findByRole('button', { name: /お知らせ/ }))

await user.click(screen.getByRole('button', { name: 'すべて既読にする' }))

expect(screen.getByRole('dialog', { name: 'お知らせ' })).toHaveAccessibleDescription(
'すべて確認済みです。',
)
})
})

テストは、決まった値を返す関数と、失敗する関数を渡しています。通信は一度も起きません。3 本で、読み込み中、成功、失敗の表示と、既読にしたあとの説明文を確かめています。

この方法は準備が要りません。その代わり、fetch を呼ぶ関数の中身 (URL の組み立てや、応答の解釈) は通りません。そこまで含めて確かめたいときに使われるのが MSW (Mock Service Worker) です。MSW は、アプリが出したリクエストをネットワークの層で受け止め、あらかじめ決めた応答を返すライブラリです2。

差し替える場所テストが通るコード準備
取得する関数 (本ガイドの方法)コンポーネントと TanStack Query関数を 1 つ渡すだけです
ネットワークの層 (MSW)上に加えて、fetch を呼ぶ関数応答の定義と、テストの環境への組み込みが要ります

MSW は、応答の定義を単体テストと Storybook と実ブラウザのテストで使い回せることを特徴に挙げています。3 つの層すべてで通信を伴う部品を扱うようになったら、導入を検討する時期です。

本ガイドは MSW のコードを載せていません。手元で動かしていないためです。導入の手順は公式ドキュメントを参照してください。通信の境界をどこに引くかという考え方は、非同期とネットワーク境界 が扱っています。

本ガイドで確かめていないこと​

掲載したコードは手元で動かしていますが、確かめた範囲には限りがあります。自分のプロジェクトへ持ち込むときは、次の 3 つを補ってください。

確かめていないこと補い方
Chromium 以外のブラウザでの動作08 章の設定で、Vitest の instances と Playwright の projects に Firefox と WebKit を足します
スクリーンリーダーでの読み上げ実際のスクリーンリーダーで、開く、読む、閉じるの一連を操作します。axe-core の検査に通ることは、支援技術で使いやすいことを保証しません
Chromatic による見た目の比較07 章の流れに沿って、開いたまま終わる story を対象に baseline を作ります

まとめ​

  • Base UI は振る舞いを、Tailwind CSS は見た目を、Storybook は状態の一覧と検査を、テストは分けた結果の確認を受け持ちます
  • 4 つをつなぐのは、data-* 属性、props、役割と名前です。相手の内部には触れません
  • フォームでは、入力のルールを Zod のスキーマとして外に置き、Base UI の Form に errors として渡します。スキーマは描画せずにテストできます
  • データの取得はダイアログを包むコンポーネントに置き、ダイアログへは props で渡します。ダイアログのコードも、story も、テストも変わりません
  • 通信を伴う部分は、取得する関数を差し替えるか、MSW でネットワークの層を差し替えます

次に読む​

Footnotes​

  1. Form に、errors と onFormSubmit の説明と、Zod を使った例があります。項目を構成するパーツは Field にあります。 ↩

  2. MSW の公式ドキュメント の Introduction です。ブラウザでは Service Worker の API でリクエストを受け止めることと、同じ定義を開発、テスト、Storybook で使い回せることが書かれています。 ↩