ホーム>React>React入門チュートリアル:ViteでToDoアプリを作って使い方を覚える
React

React入門チュートリアル:ViteでToDoアプリを作って使い方を覚える

いつもご利用ありがとうございます。
この記事にはアフィリエイト広告を含む広告が掲載されており、その広告費によって運営しています。
Amazonのアソシエイトとして、ララテックは適格販売により収入を得ています。

React初心者向けの入門チュートリアルです。Viteでプロジェクトを作り、コンポーネント・props・useState・イベント・リスト表示を使ってToDoアプリを完成させ、VitestとTesting Libraryで動きを確かめるところまで、React 19.3で実行した結果つきで解説します。

React を触ってみたいけれど、何から手を付ければいいか分からない、という方に向けたチュートリアルです。

小さな ToDo アプリを1つ作りながら、React の使い方の基本を順番に見ていきます。

この記事のコードは、2026年11月に Docker の node:22 のコンテナで実際に動かし、画面とテストの結果を確かめたものです。

React そのものが何なのかは、Reactとは?読み方と仕組みを、動くコードで確かめながら解説にまとめています。

このチュートリアルで作るもの

完成形はこの画面です。

完成したToDoアプリ。入力欄と追加ボタンの下に、チェックボックス付きの項目が2つ並び、完了した1つ目には取り消し線が付いている。下に「残り 1 件」と出ている

できることは次の4つです。

  • 入力欄に書いて「追加」を押すと、一覧に増える

  • チェックを入れると取り消し線が付き、「残り ○ 件」が減る

  • 「削除」で一覧から消える

  • 全部消すと「やることはありません」と出る

この中で、React を使うときに毎回出てくる次の5つを一通り使います。

使うものこの記事での役割
コンポーネント画面を App・TodoForm・TodoItem の3つの部品に分ける
JSXJavaScript の中に HTML のような書き方で画面を書く
props親(App)から子(TodoItem)へ、データと関数を渡す
useState一覧や入力中の文字を「状態」として持ち、変わったら画面を描き直す
イベント送信・チェック・削除のボタンが押されたときの処理

確かめた環境

ものバージョン
Node.js22.23.4(Docker の node:22)
npm10.9.9
Vite8.3.6
React / React DOM19.3.1
Vitest5.1.0
jsdom31.0.0
@testing-library/react16.3.3
@testing-library/user-event14.6.8
@testing-library/jest-dom7.0.1

画面の確認は Chromium 156 で行いました。

Node.js が入っていない場合は、先に入れておいてください。

Node.js のバージョンを切り替える方法はこちらの記事にあります。

手順1: Vite で React のプロジェクトを作る

Vite(ヴィート)は、React のコードを開発中に動かしたり、公開用にまとめたりする道具です。

次のコマンドで、todo というフォルダに React のプロジェクトが作られます。

npm create vite@latest todo -- --template react --no-interactive
cd todo
npm install
npm run dev

--template react で React のひな形を選んでいます。

確かめたときは、途中で質問が出ないように --no-interactive も付けて実行しました。

作られた package.json の中身はこうでした。

{
  "name": "todo",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "lint": "oxlint",
    "preview": "vite preview"
  },
  "dependencies": {
    "react": "^19.3.1",
    "react-dom": "^19.3.1"
  },
  "devDependencies": {
    "@types/react": "^19.3.0",
    "@types/react-dom": "^19.3.0",
    "@vitejs/plugin-react": "^6.1.2",
    "oxlint": "^1.90.0",
    "vite": "^8.3.6"
  }
}

npm run dev を実行すると、http://localhost:5173/ で開発用のサーバーが動きます。

  VITE v8.3.6  ready in 145 ms

  ➜  Local:   http://localhost:5173/

ブラウザで開くと、Vite と React のロゴの入った画面が出ます。

真ん中の「Count is 0」のボタンを2回押すと、数字が2になりました。

