コンテンツにスキップ

10-2. フロントエンドで一覧表示とフォーム入力を作る

  • 書籍データを JavaScript の配列・オブジェクトで持つ考え方
  • querySelector / addEventListener を使って画面とコードをつなぐ方法
  • フォーム送信、一覧表示、削除を DOM 操作で実現する手順
  • state を更新してから render() する流れ
  • Console / Elements を使って、どこで値がずれたかを調べる方法

前のセクションでは、書籍管理アプリの見た目だけを作った。
ここからは JavaScript を加えて、入力 → 画面更新 の流れをブラウザの中で動かしていく。


このセクションの終わりでは、次の動きができる状態を目指す。

  • フォームから本を 1 冊追加できる
  • 一覧に新しい行が表示される
  • 登録冊数・読書中・読了の件数が更新される
  • 削除ボタンで一覧から消せる
  • まだ保存はしないので、ページを再読み込みすると元に戻る
フォーム入力
↓ submit
state.books に 1 件追加
render()
├─ 集計カードを書き換える
├─ tbody を作り直す
└─ 空メッセージの表示/非表示を切り替える

10-2 は、完成コードを一気に貼り付けるよりも、1ステップごとに保存 → ブラウザ更新 → 変化を確認する ほうが理解しやすい。

このセクションでは、各実装段階の末尾に「ブラウザを更新して確認する」を置く。毎回、次の順で進めよう。

  1. 指定された場所へコードを追加・修正する
  2. ファイルを保存する
  3. ブラウザを更新する
  4. 何が変わったか、変わらないならなぜ変わらないかを確認する

見た目が変わらないステップもある。
その場合も「まだ state を DOM へ写していないから変わらない」と説明できることが、DOM 操作の理解につながる。


1. JavaScript で画面を動かすときの考え方

Section titled “1. JavaScript で画面を動かすときの考え方”

初心者がつまずきやすいのは、「どのタイミングで画面が変わるのか」が頭の中で曖昧なことだ。

書籍管理アプリでは、次の流れで考えると整理しやすい。

ユーザーが操作する
JavaScript が状態(state)を更新する
render() で DOM を描き直す

この順番が大切である。

  • DOM をその場しのぎで直接書き換えるだけだと、データの正しい姿が分からなくなる
  • 先に state を更新しておけば、「今の正しいデータは何か」が 1 か所で分かる

つまり JavaScript では、画面そのものより、画面の元になるデータを先に考える のが基本である。


2. まず HTML に JavaScript 用の目印を付ける

Section titled “2. まず HTML に JavaScript 用の目印を付ける”

前のセクションの HTML に、JavaScript から触りたい場所の id を追加する。

変更する場所: index.html の各要素を修正する。赤い行(-)を緑の行(+)に書き換える。緑だけの行はコード内のコメントが示す位置に追加する。

まず、書籍登録パネルの <h2>section-heading div で包み、注釈を加える。

index.html
<section class="panel"><!-- すでにある(書籍を登録するパネル) -->
<h2>書籍を登録する</h2>
<div class="section-heading">
<h2>書籍を登録する</h2>
<p class="section-note">JavaScript で表示を更新します</p>
</div>
<form class="book-form"><!-- 続き、すでにある -->

次に、集計カードの id 付け・フォームの id 付け・一覧パネルの空メッセージ追加・tbody 差し替え・script タグ追加をまとめて行う。

