JavaScript / LESSON 18 / JS18

JavaScript:総合演習:Webアプリを作る

1学習の目的

2基礎解説

役割使う道具(学んだ章)
① 状態アプリが持つデータ配列・オブジェクト(JS06・JS07)
② ロジック加工・集計・検証関数・高階関数(JS08・JS09)
③ 表示状態を画面に描くDOM操作(JS11)
④ 入出力操作・通信・保存イベント・fetch・Storage(JS12・JS16・JS17)
✅ アプリ設計の原則
  1. 状態を1箇所に集める。画面のあちこちにデータが散らばると、どれが正しいのか分からなくなる。「状態が正、画面はその写し」を徹底する。
  2. 「状態を変える → 保存する → 描き直す」を必ずセットで行う。どれか1つを忘れた瞬間、画面と保存内容がズレる。
  3. 役割ごとに関数を分ける。load / save / render / addTask のように、1つの関数が1つの仕事だけをするようにする。
  4. 外から来るデータは必ず壊れていると考える。通信・localStorage・ユーザー入力——すべて try で守り、失敗しても動き続ける形にする。
  5. まず動かし、それから整える。最初から完璧な設計を目指さず、動くものを作ってから関数に切り出していく。

この章の題材:タスク管理アプリを作ります。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問)

JS18-D01 タスクの配列 [{ text: "買い物", done: false }, { text: "掃除", done: true }] から未完了のものだけを取り出し、その件数を出力せよ。 コーディング★☆☆無料
期待される結果

出力 == 1

ヒント

filter で done が false のものを絞る(JS09)。

模範解答
const tasks = [
  { text: "買い物", done: false },
  { text: "掃除", done: true },
];

console.log(tasks.filter((t) => !t.done).length);
解説

状態から必要なものを取り出すのがアプリの基本動作。元の配列は変わらないので、絞り込みを何度でも自由に試せる。

JS18-D02 アプリのデータを扱うとき、最も適切な考え方はどれか。
A. 画面の表示内容を正として、そこから読み取る B. 状態を1箇所で持ち、画面はその写しとして描き直す C. 変数をあちこちに置いて必要な場所で更新する D. 毎回サーバーに問い合わせる
選択★☆☆無料
期待される結果

解答 == B

ヒント

画面と保存内容がズレないようにするには。

模範解答
B
解説

「状態が正、画面はその写し」——これが宣言的UIの考え方(JS17)。React・Vueなど現代のフレームワークもすべてこの思想で作られている。

JS18-D03 タスク配列を受け取り、{ all, done, remaining } を返す関数 countTasks を定義せよ。上記2件のデータで「全2件 / 完了1件 / 残り1件」と出力すること。 コーディング★☆☆無料
期待される結果

出力 == 全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}件`);
解説

集計を関数に切り出すと、画面のどこからでも同じ計算を呼べる。表示形式を変えたくなっても、この関数は触らなくてよい。

JS18-D04 状態を保存するコードを完成させよ。 穴埋め★☆☆無料
コード
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] になり、中身が失われる。

JS18-D05 タスクを追加したあと、必ず行うべき処理の組み合わせはどれか。
A. 保存だけ B. 再描画だけ C. 保存と再描画の両方 D. どちらも不要
選択★☆☆無料
期待される結果

解答 == C

ヒント

片方だけだと何がズレるか。

模範解答
C
解説

保存を忘れると再読み込みで消え、再描画を忘れると画面に出ない。「状態を変える → 保存 → 描き直す」は必ずセットで行う。

JS18-D06 フィルタ条件("all" / "done" / "active")とタスク配列を受け取り、該当するタスクだけを返す関数 filterTasks を定義せよ。上記2件のデータで、3つの条件それぞれの件数を「all:2 done:1 active:1」の形式で出力すること。 コーディング★★☆無料
期待される結果

出力 == 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);
解説

絞り込みの条件も状態の一部として持つのがコツ。フィルタを切り替えたら再描画するだけで、表示が切り替わる。

JS18-D07 保存内容が壊れていても落ちない読み込み関数を完成させよ。 穴埋め★★☆無料
コード
localStorage.setItem("tasks", "{壊れたデータ");

const load = () => {
  ____ {
    return JSON.parse(localStorage.getItem("tasks")) ?? [];
  } catch (e) {
    return [];
  }
};

console.log(load().length);
期待される結果

出力 == 0

ヒント

例外が起きうる処理を囲むキーワード(JS10)。

模範解答
try
解説

保存内容はユーザーが手で書き換えられるし、アプリの更新で形式が変わることもある。失敗したら初期状態から始めるのが安全。

JS18-D08 一覧が動的に増減するとき、各項目の削除ボタンに反応する最適な方法はどれか。
A. 項目ごとに addEventListener する B. 親要素に1つ登録し e.target で判定する C. 一定時間ごとに確認する D. HTMLに onclick を書く
選択★★☆無料
期待される結果

解答 == B

ヒント

後から追加された項目にも反応させたい。

模範解答
B
解説

イベント委譲(JS12)。再描画で項目を作り直すたびにリスナーを付け直す必要がなく、登録も1回で済む。

JS18-D09 タスク配列と番号を受け取り、その項目の done を反転させる関数 toggleTask を定義せよ。1番目(番号0)を反転させ、「買い物:完了」と出力すること。 コーディング★★☆無料
期待される結果

出力 == 買い物:完了

ヒント

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 ? "完了" : "未完了"}`);
解説

