JavaScript:総合演習:Webアプリを作る
1学習の目的
- JS01〜JS17で学んだすべてを組み合わせ、データの取得・加工・表示・保存・操作までを備えたWebアプリを自力で組み立てられるようになる。
- 「どの場面でどの道具を使うか」を判断できるようになる。文法を知っていることと、動くものを作れることの差がここで埋まる。
2基礎解説
| 層 | 役割 | 使う道具(学んだ章) |
|---|---|---|
| ① 状態 | アプリが持つデータ | 配列・オブジェクト(JS06・JS07) |
| ② ロジック | 加工・集計・検証 | 関数・高階関数(JS08・JS09) |
| ③ 表示 | 状態を画面に描く | DOM操作(JS11) |
| ④ 入出力 | 操作・通信・保存 | イベント・fetch・Storage(JS12・JS16・JS17) |
- 状態を1箇所に集める。画面のあちこちにデータが散らばると、どれが正しいのか分からなくなる。「状態が正、画面はその写し」を徹底する。
- 「状態を変える → 保存する → 描き直す」を必ずセットで行う。どれか1つを忘れた瞬間、画面と保存内容がズレる。
- 役割ごとに関数を分ける。load / save / render / addTask のように、1つの関数が1つの仕事だけをするようにする。
- 外から来るデータは必ず壊れていると考える。通信・localStorage・ユーザー入力——すべて try で守り、失敗しても動き続ける形にする。
- まず動かし、それから整える。最初から完璧な設計を目指さず、動くものを作ってから関数に切り出していく。
この章の題材:タスク管理アプリを作ります。APIから初期データを取得し、追加・完了・削除・絞り込みができ、内容はブラウザに保存される——実際に公開できる水準のアプリを完成させます。
// アプリの骨格(すべての章がここに集まる)
const state = { tasks: [], filter: "all" }; // ① 状態(JS07)
const load = () => { … }; // localStorage から復元(JS17)
const save = () => { … }; // localStorage へ保存(JS17)
const visibleTasks = () => // ② 絞り込み(JS09)
state.tasks.filter((t) => …);
const render = () => { … }; // ③ 状態から画面を作る(JS11)
button.addEventListener("click", () => { // ④ 操作を受け付ける(JS12)
state.tasks.push(newTask);
save();
render();
});
async function init() { // 起動処理(JS15・JS16)
state.tasks = load() ?? (await fetchTasks());
render();
}
3基本ドリル(10問)
出力 == 1
filter で done が false のものを絞る(JS09)。
const tasks = [
{ text: "買い物", done: false },
{ text: "掃除", done: true },
];
console.log(tasks.filter((t) => !t.done).length);
状態から必要なものを取り出すのがアプリの基本動作。元の配列は変わらないので、絞り込みを何度でも自由に試せる。
A. 画面の表示内容を正として、そこから読み取る B. 状態を1箇所で持ち、画面はその写しとして描き直す C. 変数をあちこちに置いて必要な場所で更新する D. 毎回サーバーに問い合わせる 選択★☆☆無料
解答 == B
画面と保存内容がズレないようにするには。
B
「状態が正、画面はその写し」——これが宣言的UIの考え方(JS17)。React・Vueなど現代のフレームワークもすべてこの思想で作られている。
出力 == 全2件 / 完了1件 / 残り1件
filter で完了数を数え、残りは引き算。オブジェクトで返すと受け取り側が分割代入できる(JS08)。
const countTasks = (tasks) => {
const done = tasks.filter((t) => t.done).length;
return { all: tasks.length, done, remaining: tasks.length - done };
};
const tasks = [
{ text: "買い物", done: false },
{ text: "掃除", done: true },
];
const { all, done, remaining } = countTasks(tasks);
console.log(`全${all}件 / 完了${done}件 / 残り${remaining}件`);
集計を関数に切り出すと、画面のどこからでも同じ計算を呼べる。表示形式を変えたくなっても、この関数は触らなくてよい。
const state = { tasks: [{ text: "買い物", done: false }] };
localStorage.setItem("tasks", JSON.____(state.tasks));
console.log(localStorage.getItem("tasks"));
出力 == [{"text":"買い物","done":false}]
オブジェクトや配列を文字列にするメソッド(JS17)。
stringify
localStorage は文字列しか保存できない。これを忘れると [object Object] になり、中身が失われる。
A. 保存だけ B. 再描画だけ C. 保存と再描画の両方 D. どちらも不要 選択★☆☆無料
解答 == C
片方だけだと何がズレるか。
C
保存を忘れると再読み込みで消え、再描画を忘れると画面に出ない。「状態を変える → 保存 → 描き直す」は必ずセットで行う。
出力 == all:2 done:1 active:1
switch で条件を分ける(JS04)。all のときは元の配列をそのまま返す。
const filterTasks = (tasks, filter) => {
switch (filter) {
case "done":
return tasks.filter((t) => t.done);
case "active":
return tasks.filter((t) => !t.done);
default:
return tasks;
}
};
const tasks = [
{ text: "買い物", done: false },
{ text: "掃除", done: true },
];
const counts = ["all", "done", "active"]
.map((f) => `${f}:${filterTasks(tasks, f).length}`)
.join(" ");
console.log(counts);
絞り込みの条件も状態の一部として持つのがコツ。フィルタを切り替えたら再描画するだけで、表示が切り替わる。
localStorage.setItem("tasks", "{壊れたデータ");
const load = () => {
____ {
return JSON.parse(localStorage.getItem("tasks")) ?? [];
} catch (e) {
return [];
}
};
console.log(load().length);
出力 == 0
例外が起きうる処理を囲むキーワード(JS10)。
try
保存内容はユーザーが手で書き換えられるし、アプリの更新で形式が変わることもある。失敗したら初期状態から始めるのが安全。
A. 項目ごとに addEventListener する B. 親要素に1つ登録し e.target で判定する C. 一定時間ごとに確認する D. HTMLに onclick を書く 選択★★☆無料
解答 == B
後から追加された項目にも反応させたい。
B
イベント委譲(JS12)。再描画で項目を作り直すたびにリスナーを付け直す必要がなく、登録も1回で済む。
出力 == 買い物:完了
tasks[index].done = !tasks[index].done で反転できる。
const toggleTask = (tasks, index) => {
tasks[index].done = !tasks[index].done;
};
const tasks = [
{ text: "買い物", done: false },
{ text: "掃除", done: true },
];
toggleTask(tasks, 0);
console.log(`${tasks[0].text}:${tasks[0].done ? "完了" : "未完了"}`);
! で真偽を反転させるのが定番。状態を変えるだけの関数にしておくと、保存や再描画は呼び出し側の責任として分離できる。
出力 == true false false
trim() で前後の空白を除いてから判定する(JS12)。21文字は "あ".repeat(21) で作れる。
const validateTask = (text) => {
const trimmed = text.trim();
return trimmed.length > 0 && trimmed.length <= 20;
};
const results = ["買い物", " ", "あ".repeat(21)]
.map((t) => validateTask(t))
.join(" ");
console.log(results);
検証を関数に切り出すと、追加時と編集時の両方から同じルールを使える。ルールが変わってもここ1箇所を直すだけで済む。
4実践シナリオ(5問)
出力 == 保存:2件 / フィルタ:all
state ごと JSON.stringify で保存し、JSON.parse で復元する。
const state = { tasks: [], filter: "all" };
const save = () => localStorage.setItem("app", JSON.stringify(state));
state.tasks.push({ text: "買い物", done: false });
state.tasks.push({ text: "掃除", done: true });
save();
const loaded = JSON.parse(localStorage.getItem("app"));
console.log(`保存:${loaded.tasks.length}件 / フィルタ:${loaded.filter}`);
状態をまるごと1つのキーに保存すると、項目が増えても保存処理を書き足さずに済む。フィルタの選択状態も復元されるので、再訪時に前回の続きから始められる。
li 2つ・done 1つ・summary が 全2件 / 残り1件
render の冒頭で list.innerHTML = "" して作り直す(JS11・JS17)。
const state = {
tasks: [
{ text: "買い物", done: false },
{ text: "掃除", done: true },
],
};
const list = document.querySelector("#list");
const summary = document.querySelector("#summary");
const render = () => {
list.innerHTML = "";
for (const task of state.tasks) {
const li = document.createElement("li");
li.textContent = task.text;
if (task.done) {
li.classList.add("done");
}
const del = document.createElement("button");
del.textContent = "削除";
del.classList.add("del");
li.appendChild(del);
list.appendChild(li);
}
const remaining = state.tasks.filter((t) => !t.done).length;
summary.textContent = `全${state.tasks.length}件 / 残り${remaining}件`;
};
render();
render は状態を読むだけで、状態を変えない。この分担を守ると、いつ呼んでも同じ結果になるので、動作が予測しやすくなる。
出力 == APIから2件取得
load() が空配列なら await fetchTasks() を使う。非同期なので init は async にする(JS15)。
const fetchTasks = () =>
new Promise((resolve) => {
setTimeout(() => {
resolve([
{ text: "資料作成", done: false },
{ text: "メール返信", done: false },
]);
}, 20);
});
const load = () => {
try {
return JSON.parse(localStorage.getItem("tasks")) ?? [];
} catch (e) {
return [];
}
};
const state = { tasks: [] };
async function init() {
const saved = load();
if (saved.length > 0) {
state.tasks = saved;
console.log(`保存から${saved.length}件復元`);
return;
}
state.tasks = await fetchTasks();
console.log(`APIから${state.tasks.length}件取得`);
}
init();
保存があればそれを使い、無ければ取得する——実際のアプリで最もよくある起動処理。2回目以降の訪問では通信が発生しないので、表示も速くなる。
「新しいタスク」を入力してクリックした結果、li が1つ・入力欄が空・localStorage に1件保存されていることを確認せよ。 コーディング★★★無料
li 1つ・input が空・保存1件
validate → push → save → render → クリアの順に書く。1つでも欠けるとズレる。
const state = { tasks: [] };
const input = document.querySelector("#input");
const addBtn = document.querySelector("#addBtn");
const list = document.querySelector("#list");
const save = () => localStorage.setItem("tasks", JSON.stringify(state.tasks));
const render = () => {
list.innerHTML = "";
for (const task of state.tasks) {
const li = document.createElement("li");
li.textContent = task.text;
list.appendChild(li);
}
};
addBtn.addEventListener("click", () => {
const text = input.value.trim();
if (!text) {
return;
}
state.tasks.push({ text, done: false });
save();
render();
input.value = "";
});
この5手が「1つの操作」の完成形。実務でも、状態を変える操作はすべてこの流れで書く。順序を守れば、画面・保存・状態の3つが必ず一致する。
タスク3件(未完了2・完了1)を用意し、"active" を選んだ結果 li が2つになることを確認せよ。 コーディング★★★無料
active 選択後 li が2つ
e.target.dataset.filter で属性値が取れる。state.filter を書き換えて render を呼ぶだけでよい(イベント委譲)。
const state = {
tasks: [
{ text: "A", done: false },
{ text: "B", done: true },
{ text: "C", done: false },
],
filter: "all",
};
const list = document.querySelector("#list");
const filters = document.querySelector("#filters");
const visibleTasks = () => {
switch (state.filter) {
case "active":
return state.tasks.filter((t) => !t.done);
case "done":
return state.tasks.filter((t) => t.done);
default:
return state.tasks;
}
};
const render = () => {
list.innerHTML = "";
for (const task of visibleTasks()) {
const li = document.createElement("li");
li.textContent = task.text;
list.appendChild(li);
}
};
filters.addEventListener("click", (e) => {
const value = e.target.dataset.filter;
if (!value) {
return;
}
state.filter = value;
render();
});
render();
フィルタを変えてもタスク自体は一切変わらない。変わるのは「どれを表示するか」だけ。状態と表示を分けておくと、こういう機能があとから簡単に足せる。
5仕上げ課題
HTMLには次の要素があるものとする:
#input(入力欄)/#addBtn(追加ボタン)/#list(ul)/#summary(p)/#filters(中に data-filter="all|active|done" のボタン3つ)/#message(p)
要件
① 状態:{ tasks: [], filter: "all" } を1箇所で持つ
② 起動:localStorage に保存があれば復元、無ければ擬似API fetchTasks()(20ms後に2件返す)から取得。失敗したら空で開始し #message に「読み込みに失敗しました」と表示
③ 追加:空文字・20文字超は追加せず #message に理由を表示。正常なら追加 → 保存 → 再描画 → 入力欄クリア
④ 完了:li のテキスト部分クリックで done を反転 → 保存 → 再描画
⑤ 削除:削除ボタンで取り除く → 保存 → 再描画
⑥ 絞り込み:フィルタボタンで表示を切り替え(状態は変えない)
⑦ 集計:#summary に「全3件 / 完了1件 / 残り2件」を表示(絞り込みに関わらず全体の数)
検証の流れ:起動(API取得2件)→「買い物」を追加 → 1件目を完了 → 2件目を削除 → フィルタを "active" に
期待される最終状態:状態は2件(1件完了)、表示中の li は1つ、summary は「全2件 / 完了1件 / 残り1件」、localStorage にも2件保存されている
状態2件・表示1件・summary一致・保存2件
関数を load / save / visibleTasks / render / showMessage に分ける。追加・完了・削除はすべて「状態変更 → save() → render()」の3手。li の位置特定は [...list.children].indexOf(li) だが、フィルタ中は表示と状態の並びがズレるので、表示中のタスクを介して元の配列での位置を求める(indexOf でオブジェクトそのものを探す)。
const KEY = "tasks-app";
const MAX_LENGTH = 20;
const state = { tasks: [], filter: "all" };
const input = document.querySelector("#input");
const addBtn = document.querySelector("#addBtn");
const list = document.querySelector("#list");
const summary = document.querySelector("#summary");
const filters = document.querySelector("#filters");
const message = document.querySelector("#message");
const fetchTasks = () =>
new Promise((resolve) => {
setTimeout(() => {
resolve([
{ text: "資料作成", done: false },
{ text: "メール返信", done: false },
]);
}, 20);
});
const load = () => {
try {
return JSON.parse(localStorage.getItem(KEY)) ?? null;
} catch (e) {
return null;
}
};
const save = () => {
localStorage.setItem(KEY, JSON.stringify(state.tasks));
};
const showMessage = (text) => {
message.textContent = text;
};
const visibleTasks = () => {
switch (state.filter) {
case "active":
return state.tasks.filter((t) => !t.done);
case "done":
return state.tasks.filter((t) => t.done);
default:
return state.tasks;
}
};
const render = () => {
list.innerHTML = "";
for (const task of visibleTasks()) {
const li = document.createElement("li");
li.textContent = task.text;
if (task.done) {
li.classList.add("done");
}
const del = document.createElement("button");
del.textContent = "削除";
del.classList.add("del");
li.appendChild(del);
list.appendChild(li);
}
const doneCount = state.tasks.filter((t) => t.done).length;
summary.textContent = `全${state.tasks.length}件 / 完了${doneCount}件 / 残り${state.tasks.length - doneCount}件`;
};
addBtn.addEventListener("click", () => {
const text = input.value.trim();
if (!text) {
showMessage("入力してください");
return;
}
if (text.length > MAX_LENGTH) {
showMessage(`${MAX_LENGTH}文字以内で入力してください`);
return;
}
state.tasks.push({ text, done: false });
save();
render();
input.value = "";
showMessage("");
});
list.addEventListener("click", (e) => {
const isDelete = e.target.classList.contains("del");
const li = isDelete ? e.target.parentNode : e.target;
const position = [...list.children].indexOf(li);
if (position === -1) {
return;
}
const task = visibleTasks()[position];
const index = state.tasks.indexOf(task);
if (isDelete) {
state.tasks.splice(index, 1);
} else {
state.tasks[index].done = !state.tasks[index].done;
}
save();
render();
});
filters.addEventListener("click", (e) => {
const value = e.target.dataset.filter;
if (!value) {
return;
}
state.filter = value;
render();
});
async function init() {
const saved = load();
if (saved !== null) {
state.tasks = saved;
render();
return;
}
try {
state.tasks = await fetchTasks();
} catch (e) {
state.tasks = [];
showMessage("読み込みに失敗しました");
}
save();
render();
}
init();
これがJavaScript編の到達点です。
このアプリ1本に、変数(JS02)・条件分岐(JS04)・ループ(JS05)・配列(JS06)・オブジェクト(JS07)・関数(JS08)・高階関数(JS09)・エラー処理(JS10)・DOM操作(JS11)・イベント委譲(JS12)・非同期処理(JS15)・API取得(JS16)・状態管理と保存(JS17)——18章で学んだほぼすべてが入っています。
設計の核心は3つです。第一に状態を1箇所に集めたこと。tasks も filter も state の中にあり、どこを見れば正解が分かるかが明確です。第二に「状態変更 → save() → render()」を全操作で徹底したこと。追加・完了・削除のどれもこの3手を守っているので、画面・保存・状態が食い違いません。第三に役割ごとに関数を分けたこと。load / save / visibleTasks / render / showMessage が、それぞれ1つの仕事だけをしています。
そして注目してほしいのが、フィルタ中の位置特定です。表示されている並びと状態の並びは異なるため、visibleTasks()[position] でタスク本体を取り、state.tasks.indexOf(task) で元の位置を求めています。「見えているもの」と「持っているもの」を混同しない——これが状態管理で最も重要な感覚です。
ここまで来たあなたは、もう「JavaScriptを勉強している人」ではありません。JavaScriptでアプリを作れる人です。この先に待っているのは React や Vue といったフレームワークですが、それらがやっているのは、まさにあなたが今書いた「状態を変えたら画面を描き直す」という処理の自動化にすぎません。土台はすでにできています。
お疲れさまでした。次に作るものは、あなたが決めてください。