index.html
<section class="summary-grid" aria-label="現在の集計">
<article class="summary-card">
<p class="summary-label">登録冊数</p>
<p class="summary-value">3</p>
<p class="summary-value" id="total-count">0</p>
</article>
<article class="summary-card">
<p class="summary-label">読書中</p>
<p class="summary-value">1</p>
<p class="summary-value" id="reading-count">0</p>
</article>
<article class="summary-card">
<p class="summary-label">読了</p>
<p class="summary-value">1</p>
<p class="summary-value" id="finished-count">0</p>
</article>
</section>
<form class="book-form">
<form class="book-form" id="book-form">
<!-- 入力欄は前節と同じ。id は title / author / category / price / status / memo を使う -->
<button class="primary-button" type="submit">書籍を追加</button>
<button class="primary-button" id="submit-button" type="submit">書籍を追加</button>
</form>
<section class="panel">
<div class="section-heading">
<h2>登録済み書籍</h2>
<p class="section-note">この段階ではまだサンプル表示です</p>
</div><!-- section-heading の閉じタグ(すでにある) -->
<p class="empty-message" id="empty-message">まだ書籍はありません。</p><!-- ↑ ここに追加 -->
<div class="table-wrapper"><!-- すでにある -->
<table class="book-table">
<thead>
<tr>
<th>タイトル</th>
<th>著者</th>
<th>カテゴリ</th>
<th>価格</th>
<th>状態</th>
<th>メモ</th>
<th>操作</th>
</tr>
</thead>
<tbody id="book-table-body">
<!-- 前節のサンプル行 2 件をすべて削除する -->
</tbody>
<tbody id="book-table-body"></tbody>
</table>
</div>
</section><!-- 一覧パネルの閉じタグ(すでにある) -->
<script src="./script.js" defer></script><!-- ↑ ここに追加 -->

このステップで <script src="./script.js" defer></script> も追加するので、更新前に空の script.js を 1 つ作っておくと、Console の 404 エラーを避けられる。
中身はまだ空でよい。

  • 前のセクションで置いたサンプル行が消え、「まだ書籍はありません。」が見える
  • 集計カードの数字は 0 / 0 / 0 になっている(HTML を書き換えたため)
  • つまりこの段階では、JavaScript が書き換える置き場だけ先に用意した 状態である

一覧が空で集計も 0 なのは、まだ JavaScript が動いていないからである。 次の render() で、初めて state の内容が画面表示に反映される。

document.querySelector('#book-form') のように、JavaScript は CSS セレクタで要素を探す。
そのため、

  • フォーム本体
  • 件数表示
  • テーブル本体
  • 空メッセージ

のような「コードから触る場所」は、名前を付けておく必要がある。


3. 状態(state)を 1 つのオブジェクトで持つ

Section titled “3. 状態(state)を 1 つのオブジェクトで持つ”

次に script.js を作る。

book-app/
└── book-frontend/
├── index.html
├── style.css
└── script.js

最初に、アプリのデータを state へまとめる。

script.js
const state = {
books: [
{
id: 'b1',
title: 'JavaScript入門',
author: '山田太郎',
category: 'フロントエンド',
price: 2800,
status: '未読',
memo: 'DOM 操作の章まで読む',
},
{
id: 'b2',
title: 'Git実践入門',
author: '佐藤花子',
category: '開発ツール',
price: 2400,
status: '読了',
memo: 'ブランチ運用を復習済み',
},
],
};

なぜ配列の中にオブジェクトを入れるのか

Section titled “なぜ配列の中にオブジェクトを入れるのか”

1 冊の本には、タイトルだけでなく著者や価格もある。
そこで 1 冊を オブジェクト で表し、それを複数冊まとめて 配列 に入れる。

books
├─ 1冊目 { title, author, category, ... }
├─ 2冊目 { title, author, category, ... }
└─ 3冊目 { title, author, category, ... }

この形にしておくと、追加・削除・集計がやりやすい。

  • 見た目は前のステップから変わらなくてよい
  • まだ state を作っただけで、画面へ描き出す処理は書いていないからである
  • Console で state.books.length を実行すると 2 になる

「データはあるが、まだ画面へは出していない」という状態を意識できると、DOM 操作で迷いにくくなる。


次に、HTML の部品を JavaScript 側で取り出す。

追加する場所: script.jsstate 定義の下に追加する。