Viteで作ったReactプロジェクトの最初の画面。「Get started」の見出しの下に「Count is 2」のボタンがある

ここまで出れば準備は完了です!

作られたファイルのうち触るもの

src フォルダの中身はこうなっていました。

src/
├── App.css
├── App.jsx
├── assets/
├── index.css
└── main.jsx

画面を描き始める入り口が main.jsx です。

import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <App />
  </StrictMode>,
)

index.html の <div id="root"></div> の中に、App というコンポーネントを描く、という意味です。

このチュートリアルでは main.jsx はそのままにして、App.jsx を書き換え、TodoForm.jsx と TodoItem.jsx を新しく作ります。

手順2: 1件分の表示を TodoItem コンポーネントにする

React のコンポーネントは、JSX を返す関数です。

関数の名前は大文字で始めます。

まず、ToDo の1件分を表示する部品を src/TodoItem.jsx に作ります。

function TodoItem({ todo, onToggle, onDelete }) {
  return (
    <li className={todo.done ? 'done' : ''}>
      <label>
        <input
          type="checkbox"
          checked={todo.done}
          onChange={() => onToggle(todo.id)}
        />
        {todo.title}
      </label>
      <button onClick={() => onDelete(todo.id)}>削除</button>
    </li>
  )
}

export default TodoItem

関数の引数 { todo, onToggle, onDelete } が props です。

親のコンポーネントから <TodoItem todo={...} onToggle={...} /> のように渡された値を、ここで受け取ります。

JSX の中で { } で囲むと、JavaScript の値や式を書けます。

{todo.title} で文字を出し、className={todo.done ? 'done' : ''} で、完了したものだけに done というクラスを付けています。

HTML の class は、JSX では className と書くところに注意です。

onChange や onClick に関数を渡すと、チェックやクリックのときにその関数が呼ばれます。

TodoItem は「チェックされた」「削除が押された」を親に知らせるだけで、一覧そのものは触りません。

一覧をどう変えるかは、一覧を持っている App が決めます。

手順3: App で一覧を useState に持たせる

次に src/App.jsx を、中身ごと書き換えます。

import { useState } from 'react'
import TodoForm from './TodoForm.jsx'
import TodoItem from './TodoItem.jsx'
import './App.css'

const initialTodos = [
  { id: 1, title: 'Reactの公式チュートリアルを読む', done: true },
  { id: 2, title: 'ToDoアプリを作る', done: false },
]

function App() {
  const [todos, setTodos] = useState(initialTodos)

  function addTodo(title) {
    setTodos([...todos, { id: Date.now(), title, done: false }])
  }

  function toggleTodo(id) {
    setTodos(
      todos.map((todo) => (todo.id === id ? { ...todo, done: !todo.done } : todo)),
    )
  }

  function deleteTodo(id) {
    setTodos(todos.filter((todo) => todo.id !== id))
  }

  const remaining = todos.filter((todo) => !todo.done).length

  return (
    <main>
      <h1>ToDoリスト</h1>
      <TodoForm onAdd={addTodo} />
      {todos.length === 0 ? (
        <p>やることはありません</p>
      ) : (
        <ul>
          {todos.map((todo) => (
            <TodoItem
              key={todo.id}
              todo={todo}
              onToggle={toggleTodo}
              onDelete={deleteTodo}
            />
          ))}
        </ul>
      )}
      <p>残り {remaining} 件</p>
    </main>
  )
}

export default App

大事なのは const [todos, setTodos] = useState(initialTodos) の1行です。

todos が今の一覧で、setTodos が一覧を新しくする関数です。

setTodos に新しい配列を渡すと、React が App を描き直し、画面が新しい一覧に変わります。

配列は書き換えず、新しく作って渡す

追加・チェック・削除のどれも、todos を直接いじらずに、新しい配列を作って setTodos に渡しています。

