1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【JavaScriptアウトプット】学習記録アプリ制作で学んだ localStorage・DOM操作・バリデーション

1
Last updated at Posted at 2026-04-03

タイトルなし1.gif

1. はじめに

こちらのロードマップを参考にJavaScriptを学習しています。
ドットインストールでHTML/CSS・JavaScriptの基礎を一通り学んだあと、Chapter1の「JavaScriptで個人開発」という課題に取り組みました。

課題の内容は「学習記録アプリ」を作ることで、仕様は以下の通りです。

  • 学習内容と学習時間を入力して登録できる
  • 入力中にリアルタイムでプレビューが表示される
  • 入力が不正のときはエラーを表示して登録を止める
  • 登録した記録は一覧で表示される
  • 記録の合計時間が表示される
  • ページをリロードしても記録が消えない(localStorageで保存)

コードはまだ「動けばいい」レベルでぐちゃぐちゃな部分もあります。
それでも「まずは動くものを作る」という気持ちで最後まで書ききったので、実装の流れと詰まったポイントを整理していきます。

仕様には「テストデータを用意する」という項目がありました。ただ今回は localStorage を使って記録を永続化する実装を選んだため、初回アクセス時はデータなしの状態からスタートする仕様にしました。ハードコードでダミーデータを入れてしまうと localStorage の保存・復元の仕組みと混在して管理が複雑になると判断したためです。

バリデーションは「入力が空の場合にのみエラーを出す」という実装にとどめています。

2. HTML・CSSの構成

今回のアプリを作るうえで必要なHTML・CSSを確認しておきます。

2.1 HTML

アプリ全体のHTMLです。

<!DOCTYPE html>
<html lang="ja">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>study</title>
  <link rel="stylesheet" href="style.css">
</head>

<body>
  <main>
    <div class="card">
      <div class="form-group">
        <label for="subject">学習内容</label>
        <input type="text" id="subject" placeholder="">
      </div>

      <div class="form-group">
        <label for="hours">学習時間</label>
        <input type="number" id="hours" placeholder="">
        <span class="unit">時間</span>
      </div>

      <div class="preview">
        <p>入力されている学習内容:<span id="preview-subject"></span></p>
        <p>入力されている時間:<span id="preview-hours"></span>時間</p>
      </div>

      <!-- 記録の一覧表示エリア -->
      <ul id="history-box">
      </ul>
      <p id="error" class="error-text">入力されていない項目があります</p>

      <button id="register-btn" class="register-btn">登録</button>

      <p class="total">合計時間:<span id="total-hours">0</span> / 1000 (h)</p>
    </div>
  </main>
  <script src="main.js"></script>
</body>

</html>

JavaScriptから操作する要素に id を付けています。
history-box に記録を追加し、total-hours に合計時間を表示し、error にエラー文を出す構成にしました。

2.2 CSS

アプリ全体のCSSです。

@charset "utf-8";