script.js
const elements = {
form: document.querySelector('#book-form'),
title: document.querySelector('#title'),
author: document.querySelector('#author'),
category: document.querySelector('#category'),
price: document.querySelector('#price'),
status: document.querySelector('#status'),
memo: document.querySelector('#memo'),
tbody: document.querySelector('#book-table-body'),
emptyMessage: document.querySelector('#empty-message'),
totalCount: document.querySelector('#total-count'),
readingCount: document.querySelector('#reading-count'),
finishedCount: document.querySelector('#finished-count'),
};

ここで null になってしまう場合は、HTML 側の id が違っている可能性が高い。
つまり、JavaScript のエラーはコードの文法ミスだけでなく、HTML との約束のズレ でも起きる。

  • 見た目はまだ変わらない
  • ただし Console で elements.formelements.tbody を見ると、null ではないことを確認できる
  • もし null なら、HTML の id と JavaScript の selector がずれている

この段階で参照のズレを見つけておくと、あとで render() が動かないときに原因を切り分けやすい。


5. フォームの値を 1 冊分のデータへ変換する

Section titled “5. フォームの値を 1 冊分のデータへ変換する”

入力欄はすべて文字列として読まれる。
ただし価格だけは数値として扱いたいので、Number() で変換する。

追加する場所: script.jselements 定義の下に追加する。

script.js
function readBookFormData() {
return {
title: elements.title.value.trim(),
author: elements.author.value.trim(),
category: elements.category.value,
price: Number(elements.price.value),
status: elements.status.value,
memo: elements.memo.value.trim(),
};
}
function createBook(formData) {
return {
id: String(Date.now()),
title: formData.title,
author: formData.author,
category: formData.category,
price: formData.price,
status: formData.status,
memo: formData.memo,
};
}

"JavaScript入門"" JavaScript入門 " は、人には同じに見えても文字列としては別物である。
前後の空白を消しておくと、あとで検索や比較をするときに無駄なズレが減る。

  • 見た目はまだ変わらない
  • Console で readBookFormData() を実行すると、今フォームに入っている値が 1 冊分のオブジェクトとして返る
  • price だけが文字列ではなく数値になっていることを確認する

ここで「入力欄の値をどういう形で受け取るのか」を確かめておくと、submit の処理を理解しやすい。


追加する場所: script.js の末尾に追加する。

script.js
function renderSummary() {
const total = state.books.length;
const reading = state.books.filter((book) => book.status === '読書中').length;
const finished = state.books.filter((book) => book.status === '読了').length;
elements.totalCount.textContent = String(total);
elements.readingCount.textContent = String(reading);
elements.finishedCount.textContent = String(finished);
}

この時点では関数を追加しただけなので、まだ画面は変わらない。
続けて 6-2 と 6-3 まで入れてからブラウザを更新しよう。

今回は textContent を使って安全にセルを作る。
この書き方なら、入力値がそのまま HTML として解釈されにくい。

追加する場所: script.js の末尾に追加する。

script.js
function getStatusClass(status) {
if (status === '読書中') return 'status-reading';
if (status === '読了') return 'status-finished';
return 'status-unread';
}
function createTextCell(text) {
const td = document.createElement('td');
td.textContent = text;
return td;
}
function createStatusCell(status) {
const td = document.createElement('td');
const badge = document.createElement('span');
badge.className = `status-badge ${getStatusClass(status)}`;
badge.textContent = status;
td.appendChild(badge);
return td;
}
function createActionCell(bookId) {
const td = document.createElement('td');
const button = document.createElement('button');
button.type = 'button';
button.className = 'ghost-button delete-button';
button.dataset.bookId = bookId;
button.textContent = '削除';
td.appendChild(button);
return td;
}
function renderBooks() {
elements.tbody.innerHTML = '';
elements.emptyMessage.hidden = state.books.length > 0;
state.books.forEach((book) => {
const tr = document.createElement('tr');
tr.appendChild(createTextCell(book.title));
tr.appendChild(createTextCell(book.author));
tr.appendChild(createTextCell(book.category));
tr.appendChild(createTextCell(`${book.price}`));
tr.appendChild(createStatusCell(book.status));
tr.appendChild(createTextCell(book.memo || ''));
tr.appendChild(createActionCell(book.id));
elements.tbody.appendChild(tr);
});
}
function render() {
renderSummary();
renderBooks();
}

