React入門チュートリアル:ViteでToDoアプリを作って使い方を覚える
目次
React初心者向けの入門チュートリアルです。Viteでプロジェクトを作り、コンポーネント・props・useState・イベント・リスト表示を使ってToDoアプリを完成させ、VitestとTesting Libraryで動きを確かめるところまで、React 19.3で実行した結果つきで解説します。
React を触ってみたいけれど、何から手を付ければいいか分からない、という方に向けたチュートリアルです。
小さな ToDo アプリを1つ作りながら、React の使い方の基本を順番に見ていきます。
この記事のコードは、2026年11月に Docker の node:22 のコンテナで実際に動かし、画面とテストの結果を確かめたものです。
React そのものが何なのかは、Reactとは?読み方と仕組みを、動くコードで確かめながら解説にまとめています。
このチュートリアルで作るもの
完成形はこの画面です。
できることは次の4つです。
-
入力欄に書いて「追加」を押すと、一覧に増える
-
チェックを入れると取り消し線が付き、「残り ○ 件」が減る
-
「削除」で一覧から消える
-
全部消すと「やることはありません」と出る
この中で、React を使うときに毎回出てくる次の5つを一通り使います。
| 使うもの | この記事での役割 |
|---|---|
| コンポーネント | 画面を App・TodoForm・TodoItem の3つの部品に分ける |
| JSX | JavaScript の中に HTML のような書き方で画面を書く |
| props | 親(App)から子(TodoItem)へ、データと関数を渡す |
| useState | 一覧や入力中の文字を「状態」として持ち、変わったら画面を描き直す |
| イベント | 送信・チェック・削除のボタンが押されたときの処理 |
確かめた環境
| もの | バージョン |
|---|---|
| Node.js | 22.23.4(Docker の node:22) |
| npm | 10.9.9 |
| Vite | 8.3.6 |
| React / React DOM | 19.3.1 |
| Vitest | 5.1.0 |
| jsdom | 31.0.0 |
| @testing-library/react | 16.3.3 |
| @testing-library/user-event | 14.6.8 |
| @testing-library/jest-dom | 7.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になりました。
ここまで出れば準備は完了です!
作られたファイルのうち触るもの
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つを順に行っています。
-
e.preventDefault()で、フォームの送信によるページの再読み込みを止める -
空白だけなら何もしない
-
親から受け取った
onAddに文字を渡す(App のaddTodoが呼ばれる) -
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 件」に変わっています。
「ToDoアプリを作る」にチェックを入れると、取り消し線が付きました。
1つ目の「削除」を押すと、その項目が消えます。
どの操作でも、書いたのは「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-domvite.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 builddist/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 91msdist フォルダの中身を 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 アプリを作る」のページでは、新しいアプリは Next.js や React Router などのフレームワークで始めることが勧められているようです。
Vite のようなビルドツールから自分で組み立てる方法は、同じページの「一から作る」側の案内として載っているとのことです。
まずは React の書き方に慣れたい、という段階なら、この記事のように Vite で小さく始めるのが分かりやすいと思います。
お疲れさまでした (^^)




