JavaScript:ローカルストレージと状態管理
1学習の目的
- localStorage にデータを保存し、再訪時に復元できるようになる。ページを閉じても消えないアプリが作れるようになる。
- 「状態を1箇所で持ち、変わったら画面を描き直す」という設計を身につける。ReactやVueにも通じる、現代のUI開発の基本形がここで身につく。
2基礎解説
| やりたいこと | 書き方 | 備考 |
|---|---|---|
| 保存する | localStorage.setItem("key", 値) | 文字列しか保存できない |
| 取り出す | localStorage.getItem("key") | 無ければ null |
| オブジェクトを保存 | JSON.stringify(obj) | 文字列に変換してから |
| オブジェクトを復元 | JSON.parse(文字列) | 元の形に戻す |
- 保存できるのは文字列だけ。オブジェクトをそのまま渡すと [object Object] という文字列になり、中身が失われる。必ず JSON.stringify() を通す。
- 数値を保存しても取り出すときは文字列になる。計算に使うなら Number() で変換する(JS02)。
- 存在しないキーは null。JSON.parse(null) は null を返すので、?? 既定値 で受けるのが安全。
- 保存されているのが壊れた文字列だと JSON.parse が SyntaxError を投げる。読み込みは try で囲む(JS10・JS16と同じ)。
- 状態を1箇所で持ち、変わったら「保存 → 再描画」を必ずセットで行う。これを守れば、画面と保存内容がズレることがなくなる。
現場使用例:ダークモードの設定、入力途中の下書き保存、閲覧履歴、ログイン状態の保持、ToDoやカートの中身。サーバーを使わずにデータを残せるのが最大の利点。
// 文字列はそのまま保存できる
localStorage.setItem("theme", "dark");
console.log(localStorage.getItem("theme")); // dark
// オブジェクトは JSON にしてから
const todos = [{ text: "買い物", done: false }];
localStorage.setItem("todos", JSON.stringify(todos));
// 読み込み(無い場合や壊れている場合に備える)
function loadTodos() {
try {
return JSON.parse(localStorage.getItem("todos")) ?? [];
} catch (e) {
return [];
}
}
console.log(loadTodos()); // [{ text: '買い物', done: false }]
// よくある間違い:変換せずに保存する
localStorage.setItem("bad", { a: 1 });
console.log(localStorage.getItem("bad")); // [object Object]
3基本ドリル(10問)
出力 == 田中
setItem で保存、getItem で取得する。
localStorage.setItem("name", "田中");
console.log(localStorage.getItem("name"));
ブラウザを閉じても残るのが localStorage。同じサイトを再訪したとき、前回の状態から続けられる。
A. 正しく保存される B. "[object Object]" という文字列になる C. エラーになる D. null が保存される 選択★☆☆無料
解答 == B
localStorage が保存できるのは何型か。
B
中身が完全に失われるのに、エラーも出ないのが厄介。取り出したときに初めて気づくことになる。必ず JSON.stringify() を通す。
出力 == 未設定
getItem は無ければ null を返す。?? で既定値を指定できる(JS03)。
console.log(localStorage.getItem("missing") ?? "未設定");
?? はnull と undefined のときだけ右側を返す。空文字を有効な値として扱いたい場合、|| より安全(JS03)。
const user = { name: "田中", age: 30 };
localStorage.setItem("user", JSON.____(user));
console.log(localStorage.getItem("user"));
出力 == {"name":"田中","age":30}
オブジェクトをJSON文字列にするメソッド(JS16でも使った)。
stringify
配列でもオブジェクトでも、入れ子になっていてもそのまま文字列にできる。取り出すときは JSON.parse で元に戻す。
A. number B. string C. object D. undefined 選択★☆☆無料
解答 == B
localStorage は何型で保存するか。
B
取り出すと "42" という文字列になる。そのまま計算すると "42" + 1 が "421" になるので、Number() での変換が必須(JS02)。
出力 == 2
保存は JSON.stringify、復元は JSON.parse。復元後は普通の配列として扱える。
const todos = ["買い物", "掃除"];
localStorage.setItem("todos", JSON.stringify(todos));
const loaded = JSON.parse(localStorage.getItem("todos"));
console.log(loaded.length);
stringify と parse は往復の関係。復元した配列には push も map もそのまま使える(JS06・JS09)。
localStorage.setItem("user", '{"name":"田中"}');
const user = JSON.____(localStorage.getItem("user"));
console.log(user.name);
出力 == 田中
JSON文字列を解析してオブジェクトにするメソッド。
parse
parse を通さないと user.name は undefined になる。文字列のままではプロパティにアクセスできない。
A. そのまま使う B. try/catch で受け止めて既定値を返す C. 無視する D. ページを再読み込みする 選択★★☆無料
解答 == B
JSON.parse は失敗すると例外を投げる。
B
保存内容はユーザーが手で書き換えられるし、アプリの更新で形式が変わることもある。読み込みは必ず try で囲み、失敗したら初期状態から始める。
出力 == 6
Number() で変換する。保存が無い場合は ?? "0" で受けるか、Number(null) が 0 になる性質を使う。
localStorage.setItem("count", "5");
const current = Number(localStorage.getItem("count") ?? 0);
const next = current + 1;
localStorage.setItem("count", next);
console.log(localStorage.getItem("count"));
取り出す → 変換 → 計算 → 保存し直すが更新の基本形。訪問回数のカウントなどはこの4手で作れる。
出力 == null
removeItem で1件だけ削除できる。全部消すなら clear()。
localStorage.setItem("temp", "一時データ");
localStorage.removeItem("temp");
console.log(localStorage.getItem("temp"));
clear() はそのサイトの保存を全部消すので扱いに注意。通常は removeItem でキーを指定して消す。
4実践シナリオ(5問)
正常なデータ、未保存のキー、壊れたデータの3通りで試し、「2件」「0件」「0件」の3行を出力せよ。 コーディング★★☆無料
出力3行が完全一致
try の中で JSON.parse し、?? [] で null に備える。catch でも [] を返す。
function loadArray(key) {
try {
return JSON.parse(localStorage.getItem(key)) ?? [];
} catch (e) {
return [];
}
}
localStorage.setItem("ok", JSON.stringify(["A", "B"]));
localStorage.setItem("broken", "{壊れた");
console.log(`${loadArray("ok").length}件`);
console.log(`${loadArray("missing").length}件`);
console.log(`${loadArray("broken").length}件`);
どんな状態でも必ず配列が返るので、呼び出し側は null チェックをしなくて済む。この「常に安全な形で返す」設計が、アプリ全体を壊れにくくする。
出力2行が完全一致
読み込み→変更→保存し直しの流れ。スプレッド構文 { ...settings, theme: "light" } で一部だけ変えられる。
const save = (settings) => localStorage.setItem("settings", JSON.stringify(settings));
const load = () => JSON.parse(localStorage.getItem("settings")) ?? {};
save({ theme: "dark", fontSize: 16 });
const first = load();
console.log(`テーマ:${first.theme} / 文字サイズ:${first.fontSize}`);
save({ ...first, theme: "light" });
const second = load();
console.log(`テーマ:${second.theme} / 文字サイズ:${second.fontSize}`);
保存と読み込みを関数にまとめると、キー名やJSON変換を何度も書かずに済む。{ ...first, theme: "light" } は他の項目を保ったまま1つだけ上書きする書き方(JS07)。
出力3行が完全一致
関数にして3回呼ぶ。取り出した値は文字列なので Number() で変換する。
function visit() {
const count = Number(localStorage.getItem("visits") ?? 0) + 1;
localStorage.setItem("visits", count);
console.log(`${count}回目の訪問です`);
}
visit();
visit();
visit();
Number() を忘れると "01" "011" と文字列が連結されていく。localStorage から取り出した値を計算に使うときは、必ず変換する。
関数 addTodo(text) を作り、① 状態に追加 ② localStorage に保存 ③ 「追加:買い物(全1件)」と出力、の3つを行うこと。「買い物」「掃除」を順に追加し、最後に保存内容を読み直して「保存済み:2件」と出力せよ。 コーディング★★★無料
出力3行が完全一致
todos 配列を上に置き、addTodo の中で push → 保存 → 出力の順に行う。最後は JSON.parse で読み直す。
const KEY = "todos";
const todos = JSON.parse(localStorage.getItem(KEY) ?? "[]");
function save() {
localStorage.setItem(KEY, JSON.stringify(todos));
}
function addTodo(text) {
todos.push({ text, done: false });
save();
console.log(`追加:${text}(全${todos.length}件)`);
}
addTodo("買い物");
addTodo("掃除");
const loaded = JSON.parse(localStorage.getItem(KEY));
console.log(`保存済み:${loaded.length}件`);
状態を変えたら必ず保存する——この順序を徹底することが要点。保存を忘れる箇所が1つでもあると、画面と保存内容がズレて原因の分かりにくいバグになる。
render は ① list の中身を空にする ② 状態から li を作る(done なら "done" クラス)③ summary に「全2件 / 完了1件」と表示する。
「買い物(完了)」「掃除」の2件を状態に入れて render を呼び、li が2つ・うち1つに done クラス・summary が正しいことを確認せよ。 コーディング★★★無料
li 2つ・done 1つ・summary が 全2件 / 完了1件
render の中で list.innerHTML = "" してから作り直す(JS11)。完了数は filter で数える。
const todos = [
{ text: "買い物", done: true },
{ text: "掃除", done: false },
];
const list = document.querySelector("#list");
const summary = document.querySelector("#summary");
function render() {
list.innerHTML = "";
for (const todo of todos) {
const li = document.createElement("li");
li.textContent = todo.text;
if (todo.done) {
li.classList.add("done");
}
list.appendChild(li);
}
const doneCount = todos.filter((t) => t.done).length;
summary.textContent = `全${todos.length}件 / 完了${doneCount}件`;
}
render();
「状態が正、画面はその写し」という考え方。画面を直接いじるのではなく、状態を変えて render を呼び直す。この形なら画面と状態が食い違うことがなく、ReactやVueも同じ思想で作られている。
5仕上げ課題
HTMLには #input / #addBtn / #list / #summary があるものとする。
仕様:
① 起動時に localStorage から状態を読み込む(無い・壊れている場合は空配列)
② save() … 状態を localStorage に保存
③ render() … list を空にして状態から li を作り直す。li は「テキスト+削除ボタン(class="del"、文字は「削除」)」。done なら li に "done" クラス。summary に「全2件 / 完了1件」を表示
④ 追加:入力値で状態に { text, done: false } を追加(空文字は無視)→ 保存 → 再描画 → 入力欄をクリア
⑤ 完了:li のテキスト部分をクリックで done を反転 → 保存 → 再描画(イベント委譲)
⑥ 削除:削除ボタンで状態から取り除く → 保存 → 再描画(イベント委譲)
検証の流れ:「買い物」を追加 → 「掃除」を追加 → 1件目を完了 → 2件目を削除
期待される最終状態:li が1つ・done クラスあり・summary が「全1件 / 完了1件」・localStorage にも同じ内容が保存されている
li 1つ・done あり・summary 一致・保存内容も一致
状態を変える操作はすべて「状態を変更 → save() → render()」の3手をセットで行う。何番目の項目かは、li の位置([...list.children].indexOf(li))で特定できる。イベント委譲は e.target が del クラスかどうかで分岐する(JS12)。
const KEY = "todos";
const input = document.querySelector("#input");
const addBtn = document.querySelector("#addBtn");
const list = document.querySelector("#list");
const summary = document.querySelector("#summary");
function load() {
try {
return JSON.parse(localStorage.getItem(KEY)) ?? [];
} catch (e) {
return [];
}
}
const todos = load();
function save() {
localStorage.setItem(KEY, JSON.stringify(todos));
}
function render() {
list.innerHTML = "";
for (const todo of todos) {
const li = document.createElement("li");
li.textContent = todo.text;
if (todo.done) {
li.classList.add("done");
}
const del = document.createElement("button");
del.textContent = "削除";
del.classList.add("del");
li.appendChild(del);
list.appendChild(li);
}
const doneCount = todos.filter((t) => t.done).length;
summary.textContent = `全${todos.length}件 / 完了${doneCount}件`;
}
addBtn.addEventListener("click", () => {
const text = input.value.trim();
if (!text) {
return;
}
todos.push({ text, done: false });
save();
render();
input.value = "";
});
list.addEventListener("click", (e) => {
const li = e.target.classList.contains("del") ? e.target.parentNode : e.target;
const index = [...list.children].indexOf(li);
if (index === -1) {
return;
}
if (e.target.classList.contains("del")) {
todos.splice(index, 1);
} else {
todos[index].done = !todos[index].done;
}
save();
render();
});
render();
JS12のToDoアプリの弱点が、ついに解消された。ページを閉じても、翌日開いてもデータが残る——ここまで来て初めて「使えるアプリ」になった。
設計の核心は「状態を変更 → save() → render()」を必ずセットで行うことだ。追加・完了・削除のどの操作も、この3手を守っている。もし削除のときだけ save() を忘れたら、画面からは消えるのに再読み込みすると復活する——原因の分かりにくいバグになる。
そしてJS12との決定的な違いは、画面を直接いじっていない点だ。JS12では「li を remove する」と画面を直接操作していたが、今回は状態(todos配列)を変えて render を呼び直すだけ。画面は状態の写しにすぎない。この考え方は「宣言的UI」と呼ばれ、React・Vue・Svelteといった現代のフレームワークすべての土台になっている。あなたはすでにその本質を実装できている。
次章はいよいよ最終回。ここまでの18章すべてを統合して、1本の完成したWebアプリを作る。