HTML の data-* 属性は、JavaScript では dataset から読める。たとえば data-book-id="b001"element.dataset.bookId で取得する。ハイフン区切りが camelCase に変わる点が、最初につまずきやすいポイントである。

state.books が正しいデータ
renderSummary() が件数へ変換
renderBooks() が table 行へ変換
画面に見える文字やボタンが更新される

この形にしておくと、表示が崩れたときも「state が変なのか」「render が変なのか」を分けて調べやすい。

ここまでは関数の準備だけで、まだ render() を実行していない。
次の 6-3 で最初の 1 回を呼び出すと、初めてブラウザの表示が変わる。

関数を定義しただけでは、state.books は画面へ出ない。
最初の 1 回だけ render() を呼び、初期データを DOM へ写す。

追加する場所: script.js の末尾に追加する。

script.js
render();

これを忘れると、state.books にデータがあっても画面は空のままである。
データを持っていること画面へ出ていること は別だ、という点は DOM 操作で非常に重要である。

  • 一覧に JavaScript入門Git実践入門 の 2 行が出る
  • 集計カードが 2 / 0 / 1 に変わる
  • まだ書籍はありません。 は非表示になる

ここで初めて、「state の内容が render() を通じて DOM へ写る」感覚が見え始める。


7. フォーム送信で 1 冊追加する

Section titled “7. フォーム送信で 1 冊追加する”

フォーム送信時には、ブラウザのデフォルト動作でページ再読み込みが起きる。
今回は JavaScript だけで画面を更新したいので、preventDefault() で止める。

追加する場所: script.js の末尾に追加する。

script.js
function resetForm() {
elements.form.reset();
elements.status.value = '未読';
elements.title.focus();
}
elements.form.addEventListener('submit', (event) => {
event.preventDefault();
const formData = readBookFormData();
const newBook = createBook(formData);
state.books.unshift(newBook);
render();
resetForm();
});

なぜ「先に state を更新する」のか

Section titled “なぜ「先に state を更新する」のか”

悪い例は、送信イベントの中でその場で tr を作り、件数も別で増やし、空メッセージも別で消す書き方である。
それだと処理がばらけて、どこか 1 か所だけ更新漏れしやすい。

今の書き方では、

  1. state.books に新しいデータを追加する
  2. render() を 1 回呼ぶ

だけで、一覧も件数もまとめて更新される。
これが 状態駆動で画面を描く という考え方の入口である。

  1. ブラウザを更新し、初期の 2 行が表示されることを確認する
  2. フォームへ 1 冊分の情報を入力して「書籍を追加」を押す
  3. 新しい本が先頭行に追加されることを確認する
  4. 登録冊数と状態別の件数が更新されることを確認する
  5. フォームが空に戻り、タイトル入力欄へフォーカスが戻ることを確認する

ここでは「保存して更新したあと、クリックや送信で画面が変わるか」を見る。
読み込み直後の見た目と、操作後の見た目を分けて観察するのがポイントである。


行ごとに個別のイベントを付ける方法もあるが、今回は tbody に 1 つだけ click を付ける。
これを イベント委譲 と呼ぶ。

追加する場所: script.js の末尾に追加する。

script.js
elements.tbody.addEventListener('click', (event) => {
const target = event.target;
if (!(target instanceof HTMLElement)) {
return;
}
if (!target.matches('.delete-button')) {
return;
}
const bookId = target.dataset.bookId;
state.books = state.books.filter((book) => book.id !== bookId);
render();
});

instanceof HTMLElementmatches() は何をしているか

Section titled “instanceof HTMLElement と matches() は何をしているか”

event.target はクリックされた対象を返すが、型としては広く、テキストノードのように HTMLElement ではない値が来ることもある。そこで instanceof HTMLElement を使い、「HTML 要素として安全に扱えるか」を最初に確認している。

