第147章:フォーム部品化:Input/Buttonを整える🧩✨
この章は「フォームを毎回いちから書かずに、キレイに使い回せる形にする」回だよ〜😊💗 InputとButtonを“ちゃんと部品”にして、見た目も使い心地もそろえるよ!🪄
今日のゴール🎯✨
- ✅ Inputを部品化して、ラベル・エラー表示・アクセシビリティを毎回セットにする🧸
- ✅ Submitボタンを部品化して、送信中は自動で「送信中…」&押せない状態にする⏳🔒
- ✅ フォームのコードがスッキリして、増えても怖くない状態にする🧹✨
なんで部品化するの?🤔🧩
フォームって、地味に毎回同じことやりがち👇😵💫
- ラベル付ける
- 入力欄の見た目整える
- エラー表示する
- 送信中はボタン無効にする
これを部品に閉じ込めると、ページ側は「何を並べたいか」だけ書けばOKになるよ〜😊🌸
全体の形(今回の設計イメージ)🗺️✨
1) フォルダ構成を作ろう📁✨
こんな感じで置くと迷子になりにくいよ😊
components/
ui/
TextField/
TextField.tsx
TextField.module.css
SubmitButton/
SubmitButton.tsx
SubmitButton.module.css
app/
contact/
page.tsx
actions.ts
2) TextField(Input+Label+Error)を作る🧩📝
✅ ポイント(ここが“整う”コツ)💡
labelとidをセットにして、クリックでフォーカスできるようにする👆✨- エラーがあるときは
aria-invalidを付ける(やさしい)🫶 - エラー文は
aria-describedbyで関連づける(読み上げにも強い)🎧✨
components/ui/TextField/TextField.tsx
import styles from "./TextField.module.css";
type Props = {
id: string;
name: string;
label: string;
type?: React.HTMLInputTypeAttribute;
placeholder?: string;
defaultValue?: string;
required?: boolean;
autoComplete?: string;
error?: string;
};
export function TextField({
id,
name,
label,
type = "text",
placeholder,
defaultValue,
required,
autoComplete,
error,
}: Props) {
const errorId = `${id}-error`;
const describedBy = error ? errorId : undefined;
return (
<div className={styles.field}>
<label className={styles.label} htmlFor={id}>
{label}
{required ? <span className={styles.required}> *</span> : null}
</label>
<input
className={`${styles.input} ${error ? styles.inputError : ""}`}
id={id}
name={name}
type={type}
placeholder={placeholder}
defaultValue={defaultValue}
required={required}
autoComplete={autoComplete}
aria-invalid={error ? true : undefined}
aria-describedby={describedBy}
/>
{error ? (
<p className={styles.error} id={errorId} role="alert">
{error}
</p>
) : null}
</div>
);
}
components/ui/TextField/TextField.module.css
.field {
display: grid;
gap: 6px;
}
.label {
font-size: 14px;
}
.required {
color: #d33;
}
.input {
padding: 10px 12px;
border: 1px solid #bbb;
border-radius: 10px;
outline: none;
}
.input:focus {
border-color: #333;
}
.inputError {
border-color: #d33;
}
.error {
font-size: 13px;
color: #d33;
}
3) SubmitButton(送信中を自動制御)を作る⏳🔘✨
Server Actionsのフォームだと、送信中を知るのに useFormStatus() が便利だよ!
ただしこれはフックなので Client Component にする必要あり👇
components/ui/SubmitButton/SubmitButton.tsx
"use client";
import { useFormStatus } from "react-dom";
import styles from "./SubmitButton.module.css";
type Props = {
children: React.ReactNode;
pendingText?: string;
};
export function SubmitButton({ children, pendingText = "送信中…" }: Props) {
const { pending } = useFormStatus();
return (
<button className={styles.button} type="submit" disabled={pending}>
{pending ? pendingText : children}
</button>
);
}
components/ui/SubmitButton/SubmitButton.module.css
.button {
padding: 10px 14px;
border: none;
border-radius: 12px;
background: #111;
color: #fff;
cursor: pointer;
}
.button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
4) 使ってみよう(お問い合わせフォーム例)📮✨
ここでは「TextFieldとSubmitButtonを組み合わせて、ページ側をスッキリさせる」のが目的だよ😊🌸 (送信処理はダミーでOK!)
app/contact/actions.ts(Server Action)🧑🍳
"use server";
export type ContactState = {
errors: {
name?: string;
email?: string;
};
message?: string;
};
export async function sendContact(
_prev: ContactState,
formData: FormData
): Promise<ContactState> {
const name = String(formData.get("name") ?? "").trim();
const email = String(formData.get("email") ?? "").trim();
const errors: ContactState["errors"] = {};
if (name.length < 2) errors.name = "名前は2文字以上がいいかも…🥺";
if (!email.includes("@")) errors.email = "メールの形式がちょっと変かも…📧💦";
if (Object.keys(errors).length > 0) {
return { errors };
}
// 本当はここでDB保存やメール送信などするよ✉️✨
return { errors: {}, message: "送信できたよ〜!ありがとう😊💗" };
}
app/contact/page.tsx(フォーム表示)🧾✨
import { useActionState } from "react";
import { sendContact, type ContactState } from "./actions";
import { TextField } from "@/components/ui/TextField/TextField";
import { SubmitButton } from "@/components/ui/SubmitButton/SubmitButton";
const initialState: ContactState = { errors: {} };
function ContactForm() {
const [state, action] = useActionState(sendContact, initialState);
return (
<form action={action} style={{ display: "grid", gap: 14, maxWidth: 420 }}>
<TextField
id="name"
name="name"
label="お名前"
placeholder="例)さくら"
required
error={state.errors.name}
autoComplete="name"
/>
<TextField
id="email"
name="email"
label="メール"
type="email"
placeholder="例)sakura@example.com"
required
error={state.errors.email}
autoComplete="email"
/>
<SubmitButton pendingText="送信してるよ…⏳">送信する📨</SubmitButton>
{state.message ? (
<p style={{ padding: 10, border: "1px solid #bbb", borderRadius: 12 }}>
{state.message}
</p>
) : null}
</form>
);
}
export default function Page() {
return (
<main style={{ padding: 24 }}>
<h1 style={{ fontSize: 22, marginBottom: 12 }}>お問い合わせ📮</h1>
<ContactForm />
</main>
);
}
よくあるつまずきポイント集🪤😵💫
-
ラベル押してもフォーカスしない →
label htmlFor="id"とinput id="id"が一致してるかチェック👀✅ -
SubmitButtonで
useFormStatusが使えない! → ファイル先頭に"use client"が必要だよ〜🧠⚡ -
エラー文が“どの入力のエラー?”って分かりづらい →
aria-describedbyを付けると、やさしさ爆上がり🫶✨
ミニ課題(5〜15分)🧪💗
TextFieldを参考にしてTextAreaFieldを作ってみよう📝✨SubmitButtonにvariant(primary / ghost)を付けて、見た目を切り替えられるようにしてみよう🎨✨
これでフォームが一気に“プロっぽく整理された感じ”になるよ〜😊💫 次からフォーム増えても、ページ側はスッキリのままいける👍✨