はじめに
小さなWebサービスで「お気に入り」機能を付けたいけれど、そのためだけに会員登録やDBを用意するのは大げさ……ということはよくあります。そんなときに便利なのが localStorage です。
たとえば、ブラウザで動くミーム系サウンドボードの Meme Tap では、ボタンをハートするとお気に入りがそのブラウザだけに保存され、アカウントなしで一覧に戻ってこられます。ユーザーから見れば「押したら残る」だけのシンプルな機能ですが、実装してみると意外と落とし穴があります。
- 保存形式を後から変えたくなった
- 別タブで追加したお気に入りが反映されない
-
JSON.parseが壊れたデータで例外を投げて画面が真っ白になる - Next.js などのSSRでハイドレーションエラーが出る
この記事では、こうした問題を避けながら、ログイン不要のお気に入り機能を TypeScript と React で堅く作る方法をまとめます。コードは筆者が汎用的に書き直したもので、特定サービスの実装そのものではありません。
1. 何を保存するか:IDだけか、スナップショットか
最初に決めるべきなのは「何を保存するか」です。
| 方式 | メリット | デメリット |
|---|---|---|
| IDだけ保存 | 小さい・常に最新の情報を表示できる | 一覧表示のたびにAPIで詳細を取得する必要がある |
| 表示用の情報も保存 | オフラインでも一覧を即表示できる | 元データが変わると古い情報が残る |
おすすめはIDに加えて、表示に最低限必要な情報(名前・URL)だけを持つ折衷案です。音源が削除されたりリネームされたりしても、一覧が「空白のボタン」にならずに済みます。
type FavoriteV2 = {
id: string; // 例: "memes/vine-boom"
title: string; // 一覧表示用
addedAt: number; // 並び替え用 (Date.now())
};
2. スキーマにバージョンを付ける
localStorage に生の配列を入れてしまうと、後から形式を変えたときに古いデータとの区別がつきません。最初からバージョン付きの入れ物にしておきます。
const KEY = "favorites";
const CURRENT_VERSION = 2;
type Stored =
| { v: 1; ids: string[] }
| { v: 2; items: FavoriteV2[] };
function migrate(raw: unknown): FavoriteV2[] {
// バージョンなしの旧形式(ただの配列)にも対応
if (Array.isArray(raw)) {
return raw.filter((x) => typeof x === "string")
.map((id) => ({ id, title: id, addedAt: 0 }));
}
const data = raw as Stored;
if (data?.v === 1) {
return data.ids.map((id) => ({ id, title: id, addedAt: 0 }));
}
if (data?.v === 2 && Array.isArray(data.items)) {
return data.items.filter((x) => x && typeof x.id === "string");
}
return []; // 想定外の形式は捨てる
}
ポイントは、読み込み時に必ず最新形式へ変換し、書き込みは常に最新形式で行うことです。こうしておけば、アプリ側のコードは FavoriteV2[] だけを相手にすればよくなります。
3. 読み書きは必ず try/catch で包む
localStorage は意外とよく失敗します。
- ユーザーや拡張機能が値を書き換えて、JSONとして壊れている
- 容量上限(多くのブラウザでオリジンごとに数MB程度)を超えた
- 古いブラウザのプライベートモードやストレージが無効な環境で、アクセス自体が例外になる
お気に入りが保存できないのは残念ですが、画面ごと落ちるのは最悪です。
function read(): FavoriteV2[] {
try {
const text = window.localStorage.getItem(KEY);
return text ? migrate(JSON.parse(text)) : [];
} catch {
return [];
}
}
function write(items: FavoriteV2[]): boolean {
try {
const payload: Stored = { v: CURRENT_VERSION, items };
window.localStorage.setItem(KEY, JSON.stringify(payload));
return true;
} catch (e) {
console.warn("お気に入りを保存できませんでした", e);
return false; // UI側で「このブラウザでは保存できません」と表示する
}
}
4. タブ間で同期する:storageイベント
ユーザーがタブを2つ開いていて、片方でお気に入りを追加したとします。もう片方のタブは何も知らないので、そこで別の操作をして保存すると、追加分が上書きで消えます。
これを防ぐのが storage イベントです。注意点として、このイベントは値を変更したタブ自身では発火せず、同じオリジンの他のタブでだけ発火します。そのため、自分のタブ向けには独自の通知を併用します。
type Listener = () => void;
const listeners = new Set<Listener>();
let cache: FavoriteV2[] | null = null;
function emit() {
cache = null; // 次回 getSnapshot で読み直す
listeners.forEach((l) => l());
}
if (typeof window !== "undefined") {
window.addEventListener("storage", (e) => {
if (e.key === KEY || e.key === null) emit(); // null は clear() のとき
});
}
export const favoritesStore = {
subscribe(l: Listener) {
listeners.add(l);
return () => listeners.delete(l);
},
getSnapshot(): FavoriteV2[] {
return (cache ??= read());
},
toggle(item: Omit<FavoriteV2, "addedAt">) {
const items = read(); // 必ず最新を読み直してから変更する
const exists = items.some((x) => x.id === item.id);
const next = exists
? items.filter((x) => x.id !== item.id)
: [{ ...item, addedAt: Date.now() }, ...items];
if (write(next)) emit();
},
};
toggle の中でキャッシュではなく read() で読み直しているのがポイントです。別タブの変更を取りこぼさずに済みます。
5. Reactから使う:useSyncExternalStore
外部ストアをReactにつなぐなら useSyncExternalStore が素直です。getSnapshot が毎回新しい配列を返すと無限再レンダリングになるため、上のコードではキャッシュを持たせています。
import { useSyncExternalStore } from "react";
const EMPTY: FavoriteV2[] = [];
export function useFavorites() {
return useSyncExternalStore(
favoritesStore.subscribe,
favoritesStore.getSnapshot,
() => EMPTY, // サーバー側のスナップショット
);
}
export function HeartButton({ id, title }: { id: string; title: string }) {
const favorites = useFavorites();
const active = favorites.some((x) => x.id === id);
return (
<button
aria-pressed={active}
aria-label={active ? `${title}をお気に入りから外す` : `${title}をお気に入りに追加`}
onClick={() => favoritesStore.toggle({ id, title })}
>
{active ? "♥" : "♡"}
</button>
);
}
6. SSR(Next.jsなど)での注意
サーバーには localStorage がないので、サーバーでは「お気に入りゼロ」としてHTMLを作るしかありません。クライアントで読み込んだ瞬間にハートが変わるため、ここでハイドレーションの不一致が起きがちです。
useSyncExternalStore の第3引数(getServerSnapshot)で空配列を返しておくと、Reactはハイドレーション時にまずサーバーと同じ値で描画し、その後クライアントの値で再描画してくれます。一覧ページなら、読み込み前は「お気に入りを読み込み中」のプレースホルダーを出しておくと、空表示のチラつきも目立ちません。
7. ユーザーへの説明とバックアップ
ログイン不要は手軽ですが、裏返すとブラウザのデータを消すとお気に入りも消えるということです。別の端末やブラウザにも引き継がれません。
- お気に入りページに「このブラウザにのみ保存されます」と一言書く
- JSONでのエクスポート/インポートを用意しておく
export function exportFavorites() {
const blob = new Blob([JSON.stringify(read(), null, 2)], { type: "application/json" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "favorites.json";
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
}
これだけでも「消えた!」という問い合わせはかなり減ります。
まとめ
- 保存するのは「ID+表示に必要な最小限の情報」
- 最初からバージョン付きの形式にして、読み込み時に移行する
- 読み書きは必ず try/catch で包み、失敗してもアプリを落とさない
-
storageイベントと自前の通知でタブ間を同期する - React では
useSyncExternalStore、SSR では空のサーバースナップショット - 「このブラウザだけに保存」とユーザーに伝える
お気に入り機能は地味ですが、リピーターにとっては一番よく使う機能のひとつです。小さなサービスほど、こういう部分をきちんと作っておく価値があると感じています。
Abid Ali:パキスタンのフルスタックWeb開発者。ブラウザで使える無料のミームサウンドボード「Meme Tap」を作っています。