操作書き方何をしているか
追加[...todos, 新しい項目]今の一覧の後ろに1件足した新しい配列
チェックtodos.map(...)対象の1件だけ done を反対にした新しい配列
削除todos.filter(...)対象の1件を除いた新しい配列

todos.push(...) のように元の配列を書き換える書き方は使いません。

公式ドキュメントでは、state の配列は読み取り専用として扱い、変えたいときは新しい配列を作って set 関数に渡すように書かれています。

出典: Updating Arrays in State - React

「残り ○ 件」は state にしない

remaining は useState を使わず、todos から毎回数えています。

todos が変われば App が描き直されて、そのたびに数え直されるので、別に持つ必要がありません。

state を増やすほど、更新し忘れて食い違うところが増えます。

ほかの state から計算できるものは計算で済ませる、と覚えておくと楽です。

map でリストを出すときは key を付ける

todos.map(...) で、配列の1件ずつを <TodoItem /> にしています。

このとき key={todo.id} を付けています。

試しに key を消して画面を開くと、ブラウザのコンソールに次のエラーが出ました。

Each child in a list should have a unique "key" prop.

Check the render method of `App`.

key は、どの項目がどれなのかを React が見分けるための目印です。

配列の何番目か(index)ではなく、項目ごとに変わらない id を使います。

条件で表示を切り替える

todos.length === 0 ? (...) : (...) は、一覧が空なら「やることはありません」、空でなければリストを出す、という切り替えです。

JSX の中には if 文を書けないので、三項演算子 ? : をよく使います。

手順4: 入力欄を TodoForm コンポーネントにする

入力欄と追加ボタンを src/TodoForm.jsx に作ります。

import { useState } from 'react'

function TodoForm({ onAdd }) {
  const [title, setTitle] = useState('')

  function handleSubmit(e) {
    e.preventDefault()
    if (title.trim() === '') return
    onAdd(title.trim())
    setTitle('')
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        value={title}
        onChange={(e) => setTitle(e.target.value)}
        placeholder="やることを入力"
        aria-label="やること"
      />
      <button type="submit">追加</button>
    </form>
  )
}

export default TodoForm

入力中の文字も、title という state で持っています。

value={title} で入力欄に state の値を出し、onChange で1文字打つごとに state を更新します。

この形を、React では「制御されたコンポーネント」と呼びます。

送信のときは、次の4つを順に行っています。

  1. e.preventDefault() で、フォームの送信によるページの再読み込みを止める

  2. 空白だけなら何もしない

  3. 親から受け取った onAdd に文字を渡す(App の addTodo が呼ばれる)

  4. setTitle('') で入力欄を空に戻す

子(TodoForm)は「この文字が追加された」と親に知らせるだけで、一覧は親(App)が持っている、という分け方が手順2と同じになっているのがポイントです。

手順5: 見た目を CSS で整える

src/App.css を書き換えます。

main {
  max-width: 480px;
  margin: 40px auto;
  font-family: sans-serif;
}

form {
  display: flex;
  gap: 8px;
}

form input {
  flex: 1;
  padding: 8px;
  font-size: 16px;
}

ul {
  padding: 0;
  list-style: none;
}

li {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 8px 0;
  border-bottom: 1px solid #ddd;
}

li.done label {
  color: #999;
  text-decoration: line-through;
}

button {
  padding: 6px 12px;
  font-size: 14px;
}

Vite のテンプレートの src/index.css には最初の画面用のスタイルが入っているので、body { margin: 0; } だけに減らしました。

CSS は App.jsx の先頭の import './App.css' で読み込まれます。

手順6: ブラウザで動かす

npm run dev を動かしたまま保存すると、ブラウザの画面がすぐに変わります。

「牛乳を買う」と入力して「追加」を押すと、一覧の最後に増え、入力欄は空に戻りました。

「残り 1 件」も「残り 2 件」に変わっています。

