0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ログイン不要の「お気に入り」機能をlocalStorageで堅く作る:スキーマ移行・タブ間同期・SSR対策まで

0
Posted at

memetap.jpg

はじめに

小さな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」を作っています。

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?