【初心者向け】ReactでモダンなUIを実装しよう【Part2】


こんにちは。UIデザイナーの梁川です。React入門シリーズの第3回です。
前回はPrev/Nextボタンの重複を Button コンポーネントにまとめました。今回はヘッダーの下に、習慣を追加するための入力フォームと、習慣のリストを作っていきます。

テキストの入力欄と「Add」ボタンが横に並んだフォームと、習慣を表示するカードを作ります。カードの中には、今週の日付のボタンを7つ並べます。
入力した内容を追加したり、日付をクリックして記録したりする処理は、Stateを学ぶ回で実装します。今回は画面の組み立てまでを進めます。
src/
├── App.jsx
├── main.jsx
├── index.css
└── components/
├── Button.jsx
├── Header.jsx
├── HabitForm.jsx ← 新規
└── HabitList.jsx ← 新規src/components/ に HabitForm.jsx を用意します。
// src/components/HabitForm.jsx
import { Button } from './Button';
export function HabitForm() {
return (
<form className="flex gap-2">
<input
type="text"
placeholder="Add a new habit"
className="bg-zinc-300 rounded-full p-4 flex-1 focus-visible:ring-2 focus-visible:ring-zinc-400 focus:outline-none"
/>
<Button type="submit">Add</Button>
</form>
);
}<form> の中に、入力欄の <input> と、前回作った Button を並べています。Header と同じフォルダにあるので、import のパスは './Button' です。
<input> は /> で閉じるtype="text" や placeholder は、HTMLとまったく同じ書き方です。ただ1つだけ違うのが、最後の /> です。
HTMLでは <input> だけで書けますが、JSXでは中身を持たないタグも必ず閉じる必要があります。/> を忘れると、画面が表示されずにエラーになります。<img /> や <br /> も同じです。
前回の <Header /> と同じ書き方なので、「中身がないタグは /> で閉じる」と覚えておけば大丈夫です。
続いて App.jsx から呼び出して、Header の下に並べます。
import { Header } from './components/Header';
import { HabitForm } from './components/HabitForm';
export default function App() {
return (
<div className="max-w-6xl mx-auto p-4 flex flex-col gap-4">
<Header />
<HabitForm />
</div>
);
}ブラウザを確認すると、ヘッダーの下に入力欄とボタンが表示されていると思います。
<input> に付けたクラスを見ていきます。
| Tailwind | CSS |
|---|---|
bg-zinc-300 | background-color: #d4d4d8; |
rounded-full | border-radius: 9999px; |
p-4 | padding: 1rem; |
flex-1 | flex: 1; |
focus:outline-none | :focus { outline-style: none; } |
focus-visible:ring-2 | :focus-visible { box-shadow: 0 0 0 2px ...; } |
focus-visible:ring-zinc-400 | リングの色を #a1a1aa にする |
flex-1 で余った幅を埋める親の <form> に flex を付けているので、入力欄とボタンは横に並びます。入力欄に flex-1 を付けると、ボタンの幅を残して、余った幅を入力欄が全部使うようになります。
入力欄をクリックすると、ブラウザ標準の青い枠が表示されます。デザインに合わないので focus:outline-none で消していますが、代わりに focus-visible:ring-2 で、グレーのリング(輪郭線)を表示しています。
フォーカスの枠は、キーボードで操作している人にとって「今どこを操作しているか」を知るための目印です。標準の枠を消したら、代わりの見た目を必ず用意しておきましょう。
次は習慣のリストです。まだ習慣を追加する処理がないので、仮のデータを1件用意して表示します。
export function HabitList() {
const habits = [
{ id: 1, name: "New Habit" },
];
if (habits.length === 0) {
return (
<p className="text-zinc-400 text-center py-20">No habits found</p>
);
}
return (
<div className="flex flex-col gap-3">
{habits.map(habit => (
<HabitItem key={habit.id} habit={habit} />
))}
</div>
);
}
function HabitItem({ habit }) {
return (
<div className="rounded-3xl bg-zinc-300 flex flex-col p-8 gap-5">
<span className="font-medium pl-2">{habit.name}</span>
</div>
);
}App.jsx にも HabitList を足します。
// App.jsx
import { Header } from './components/Header';
import { HabitForm } from './components/HabitForm';
import { HabitList } from './components/HabitList';
export default function App() {
return (
<div className="max-w-6xl mx-auto p-4 flex flex-col gap-4">
<Header />
<HabitForm />
<HabitList />
</div>
);
}HabitList.jsx の中には、HabitList と HabitItem の2つのコンポーネントがあります。HabitItem はリストの中でしか使わないので、export を付けずに同じファイルに置いています。
if (habits.length === 0) の部分は、習慣が1件もないときに「No habits found」とだけ表示する処理です。return した時点で関数は終わるので、その下のリストは表示されません。
試しに habits の中身を空の配列 [] にしてみると、メッセージに切り替わるのを確認できます。
.map で並べるリストの部分を見ていきます。
{habits.map(habit => (
<HabitItem key={habit.id} habit={habit} />
))}.map は配列の中身を1つずつ取り出して、別のものに変換した新しい配列を返します。
ここでは、{ id: 1, name: "New Habit" } というデータを、<HabitItem> というJSXに変換しています。変換したJSXの配列を { } の中に置くと、Reactはそれを順番に並べて表示してくれます。
habits にデータを足してみると、その数だけカードが増えるのを確認できます。
const habits = [
{ id: 1, name: "New Habit" },
{ id: 2, name: "Read a book" },
];key はReactのための目印.map で並べるときは、それぞれの要素に key を付けます。Reactはこの key を見て、どの要素がどのデータなのかを区別しています。付け忘れると、ブラウザのコンソールに警告が表示されます。
key には、データごとに必ず違う値を使います。habit.name でも今は動きますが、同じ名前の習慣を2つ登録すると key が重なってしまうので、専用の id を使っています。
habit={habit} で1件分のデータを渡すHabitItem の中で使っている habit は、この habit={habit} から渡ってきています。
前回は <Button>Prev</Button> のように、タグの間に挟んだものを children として受け取りました。Propsは、HTMLの属性のように自分で名前を付けて渡すこともできます。
<HabitItem habit={habit} />
// ^^^^^ ^^^^^
// 名前 渡す値(.mapで取り出した1件分のデータ)左の habit がPropsの名前、右の {habit} が渡す値です。同じ単語が2回出てくるので紛らわしいですが、役割は別です。
Propsは1つのオブジェクトにまとめて渡されるので、HabitItem が受け取るのは次のようなデータです。
{ habit: { id: 1, name: "New Habit" } }これを { habit } の分割代入で取り出しているので、HabitItem の中では habit.name で習慣の名前を表示できます。Button({ children }) と同じ書き方です。
受け取る側は、渡す側で付けた名前と同じ名前で取り出します。<HabitItem item={habit} /> と書いたなら、受け取る側は HabitItem({ item }) になります。
HabitItem の中に、日付のボタンを並べていきます。まずは今日の日付だけを入れた配列で試してみます。
function HabitItem({ habit }) {
const visibleDates = [new Date()];
return (
<div className="rounded-3xl bg-zinc-300 flex flex-col p-8 gap-5">
<span className="font-medium pl-2">{habit.name}</span>
<div className="flex gap-1.5">
{visibleDates.map(date => (
<Button key={date.toISOString()}>
{date.toString()}
</Button>
))}
</div>
</div>
);
}HabitList.jsx の先頭に import { Button } from './Button'; を足すのも忘れないようにしてください。
new Date() は、今この瞬間の日時を表すデータを作るJavaScriptの機能です。date.toString() で文字列にすると、ボタンに長い日時の文字列が表示されると思います。
key には date.toISOString() を使っています。key には文字列か数値を使うので、2026-10-13T... のような文字列に変換しています。
やっていることは habits.map とまったく同じです。visibleDates の中身を増やせば、その数だけボタンが増えます。
仮の1日分を、今週の月曜から日曜までの7日分に差し替えます。週の始まりを求めたり、月をまたぐ日付を数えたりするのは自分で書くと大変なので、date-fns というライブラリを使います。
pnpm add date-fnsimport { Button } from './Button';
import { startOfWeek, endOfWeek, eachDayOfInterval, format } from 'date-fns';
// ...
function HabitItem({ habit }) {
const visibleDates = eachDayOfInterval({
start: startOfWeek(new Date(), { weekStartsOn: 1 }),
end: endOfWeek(new Date(), { weekStartsOn: 1 }),
});
return (
<div className="rounded-3xl bg-zinc-300 flex flex-col p-8 gap-5">
<span className="font-medium pl-2">{habit.name}</span>
<div className="flex gap-1.5">
{visibleDates.map(date => (
<Button key={date.toISOString()}>
<span className="text-xs text-zinc-500">{format(date, "EEE")}</span>
<span>{format(date, "d")}</span>
</Button>
))}
</div>
</div>
);
}使っている関数は4つです。
| 関数 | 返すもの |
|---|---|
startOfWeek(date, { weekStartsOn: 1 }) | その日を含む週の月曜日 |
endOfWeek(date, { weekStartsOn: 1 }) | 同じ週の日曜日 |
eachDayOfInterval({ start, end }) | start から end までの日付を全部並べた配列 |
format(date, "EEE") / format(date, "d") | Mon のような曜日の略称 / 13 のような日にち |
weekStartsOn: 1 は「週の始まりを月曜にする」という指定です。指定しないと日曜始まりになります。
関数の名前を全部覚える必要はありません。「週の始まり」「日付の範囲」のように、やりたいことをdate-fnsのドキュメントで探せば見つかります。
Button でできないことブラウザを確認すると、カードの中に7つのボタンが並んでいると思います。ただ、日付のボタンもPrev/Nextと同じ黒いボタンのままで、曜日と日にちも横にくっついて表示されています。
日付のボタンはカードに合わせて明るい色にして、曜日と日にちを縦に並べたいところです。でも今の Button は children しか受け取らないので、外から見た目を変える方法がありません。
フォームの <Button type="submit"> も同じ状態です。type="submit" を渡していますが、Button の中の <button> には届いていません。<form> の中の <button> は指定がなければ送信ボタンとして扱われるので、たまたま動いているだけです。
次回は、この Button を外から見た目や動きを変えられるように作り直していきます。
第3回は以上です。おつかれさまでした。
今回出てきたことをおさらいしておきましょう。
/> で閉じる — JSXでは <input /> のように、中身を持たないタグも必ず閉じます。flex-1 — 親が flex のとき、余った幅を全部使います。Figmaの「Fill container」と同じ考え方です。focus:outline-none で標準の枠を消したら、focus-visible:ring などで代わりの見た目を用意しておきます。.map — 配列の中身をJSXに変換して並べます。JavaScriptの配列のメソッドです。key — .map で並べた要素を区別するための目印です。データごとに必ず違う値を使います。habit={habit} のように属性の形で渡し、受け取る側は同じ名前で { habit } と取り出します。これでHabit Trackerの画面がひととおり揃いました。次回は日付のボタンの見た目を整えて、未来の日付を押せないようにしていきます。前回作った Button に手を入れていく回です。
ではまた。
