React.js

React.js入門:コンポーネント・props・stateを読書リストで学ぶ

この記事でわかること

React.jsのコンポーネント・props・stateを、検索と読了切り替え付きの読書リストで学びます。TypeScriptの完全なコード例、配列更新、key、useEffectの判断を解説。

Reactは、画面をコンポーネントに分け、データに応じて表示を組み立てるJavaScriptライブラリです。この記事ではTypeScriptで小さな読書リストを作り、コンポーネント、props、state、イベント処理の関係を学びます。検索、未読だけの表示、読了状態の切り替えまで実装します。

この記事で作るものと前提

読書リストの機能

  • タイトルを入力して本を絞り込みます。
  • 「未読だけ表示」で読了済みの本を隠します。
  • 各行のチェックボックスで読了状態を切り替えます。
  • 未読数と検索結果件数を表示します。

データはブラウザー上のメモリーに保持します。再読み込みすると初期状態に戻り、サーバーやストレージには保存しません。まず表示とデータ更新の基本に集中する構成です。

必要な知識と開発環境

JavaScriptの配列操作と関数、TypeScriptの基本的な型を知っていると読み進めやすくなります。JSXはJavaScriptの中にHTMLに似た記法で画面を書く仕組みです。TypeScriptでJSXを書くファイルには拡張子.tsxを使います。

独立したReactアプリを学ぶため、ここではViteのReact・TypeScriptテンプレートを使います。実際のサービスではルーティングやサーバー処理などの要件から、Next.jsなどのフレームワークも検討します。ViteそのものがReactのルーティング機能を提供するわけではありません。

プロジェクトを作成する

Viteのテンプレートを起動する

サポート中のNode.js LTSを用意し、利用するViteのNode.js要件を公式ガイドで確認してください。別の作業用ディレクトリで、次のコマンドを実行します。

npm create vite@latest react-reading-list -- --template react-ts
cd react-reading-list
npm install
npm run dev

ターミナルに表示されるローカルURLを開きます。テンプレートの依存バージョンは作成時点で変わるため、生成されたpackage.jsonとロックファイルをプロジェクトで管理してください。

編集するファイルを確認する

次の4ファイルを作成または置き換えます。テンプレートのsrc/main.tsxはそのまま利用し、src/index.cssの読み込みも残します。

src/
  App.tsx
  BookRow.tsx
  ReadingList.tsx
  index.css

コンポーネントとpropsで本の行を作る

BookRow.tsxで型と表示を定義する

src/BookRow.tsxを作成します。コンポーネント名は大文字で始めます。Bookは本のデータ構造、BookRowPropsは親から受け取る値の型です。

export type Book = {
  id: string;
  title: string;
  read: boolean;
};

type BookRowProps = {
  book: Book;
  onToggle: (id: Book['id']) => void;
};

export function BookRow({ book, onToggle }: BookRowProps) {
  return (
    <li>
      <label>
        <input
          type="checkbox"
          checked={book.read}
          onChange={() => onToggle(book.id)}
        />
        <span>{book.title}:{book.read ? '読了' : '未読'}</span>
      </label>
    </li>
  );
}

checkedが現在の読了状態を表示し、onChangeが操作を親に伝えます。labelでチェックボックスと説明を関連付けているので、文字をクリックしても操作できます。

propsを直接変更しない

propsは親から渡される入力です。子の中でbook.read = trueのように変更せず、onToggle(book.id)を呼んで更新を親に任せます。この構成では、読書リスト全体のデータを親が管理し、子は表示と操作の通知を担当します。

stateとイベントで読書リストを動かす

ReadingList.tsxで必要なstateだけを保持する

src/ReadingList.tsxを作成します。保存するstateは、本の配列、検索語、未読フィルターの3つです。検索結果と未読数は、この3つから計算できます。

import { useState } from 'react';
import { BookRow, type Book } from './BookRow';

const initialBooks: Book[] = [
  { id: 'react-basics', title: 'Reactの基本', read: false },
  { id: 'typescript-types', title: 'TypeScriptの型', read: true },
  { id: 'web-accessibility', title: 'Webアクセシビリティ', read: false },
];

export function ReadingList() {
  const [books, setBooks] = useState<Book[]>(initialBooks);
  const [query, setQuery] = useState('');
  const [unreadOnly, setUnreadOnly] = useState(false);

  const keyword = query.trim().toLocaleLowerCase('ja-JP');
  const visibleBooks = books.filter((book) =>
    book.title.toLocaleLowerCase('ja-JP').includes(keyword)
    && (!unreadOnly || !book.read)
  );
  const unreadCount = books.filter((book) => !book.read).length;

  function toggleBook(id: Book['id']) {
    setBooks((currentBooks) => currentBooks.map((book) =>
      book.id === id ? { ...book, read: !book.read } : book
    ));
  }

  return (
    <section aria-labelledby="reading-list-heading">
      <h2 id="reading-list-heading">読みたい本</h2>
      <label className="search-label">
        タイトルで検索
        <input
          type="search"
          value={query}
          onChange={(event) => setQuery(event.target.value)}
          placeholder="例:React"
        />
      </label>
      <label>
        <input
          type="checkbox"
          checked={unreadOnly}
          onChange={(event) => setUnreadOnly(event.target.checked)}
        />
        未読だけ表示
      </label>
      <p role="status">
        全{books.length}冊のうち未読{unreadCount}冊・表示{visibleBooks.length}冊
      </p>
      {visibleBooks.length > 0 ? (
        <ul>
          {visibleBooks.map((book) => (
            <BookRow key={book.id} book={book} onToggle={toggleBook} />
          ))}
        </ul>
      ) : (
        <p>条件に合う本がありません。検索語やフィルターを変更してください。</p>
      )}
    </section>
  );
}