! で真偽を反転させるのが定番。状態を変えるだけの関数にしておくと、保存や再描画は呼び出し側の責任として分離できる。

JS18-D10 入力値を検証する関数 validateTask を定義せよ。空文字(前後の空白のみも含む)なら false、20文字を超えても false、それ以外は true を返すこと。"買い物"" "、21文字の文字列の3件を「true false false」の形式で出力せよ。 コーディング★★☆無料
期待される結果

出力 == 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問)

JS18-S01 【状態と保存の往復】state オブジェクト { tasks: [], filter: "all" } を用意し、タスクを2件追加して保存 → 読み込み直して「保存:2件 / フィルタ:all」と出力せよ。保存は tasks と filter の両方を含めること。 コーディング★★☆無料
期待される結果

出力 == 保存: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つのキーに保存すると、項目が増えても保存処理を書き足さずに済む。フィルタの選択状態も復元されるので、再訪時に前回の続きから始められる。

JS18-S02 【状態から画面を描く】state.tasks の2件(買い物:未完了、掃除:完了)から li を作り #list に表示する render 関数を書け。完了したものには "done" クラスを付け、各 li に削除ボタン(class="del")を入れること。#summary に「全2件 / 残り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 は状態を読むだけで、状態を変えない。この分担を守ると、いつ呼んでも同じ結果になるので、動作が予測しやすくなる。

JS18-S03 【初期データの取得】タスクの初期データを返す擬似API fetchTasks を作れ(20ms後に2件を返す)。localStorage に保存があればそちらを優先し、無ければAPIから取得する init 関数を書き、「APIから2件取得」と出力せよ(保存が無い状態で実行する)。 コーディング★★☆無料
期待される結果

出力 == 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回目以降の訪問では通信が発生しないので、表示も速くなる。

JS18-S04 【追加の一連の流れ】#input#addBtn#list がある。追加ボタンを押したら、① 入力を検証(空なら何もしない)② 状態に追加 ③ 保存 ④ 再描画 ⑤ 入力欄をクリア、を行え。
「新しいタスク」を入力してクリックした結果、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つが必ず一致する。

JS18-S05 【フィルタの切り替え】state に filter("all" / "active" / "done")を持たせ、#filters の中のボタン(data-filter 属性を持つ)をクリックすると絞り込みが切り替わるようにせよ。
タスク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仕上げ課題

JS18-FINAL 【タスク管理アプリを完成させる】これまでの全章を統合し、実際に使えるアプリを作れ。JavaScript編の卒業課題

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 といったフレームワークですが、それらがやっているのは、まさにあなたが今書いた「状態を変えたら画面を描き直す」という処理の自動化にすぎません。土台はすでにできています。

お疲れさまでした。次に作るものは、あなたが決めてください。