「牛乳を買う」を追加した後の画面。項目が3つになり、「残り 2 件」と出ている

「ToDoアプリを作る」にチェックを入れると、取り消し線が付きました。

「ToDoアプリを作る」にチェックを入れた画面。2つ目の項目に取り消し線が付き、「残り 1 件」に戻っている

1つ目の「削除」を押すと、その項目が消えます。

1つ目の項目を削除した画面。「ToDoアプリを作る」と「牛乳を買う」の2件が残っている

どの操作でも、書いたのは「state をどう変えるか」だけです。

画面のどこを書き換えるかは、React が決めてくれています (・∀・)

手順7: Vitest と Testing Library で動きを確かめる

ブラウザで毎回クリックして確かめるのは大変なので、テストに任せます。

Vite と相性のよい Vitest と、画面を人と同じように操作できる Testing Library を入れます。

npm install -D vitest jsdom @testing-library/react @testing-library/user-event @testing-library/jest-dom

vite.config.js に test の設定を足します。

import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'

// https://vite.dev/config/
export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    setupFiles: './src/setupTests.js',
  },
})

environment: 'jsdom' は、ブラウザの代わりに jsdom という仕組みの上で画面を描く設定です。

src/setupTests.js も作ります。

import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach } from 'vitest'

// テストごとに描画したものを片付ける
afterEach(() => {
  cleanup()
})

1行目で toBeInTheDocument() や toHaveValue() などの書き方が使えるようになります。

cleanup() を書いておかないと、前のテストで描いた画面が残ったまま次のテストが始まります。

テストを書く

src/App.test.jsx に、手順6で手でやった操作をそのまま書きます。

import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, test } from 'vitest'
import App from './App.jsx'

describe('ToDoリスト', () => {
  test('最初は2件あり、残りは1件', () => {
    render(<App />)
    expect(screen.getAllByRole('listitem')).toHaveLength(2)
    expect(screen.getByText('残り 1 件')).toBeInTheDocument()
  })

  test('入力して追加すると、一覧に増えて入力欄が空になる', async () => {
    const user = userEvent.setup()
    render(<App />)
    await user.type(screen.getByRole('textbox', { name: 'やること' }), '牛乳を買う')
    await user.click(screen.getByRole('button', { name: '追加' }))
    expect(screen.getByText('牛乳を買う')).toBeInTheDocument()
    expect(screen.getAllByRole('listitem')).toHaveLength(3)
    expect(screen.getByRole('textbox', { name: 'やること' })).toHaveValue('')
    expect(screen.getByText('残り 2 件')).toBeInTheDocument()
  })

  test('空白だけでは追加されない', async () => {
    const user = userEvent.setup()
    render(<App />)
    await user.type(screen.getByRole('textbox', { name: 'やること' }), '   ')
    await user.click(screen.getByRole('button', { name: '追加' }))
    expect(screen.getAllByRole('listitem')).toHaveLength(2)
  })

  test('チェックを入れると残りの件数が減る', async () => {
    const user = userEvent.setup()
    render(<App />)
    await user.click(screen.getByRole('checkbox', { name: 'ToDoアプリを作る' }))
    expect(screen.getByRole('checkbox', { name: 'ToDoアプリを作る' })).toBeChecked()
    expect(screen.getByText('残り 0 件')).toBeInTheDocument()
  })

  test('全部削除すると「やることはありません」が出る', async () => {
    const user = userEvent.setup()
    render(<App />)
    for (const button of screen.getAllByRole('button', { name: '削除' })) {
      await user.click(button)
    }
    expect(screen.queryAllByRole('listitem')).toHaveLength(0)
    expect(screen.getByText('やることはありません')).toBeInTheDocument()
  })
})

getByRole('textbox', { name: 'やること' }) は、「やること」という名前の入力欄を探す、という意味です。

TodoForm の aria-label="やること" がこの名前になっています。