配列を直接変更せず、新しい値を渡す

setBooksには更新関数を渡しています。Reactが渡す更新前の配列を基にmapで新しい配列を作り、対象の本だけをスプレッド構文でコピーして変更します。更新前の配列やオブジェクトを直接書き換えないので、更新関数を純粋に保てます。

stateの更新は、その場で現在の変数を書き換える操作ではありません。更新が反映された次のレンダーで、新しいbooksを使って画面が組み立てられます。前のstateから次のstateを作る処理には、ここで使った更新関数が適しています。

安定したkeyで行を識別する

key={book.id}は、Reactが兄弟要素の中で同じ本を追跡するための識別子です。絞り込みや並び替えがある一覧で配列の位置をkeyにすると、行の対応が変わって問題になることがあります。レンダーごとに乱数を作る方法も避け、本に紐づく安定したIDを使います。

keyはReact用の特別な値なので、子の通常のpropsには渡されません。この例では子が必要とするIDをbook.idから取得します。

アプリに組み込んで動作を確認する

App.tsxを置き換える

src/App.tsxを次の内容にします。テンプレートのApp.cssの読み込みは、このファイルに残さなくて構いません。

import { ReadingList } from './ReadingList';

export default function App() {
  return (
    <main>
      <h1>小さな読書リスト</h1>
      <p>気になる本を探して、読み終えたらチェックしましょう。</p>
      <ReadingList />
    </main>
  );
}

index.cssで読みやすく整える

src/index.cssを置き換えます。キーボード操作時にフォーカス位置がわかるスタイルも指定します。

:root {
  font-family: system-ui, sans-serif;
  color: #172b35;
  background: #f1f7fa;
  line-height: 1.7;
}
* { box-sizing: border-box; }
body { margin: 0; }
main {
  max-width: 48rem;
  margin: 3rem auto;
  padding: 1.5rem;
}
section {
  background: white;
  padding: 1.5rem;
  border-radius: 1rem;
}
label { display: flex; align-items: center; gap: 0.75rem; }
.search-label { display: grid; margin-bottom: 1rem; }
input[type="search"] {
  width: 100%;
  min-width: 0;
  padding: 0.75rem;
  font: inherit;
  border: 1px solid #607985;
  border-radius: 0.5rem;
}
input[type="checkbox"] { width: 1.2rem; height: 1.2rem; flex-shrink: 0; }
input:focus-visible { outline: 3px solid #007f9c; outline-offset: 3px; }
ul { list-style: none; padding: 0; }
li { padding: 0.75rem 0; border-bottom: 1px solid #d7e2e8; }

操作とビルドを確認する

初期状態は全3冊、未読2冊、表示3冊です。「React」で検索すると1冊になり、未読フィルターを有効にしたままその本を読了にすると一覧から消えます。これは現在の条件に合わなくなったためで、本のデータが削除されたわけではありません。

  • 検索語を消すと、フィルターに合う本が再表示されることを確認します。
  • 存在しないタイトルで検索すると、0件の案内が出ることを確認します。
  • Tabキーで入力欄とチェックボックスに移動し、Spaceキーで切り替えられることを確認します。
  • ページを再読み込みすると初期状態へ戻ることを確認します。

開発サーバーとは別のターミナルで、ビルドも確認します。

npm run build

よくある疑問と設計のポイント

検索結果をstateに保存しないのはなぜですか

検索結果はbooksqueryunreadOnlyから計算できる値です。別のstateに保存すると、元のデータとの同期処理が増えます。まずはレンダー中に計算し、大量のデータで処理時間が問題になってから計測と最適化を検討します。

useEffectでフィルター処理をする必要はありますか

この実装には必要ありません。画面を描くために既存のデータを絞り込むだけなら、レンダー中の計算で足ります。Effectは外部システムとの同期などに使うもので、計算できる値を別のstateへ転記するために追加すると処理が複雑になります。

Next.jsでもそのまま使えますか

基本のReactの考え方は共通です。Next.jsのApp Routerでは、stateやイベント処理を使う入口のコンポーネントに'use client'が必要です。たとえばReadingList.tsxの先頭、importより前に指定します。Viteの今回の構成には必要ありません。Pages Routerにもこの指定は不要です。CSSの読み込み方などは、利用するルーターの構成に合わせてください。

データを保存するには何を追加しますか

ブラウザー内だけに残す場合はストレージ、複数端末で共有する場合はAPIやデータベースが候補です。保存先が決まると、読み込み状態、失敗時の表示、データ形式の検証も必要になります。今回のサンプルは、これらを含まないメモリー上の練習用アプリです。

次に学ぶことと参考資料

型とアプリ設計の理解を広げる

propsの型定義に慣れたら、TypeScriptの記事一覧で型の使い方を深めてみてください。ページ構成やWebアプリの開発に進む場合は、Next.jsの記事一覧も参考になります。

公式ドキュメント

コンポーネントに表示を分け、propsで値と操作を渡し、stateの更新から画面を組み立てる。この流れを動くコードで確かめると、機能を増やすときもデータの置き場所を判断しやすくなります。

y.
WRITTEN BY

y_ymo10

SEの部屋で、JavaScript・TypeScript・React.js・Next.jsの開発ノートを公開しています。

ほかの開発ノートを読む →