本文までスキップする

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

アバター画像
Takuma Yanagawa
/ 8 views

こんにちは。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   ← 新規

HabitFormコンポーネントを作る

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> に付けたクラスを見ていきます。

TailwindCSS
bg-zinc-300background-color: #d4d4d8;
rounded-fullborder-radius: 9999px;
p-4padding: 1rem;
flex-1flex: 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 で、グレーのリング(輪郭線)を表示しています。

フォーカスの枠は、キーボードで操作している人にとって「今どこを操作しているか」を知るための目印です。標準の枠を消したら、代わりの見た目を必ず用意しておきましょう。

HabitListコンポーネントを作る

次は習慣のリストです。まだ習慣を追加する処理がないので、仮のデータを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 の中身を増やせば、その数だけボタンが増えます。

date-fnsで今週の7日分を作る

仮の1日分を、今週の月曜から日曜までの7日分に差し替えます。週の始まりを求めたり、月をまたぐ日付を数えたりするのは自分で書くと大変なので、date-fns というライブラリを使います。

pnpm add date-fns
import { 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 で並べた要素を区別するための目印です。データごとに必ず違う値を使います。
  • 名前を付けて渡すProps — habit={habit} のように属性の形で渡し、受け取る側は同じ名前で { habit } と取り出します。
  • date-fns — 日付の計算を任せられるライブラリです。関数名は覚えずに、ドキュメントで探します。

これでHabit Trackerの画面がひととおり揃いました。次回は日付のボタンの見た目を整えて、未来の日付を押せないようにしていきます。前回作った Button に手を入れていく回です。
ではまた。

参考