* {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

body {
  font-family: sans-serif;
  background-color: #f0f0f0;
  display: flex;
  justify-content: center;
  padding: 40px 16px;
}

main {
  width: 100%;
  max-width: 560px;
}

.card {
  background-color: #fff;
  border-radius: 8px;
  padding: 32px 40px;
  display: flex;
  flex-direction: column;
  gap: 20px;
}

.form-group {
  display: flex;
  flex-direction: column;
  gap: 6px;
}

.form-group label {
  font-size: 15px;
  font-weight: bold;
}

.form-group input {
  width: 100%;
  padding: 10px 12px;
  font-size: 15px;
  border: 2px solid #333;
  border-radius: 4px;
  outline: none;
}

.unit {
  font-size: 14px;
  color: #333;
  margin-top: 2px;
}

.preview {
  font-size: 14px;
  color: #333;
  line-height: 1.8;
}

.history-text {
  background-color: #f5f5dc;
  padding: 12px 16px;
  border-radius: 4px;
  font-size: 14px;
  list-style: none;
  color: #333;
}

.history-text + li {
  margin-top: 5px;
}

.register-btn {
  align-self: flex-start;
  background-color: #1a73e8;
  color: #fff;
  border: none;
  border-radius: 4px;
  padding: 10px 28px;
  font-size: 15px;
  cursor: pointer;
}

.register-btn:hover {
  background-color: #1558b0;
}

.total {
  font-size: 15px;
  font-weight: bold;
  color: #333;
}

/* エラーテキストはデフォルトで非表示 */
.error-text {
  color: red;
  display: none;
}

/* .errorクラスが付いたときだけ表示 */
.error {
  display: block;
}

エラー表示の仕組みはCSSで制御しています。
デフォルトでは .error-textdisplay: none を当てておいて、JavaScriptから .error クラスを付け外しすることで表示・非表示を切り替えます。

3. JavaScriptの実装

今回の記事のメインです。JavaScriptのコード全体をまず掲載して、その後ポイントを順番に整理していきます。

アプリ全体のJavaScriptコードです。

"use strict";

{
  // 各DOM要素を取得
  const registerBtn = document.getElementById("register-btn");
  const subjectInput = document.getElementById("subject");
  const hoursInput = document.getElementById("hours");

  const previewSubject = document.getElementById("preview-subject");
  const previewHours = document.getElementById("preview-hours");

  let registerStudies;

  // localStorageに記録があれば読み込んで一覧に表示する
  if (localStorage.getItem("record") !== null) {
    registerStudies = JSON.parse(localStorage.getItem("record"));
    console.log(registerStudies);

    registerStudies.forEach((study) => {
      createRecord(study.subject, study.hours);
    });
  } else {
    // 記録がなければ空配列で初期化
    registerStudies = [];
  }

  // 記録をDOMに追加して合計時間を更新する関数
  function createRecord(subject, hours) {
    const li = document.createElement("li");
    const historyBox = document.getElementById("history-box");
    const totalHours = document.getElementById("total-hours");
    const error = document.getElementById("error");

    let total = 0;

    // 配列をループして合計時間を計算
    registerStudies.forEach((study) => {
      console.log(total);
      total += study.hours;
    });

    // 合計時間をDOMに反映
    totalHours.textContent = total;

    // li要素にクラスとテキストを設定してulに追加
    li.classList.add("history-text");
    li.textContent = `${subject}  ${hours}時間`;
    historyBox.appendChild(li);

    // 入力欄とプレビューをリセット
    previewSubject.textContent = "";
    previewHours.textContent = "";

    subjectInput.value = "";
    hoursInput.value = "";
  }

  // 登録ボタンのクリックイベント
  registerBtn.addEventListener("click", () => {
    // どちらかが空ならエラー表示して処理を止める
    if (subjectInput.value === "" || hoursInput.value === "") {
      error.classList.add("error");
      return;
    }
    // 配列にオブジェクトとして追加
    registerStudies.push({
      subject: subjectInput.value,
      hours: Number(hoursInput.value),
    });
    createRecord(subjectInput.value, hoursInput.value);
    // localStorageに保存
    localStorage.setItem("record", JSON.stringify(registerStudies));
  });

  // 学習内容の入力イベント(リアルタイムプレビュー)
  subjectInput.addEventListener("input", () => {
    previewSubject.textContent = subjectInput.value;
    error.classList.remove("error");
  });

  // 学習時間の入力イベント(リアルタイムプレビュー)
  hoursInput.addEventListener("input", () => {
    previewHours.textContent = hoursInput.value;
    error.classList.remove("error");
  });
}

error 要素の取得や getElementById の呼び出しを各処理の中で都度書いてしまっています。本来はスコープの先頭でまとめて取得するほうがスッキリしますが、「まず動くものを作る」を優先した結果です。この辺りはAIの添削を経て後半で整理しています。

3.1 localStorageで記録を永続化する

ページをリロードしても記録が消えないように、localStorage を使って保存する仕組みを実装しました。

localStorage は文字列しか保存できないので、配列はそのまま保存できません。
保存時は JSON.stringify() で文字列に変換し、読み込み時は JSON.parse() でオブジェクトに戻しています。

ページ読み込み時に記録を復元する処理です。

// localStorageに記録があれば読み込んで一覧に表示する
if (localStorage.getItem("record") !== null) {
  registerStudies = JSON.parse(localStorage.getItem("record"));
  console.log(registerStudies);

  registerStudies.forEach((study) => {
    createRecord(study.subject, study.hours);
  });
} else {
  // 記録がなければ空配列で初期化
  registerStudies = [];
}

登録ボタンが押されたときに保存する処理です。

// localStorageに保存
localStorage.setItem("record", JSON.stringify(registerStudies));

localStorage.getItem() はキーが存在しない場合に null を返します。そのため !== null で判定することで、「保存済みの記録があるかどうか」を確認しています。

3.2 バリデーションでエラーを表示して登録を止める

今回は「入力が空の場合にのみエラーを表示して登録を止める」という実装をしました。

登録ボタンクリック時のバリデーション処理です。

registerBtn.addEventListener("click", () => {
  // どちらかが空ならエラー表示して処理を止める
  if (subjectInput.value === "" || hoursInput.value === "") {
    error.classList.add("error");
    return;
  }
  // 配列にオブジェクトとして追加
  registerStudies.push({
    subject: subjectInput.value,
    hours: Number(hoursInput.value),
  });
  createRecord(subjectInput.value, hoursInput.value);
  localStorage.setItem("record", JSON.stringify(registerStudies));
});

条件に引っかかったときに error.classList.add("error") でエラーを表示して、return を書くことでそれ以降の登録処理が実行されないようにしています。

また、入力するたびにエラーが消えるようにもしました。
input イベントで classList.remove("error") を呼ぶことで、ユーザーが入力を始めたタイミングでエラー表示が消えます。

// 学習内容の入力イベント(リアルタイムプレビュー)
subjectInput.addEventListener("input", () => {
  previewSubject.textContent = subjectInput.value;
  error.classList.remove("error");
});

今回のバリデーションは「空かどうか」のチェックのみにとどめています。

3.3 記録をDOMに追加して合計時間を更新する

登録された記録を画面に表示し、合計時間も毎回計算し直す関数を作りました。

記録追加と合計計算を行う関数です。

function createRecord(subject, hours) {
  const li = document.createElement("li");
  const historyBox = document.getElementById("history-box");
  const totalHours = document.getElementById("total-hours");
  const error = document.getElementById("error");

  let total = 0;

  // 配列をループして合計時間を計算
  registerStudies.forEach((study) => {
    console.log(total);
    total += study.hours;
  });

  // 合計時間をDOMに反映
  totalHours.textContent = total;

  // li要素にクラスとテキストを設定してulに追加
  li.classList.add("history-text");
  li.textContent = `${subject}  ${hours}時間`;
  historyBox.appendChild(li);

  // 入力欄とプレビューをリセット
  previewSubject.textContent = "";
  previewHours.textContent = "";

  subjectInput.value = "";
  hoursInput.value = "";
}

合計時間の計算は、registerStudies 配列を forEach でループして total に足し込んでいます。
記録を追加するたびにこの関数が呼ばれるので、毎回最新の合計が計算されます。

DOMへの追加は createElementli 要素を作り、textContent でテキストをセットしてから appendChildul の中に追加する流れです。

3.4 リアルタイムプレビューの実装

入力しながらその内容がリアルタイムで表示される機能を実装しました。
input イベントを使うことで、ボタンを押さなくても入力のたびに表示が更新されます。

リアルタイムプレビューの処理です。

// 学習内容の入力イベント(リアルタイムプレビュー)
subjectInput.addEventListener("input", () => {
  previewSubject.textContent = subjectInput.value;
  error.classList.remove("error");
});

// 学習時間の入力イベント(リアルタイムプレビュー)
hoursInput.addEventListener("input", () => {
  previewHours.textContent = hoursInput.value;
  error.classList.remove("error");
});

click イベントではなく input イベントを使うことで、文字を打つたびにプレビューがその場でリアルタイムに更新されます。

まとめ

今回の実装で特に印象に残ったのは以下の3点でした。

  • localStorage は文字列しか保存できないので、JSON.stringify()JSON.parse() をセットで使う必要があります
  • バリデーションは「エラーを表示する」+「return で止める」のセットで実装するとシンプルに書けます
  • input イベントを使うと、ボタンを押さなくても入力のたびに処理を走らせられて便利でした

コードはまだ getElementById の呼び出しを各処理で都度書いてしまっていたり、関数の設計がきれいとは言えない部分も正直あります。
それでも「まず動くものを完成させる」を最優先に取り組んだことで、これまで個別に学んできた内容がひとつにつながった感覚がありました。

4. AIに添削してもらった

動くものが完成したあと、AIに添削をお願いしました。
JavaScriptに絞った改善点を整理していきます。

4.1 DOM要素の取得はまとめて先頭に書く

createRecord 関数の中で getElementById を都度呼び出していました。

修正前のコードです。

// 関数が呼ばれるたびに毎回要素を探しに行ってしまっている
function createRecord(subject, hours) {
  const li = document.createElement("li");
  const historyBox = document.getElementById("history-box");
  const totalHours = document.getElementById("total-hours");
  const error = document.getElementById("error");
  // ...
}

修正後のコードです。

// スコープの先頭でまとめて取得しておく
const historyBox = document.getElementById("history-box");
const totalHours = document.getElementById("total-hours");
const error = document.getElementById("error");

function createRecord(subject, hours) {
  const li = document.createElement("li");
  // historyBox, totalHours, error はそのまま使える
}

getElementById は「HTMLの中から要素を探してくる」命令なので、関数が呼ばれるたびに毎回実行するのは無駄な処理になるということでした。HTMLの要素は最初から変わらないので、最初に1回だけ取得して変数に入れておくのがスマートな書き方だと勉強になりました。

4.2 合計時間の計算は専用の関数に切り出す

createRecord 関数の中に合計時間の計算処理が混在していたので、calcTime という専用の関数に切り出しました。

もともとのコードでは createRecord の中に合計計算が書かれていたため、ページ読み込み時に保存済みの記録を復元する際、forEachcreateRecord を呼ぶたびに合計計算も一緒に走ってしまっていました。記録が3件あれば合計計算が3回呼ばれる状態になっていたということです。登録ボタンを押すたびに createRecord が呼ばれるため、登録のたびにも合計計算が余分に実行されていました。

修正前のコードです。

// createRecord の中に合計計算が混ざっていた
function createRecord(subject, hours) {
  let total = 0;

  // 記録が追加されるたびに合計計算が走ってしまっている
  registerStudies.forEach((study) => {
    console.log(total);
    total += study.hours;
  });
  totalHours.textContent = total;

  // DOM追加
  const li = document.createElement("li");
  li.classList.add("history-text");
  li.textContent = `${subject}  ${hours}時間`;
  historyBox.appendChild(li);
  // ...
}

修正後のコードです。

// 合計時間を計算して表示する専用の関数として切り出す
function calcTime() {
  let total = 0;
  registerStudies.forEach((study) => {
    total += study.hours;
  });
  totalHours.textContent = total;
}

// createRecord はDOM追加だけに専念できる
function createRecord(subject, hours) {
  const li = document.createElement("li");
  li.classList.add("history-text");
  li.textContent = `${subject}  ${hours}時間`;
  historyBox.appendChild(li);

  previewSubject.textContent = "";
  previewHours.textContent = "";
  subjectInput.value = "";
  hoursInput.value = "";
}

calcTime を切り出したことで、全件の復元が終わったあとに1回だけ呼び出す形に整理できました。1つの関数が1つの役割だけを担うように書くと、コードが読みやすくなると学びました。

4.3 localStorageの取得は1回だけにする

localStorage.getItem("record") を2回呼び出していました。

修正前のコードです。

// 同じデータを2回取得してしまっている
if (localStorage.getItem("record") !== null) {
  registerStudies = JSON.parse(localStorage.getItem("record"));
}

修正後のコードです。

// 1回だけ取得して変数に入れておく
const savedRecord = localStorage.getItem("record");

if (savedRecord !== null) {
  registerStudies = JSON.parse(savedRecord);
  registerStudies.forEach((study) => {
    createRecord(study.subject, study.hours);
  });
  calcTime();
} else {
  // 記録がなければ空配列で初期化
  registerStudies = [];
}

同じデータを取得するのに2回ストレージにアクセスするのは無駄な処理になるということでした。1回取得した結果を変数 savedRecord に入れておくことで使い回せるようになります。

4.4 console.logを削除する

createRecord 関数内の forEach と、ページ読み込み時の処理に console.log が残ったままになっていました。

// ページ読み込み時
registerStudies = JSON.parse(localStorage.getItem("record"));
console.log(registerStudies); // ← 削除対象

// createRecord 内
registerStudies.forEach((study) => {
  console.log(total); // ← 削除対象
  total += study.hours;
});

console.log はデバッグ中に使う確認ツールなので、完成したコードには残さないのがマナーだということを知りました。開発中に積極的に使いつつ、完成したら忘れずに削除するクセをつけていきたいと思っています。

4.5 改善後のコード全体

以上の改善を反映した main.js の完成コードです。

"use strict";

{
  // DOM要素をまとめて取得
  const registerBtn = document.getElementById("register-btn");
  const subjectInput = document.getElementById("subject");
  const hoursInput = document.getElementById("hours");
  const previewSubject = document.getElementById("preview-subject");
  const previewHours = document.getElementById("preview-hours");
  const totalHours = document.getElementById("total-hours");
  const historyBox = document.getElementById("history-box");
  const error = document.getElementById("error");

  let registerStudies;

  // localStorageへのアクセスは1回だけにする
  const savedRecord = localStorage.getItem("record");

  // 保存済みの記録があれば読み込んで表示する
  if (savedRecord !== null) {
    registerStudies = JSON.parse(savedRecord);
    registerStudies.forEach((study) => {
      createRecord(study.subject, study.hours);
    });
    // リストを表示したあとに合計を1回だけ計算する
    calcTime();
  } else {
    // 記録がなければ空配列で初期化
    registerStudies = [];
  }

  // 記録をDOMに追加する関数
  function createRecord(subject, hours) {
    const li = document.createElement("li");
    li.classList.add("history-text");
    li.textContent = `${subject}  ${hours}時間`;
    historyBox.appendChild(li);

    // 入力欄とプレビューをリセット
    previewSubject.textContent = "";
    previewHours.textContent = "";
    subjectInput.value = "";
    hoursInput.value = "";
  }

  // 合計時間を計算して表示する関数
  function calcTime() {
    let total = 0;
    registerStudies.forEach((study) => {
      total += study.hours;
    });
    totalHours.textContent = total;
  }

  // 登録ボタンのクリックイベント
  registerBtn.addEventListener("click", () => {
    // どちらかが空ならエラー表示して処理を止める
    if (subjectInput.value === "" || hoursInput.value === "") {
      error.classList.add("error");
      return;
    }
    // 配列にオブジェクトとして追加
    registerStudies.push({
      subject: subjectInput.value,
      hours: Number(hoursInput.value),
    });
    createRecord(subjectInput.value, hoursInput.value);
    // 合計時間を更新
    calcTime();
    // localStorageに保存
    localStorage.setItem("record", JSON.stringify(registerStudies));
  });

  // 学習内容の入力イベント(リアルタイムプレビュー)
  subjectInput.addEventListener("input", () => {
    previewSubject.textContent = subjectInput.value;
    error.classList.remove("error");
  });

  // 学習時間の入力イベント(リアルタイムプレビュー)
  hoursInput.addEventListener("input", () => {
    previewHours.textContent = hoursInput.value;
    error.classList.remove("error");
  });
}

「まず動くものを作る」を優先して書いたコードでも、こうして見直すと改善できる点がたくさんあることがわかりました。特に「1つの関数に1つの役割」という考え方は、今後コードを書くうえで意識していきたいと思っています。

1
1
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?