matches('.delete-button') は、その要素自身が .delete-button に一致するかを調べる。イベント委譲では tbody 全体で click を受けるため、「本当に削除ボタンが押されたか」をここで絞り込む必要がある。

target.dataset.bookId は、ボタンへ埋め込んだ data-book-id を読み取り、「どの書籍を削除するか」を特定するための値である。

一覧の行は render() のたびに作り直される。
つまり、ボタンに直接イベントを付けても、再描画のたびに消えてしまう。

tbody に click を 1つ付ける
クリックされた要素が delete-button か確認する
該当 bookId を削除する

この方法なら、行が何度描き直されてもイベント設定をやり直さなくてよい。

  1. ブラウザを更新し、一覧が表示されることを確認する
  2. どれか 1 行の「削除」を押す
  3. 押した行だけが消えることを確認する
  4. 集計カードの件数も同じだけ減ることを確認する
  5. すべて削除すると まだ書籍はありません。 が再表示されることを確認する

ここで見たいのは、「state.books を減らした結果が、行数・件数・空メッセージへまとめて反映されるか」である。


const state = { books: [...] };
const elements = { ... };
function readBookFormData() { ... }
function createBook(formData) { ... }
function renderSummary() { ... }
function renderBooks() { ... }
function render() { ... }
render();
function resetForm() { ... }
elements.form.addEventListener('submit', ...);
elements.tbody.addEventListener('click', ...);

この構成は単純だが、

  • state
  • render
  • event

の 3 つが分かれているため、あとから入力チェックや状態更新、API 通信を追加しやすい。


失敗症状直し方
event.preventDefault() を忘れる送信するとページが再読み込みされ、追加した行が見えないsubmit イベントの最初で止める
elements.price.value をそのまま使う2800 が文字列のまま残り、計算や比較でズレるNumber() で数値へ変換する
renderBooks()tbody を空にしていない同じ本が何度も増殖して見えるelements.tbody.innerHTML = '' で描き直す前に初期化する
dataset.bookId の名前が HTML と JS でずれている削除ボタンを押しても消えないdata-book-iddataset.bookId の対応を確認する
render() を最後に呼んでいないstate に初期データがあっても表が空のまま初期化時に 1 回呼ぶ

JavaScript の DOM 操作は、Console と Elements を行き来しながら確認する と理解しやすい。

console.table(state.books);

これで、今ブラウザが持っている本の一覧を表形式で見られる。
「画面に出ない」のに state.books には入っているなら、原因は render 側にある。

document.querySelectorAll('#book-table-body tr').length;
document.querySelector('#empty-message').hidden;
  • 行数が何件あるか
  • 空メッセージが隠れているか

をその場で確かめられる。

3. Elements パネルで tbody の中を見る

Section titled “3. Elements パネルで tbody の中を見る”

期待する trbutton[data-book-id] が本当に出ているかを確認する。
もし DOM に行があるのに見た目が変なら CSS 側、DOM 自体がなければ JavaScript 側の問題だと切り分けられる。

入力欄の値
↓ readBookFormData()
formData
↓ createBook()
newBook
↓ state.books.unshift()
state.books
↓ render()
DOM

どこでずれたか分からなくなったら、この順に console.log() を入れて確認するとよい。


項目ポイント
state画面の元になる正しいデータをまとめて持つ
querySelectorHTML の目印 (id) を頼りに要素を取得する
addEventListenersubmit や click をきっかけに処理を実行する
render()state から件数表示と一覧テーブルを描き直す
preventDefault()フォーム送信時の再読み込みを止める
イベント委譲再描画される一覧でも削除ボタンを扱いやすくする
デバッグConsole で state、Elements で DOM の結果を確認する

演習問題 で、state・render・イベントの流れを自分の言葉と手で確認しよう。

理解できたら 10-3. 更新・削除・入力チェックを追加する へ進み、続けて使っても壊れにくい画面へ育てよう。