チェックボックスは <label> で項目名と囲んでいるので、{ name: 'ToDoアプリを作る' } で探せます。

テストを実行する

npx vitest run --reporter=verbose
 RUN  v5.1.0 /work/todo

 ✓ src/App.test.jsx > ToDoリスト > 最初は2件あり、残りは1件 39ms
 ✓ src/App.test.jsx > ToDoリスト > 入力して追加すると、一覧に増えて入力欄が空になる 52ms
 ✓ src/App.test.jsx > ToDoリスト > 空白だけでは追加されない 32ms
 ✓ src/App.test.jsx > ToDoリスト > チェックを入れると残りの件数が減る 15ms
 ✓ src/App.test.jsx > ToDoリスト > 全部削除すると「やることはありません」が出る 26ms

 Test Files  1 passed (1)
      Tests  5 passed (5)

5つとも通りました!

わざと壊すとテストが教えてくれる

本当にテストが見張ってくれているのでしょうか?

TodoForm の setTitle('') の行を消して試しました。

 ❯ src/App.test.jsx (5 tests | 1 failed) 163ms
   ❯ ToDoリスト (5)
     ✓ 最初は2件あり、残りは1件 41ms
     × 入力して追加すると、一覧に増えて入力欄が空になる 56ms
     ✓ 空白だけでは追加されない 30ms
     ✓ チェックを入れると残りの件数が減る 13ms
     ✓ 全部削除すると「やることはありません」が出る 22ms

 FAIL  src/App.test.jsx > ToDoリスト > 入力して追加すると、一覧に増えて入力欄が空になる
Error: expect(element).toHaveValue()

Expected the element to have value:

Received:
  牛乳を買う

追加した後も入力欄に「牛乳を買う」が残っている、と教えてくれました。

行を戻すと、また5つとも通ります。

手順8: 公開用にビルドする

npm run build
dist/index.html                   0.45 kB │ gzip:  0.29 kB
dist/assets/index-CQZC4i8B.css    0.37 kB │ gzip:  0.26 kB
dist/assets/index-B1FyU_Z1.js   220.79 kB │ gzip: 69.25 kB

✓ built in 91ms

dist フォルダの中身を Web サーバーに置けば、公開できます。

できた dist/index.html を開いてみると、ToDo の文字はどこにもありません。

  <body>
    <div id="root"></div>
  </body>

一覧や入力欄は、index-B1FyU_Z1.js が読み込まれた後に、ブラウザの中で React が描いています。

JavaScript が動いて初めて画面ができる、というのが Vite で作った React アプリの形です。

次に進むなら

このチュートリアルの ToDo は、ページを再読み込みすると最初の2件に戻ります。

続けて触るなら、次のような方向があります。

  • 一覧を localStorage に保存して、再読み込みしても消えないようにする

  • 「すべて・未完了・完了」で表示を絞り込むボタンを足す

  • ページを分けたくなったら、React Router を使う

参考:公式のチュートリアルとフレームワーク

React の公式サイトにも、三目並べのゲームを作るチュートリアル(Tutorial: Tic-Tac-Toe)があり、日本語版も用意されているとのことです。

state を親に持ち上げる考え方や、手番の履歴を残す作り方を、順番に追えるようです。

出典: チュートリアル:三目並べ - React

また、公式ドキュメントの「新しい React アプリを作る」のページでは、新しいアプリは Next.js や React Router などのフレームワークで始めることが勧められているようです。

Vite のようなビルドツールから自分で組み立てる方法は、同じページの「一から作る」側の案内として載っているとのことです。

出典: 新しい React アプリを作る - React

まずは React の書き方に慣れたい、という段階なら、この記事のように Vite で小さく始めるのが分かりやすいと思います。

お疲れさまでした (^^)

フィードバックのお願い
この記事のフィードバックがありましたらYoutubeの適当な動画にコメントしていただいたり、お問い合わせからご連絡ください。