About

Cookie・localStorage・sessionStorage — 三つの質問で決まる

同じ画面でフォルダツリーはCookie、スクロール位置はsessionStorageに分かれた理由

UI状態をどこに置くかは三つの質問で決まる。サーバーが読むか? 最初のHTMLに反映が必要か? タブを閉じても残すか? 前の二つのどちらかがyesならCookie、三つ目がyesならlocalStorage、全部noならsessionStorageだ。読み終えるころには「とりあえずlocalStorage」ではなく、理由をつけて選べるようになる。

1. Does the server read it? sent with every request 2. Must the first HTML show it? otherwise it flickers (FOUC) 3. Keep it after the tab closes? survive a restart yesyesyesnonono Cookielogin session Cookiedark theme, open folders localStoragepreferences on this device sessionStoragelist scroll position Tokens, secretsHttpOnly Cookie. No tree.

トークンや秘密値はこのツリーに乗せない。HttpOnly Cookie一択だ。理由はセキュリティの節で。

同じ画面なのに二つに分かれた

最近あるアプリで、サイドバーのフォルダツリーの開閉と、一覧のスクロール位置を両方覚えさせた。最初は「どっちもUI状態だから同じ場所でいいだろう」と思っていたが、詰めてみると答えが違った。

フォルダツリー → Cookie。 開閉は <details open> 属性ひとつで表せる。サーバーがその値を知っていれば、最初から開いた状態で描ける。localStorageに置くとサーバーは知らないので、全部閉じた状態で描かれてからJSが遅れて開く。このチラつきがFOUC(Flash of Unstyled Content、スタイルが当たりきる前の画面が一瞬見える現象)だ。

スクロール位置 → sessionStorage。 scrollTop はHTML属性やCSSで作れる値ではない。サーバーが知っていても、結局JSで入れることになる。ならCookieにする得はゼロで、画像・CSS・APIのリクエストのたびに値が乗っていくコストだけが残る。タブごとに違うのが自然だし、盗まれても意味のない値でもある。

分かれ目は「CSSやHTML属性で表せる状態か」だった。表せるならサーバーが先に描けるので、Cookieが得をする。

六つの軸を表で

軸 Cookie localStorage sessionStorage
サーバーへの送信 毎リクエスト自動 しない しない
初回描画への反映 できる できない(JSの後) できない(JSの後)
寿命 Max-Age で指定 消すまで タブを閉じるまで
共有範囲 ドメイン+パス、サブドメイン共有も可 originの全タブ origin+タブ単位
容量 1個4KB前後 originあたり5MB前後 originあたり5MB前後
JSから隠す HttpOnly で可 不可 不可

容量はブラウザによって少しずつ違う。数字はMDNを基準にした目安だ。

localStorageの「消すまで」は思ったより長い。何か月も前の状態がよみがえって「なんでこうなる?」というバグはここから来る。よみがえると困る値は入れない。

セキュリティ — JWTをlocalStorageに置かない理由

Web StorageはページのJSなら誰でも読める。XSSが一度でも通れば、入っているトークンはそのまま持っていかれる。Cookieは HttpOnly を付ければJSから読めなくなり、Secure・SameSite も付けられる。これはCookieにしかできない。だから「localStorageにJWT」は避ける。

逆にスクロール位置のように盗まれても意味のない値なら、この軸は気にしなくていい。

実際に踏んだもの

sessionStorageは例外を投げることがある。容量があふれたり、ストレージが塞がれた環境では setItem がthrowする。うちはこれを requestAnimationFrame のコールバック内で呼んでいたので、スクロールのたびに例外が積み上がった。読み書きの両方を囲む。

const key = `scroll:contracts:${listId}`   // 画面+対象IDでキーを分ける

function readScroll() {
  try { return Number(sessionStorage.getItem(key)) || 0 } catch { return 0 }
}
function saveScroll(y) {
  try { sessionStorage.setItem(key, String(y)) } catch {}
}

キーを scroll ひとつにまとめると、画面Aの値が画面Bに漏れる。画面名と対象IDをキーに入れておけば、その事故は構造的に起きない。

「タブを閉じたら消える」も完全には正しくない。閉じたタブを復元(Cmd+Shift+T)したり、タブを複製したりすると、sessionStorageも一緒についてくる。

Cookie側は、静的ファイルのリクエストにも乗ることを忘れがちだ。1ページにリクエストが50本あれば50回乗る。UIの細かい状態をCookieに詰め込むと、4KBの上限もすぐ埋まる。

数MB級のデータや構造のあるデータなら、三つとも違う。IndexedDBに行く。Web Storage APIは同期なので、大きな値を読み書きする間メインスレッドが止まる。

Rails・Hotwireの画面でこの手の状態管理を整理していて詰まったら、negabaroに気軽に聞いてほしい。うちもこの基準にたどり着くまで同じ画面を何度か直した。

キーポイント

1

サーバーがその値で分岐するならCookie。他に選択肢はない

2

HTML属性・CSSで表せる状態(テーマ、開閉)はCookieにしてサーバーに先に描かせる

3

どのみちJSでしか戻せない値(スクロール位置)はWeb Storage。タブごとに違うならsessionStorage

4

トークンはHttpOnly Cookie。Web Storageはtry/catchで囲み、キーは画面+IDで分ける

ユースケース

ダークモードやサイドバーの折りたたみなど、初回描画でチラついてはいけない設定 一覧 → 詳細 → 戻るでのスクロール位置の復元
← Pretext — DOMなしでテキスト高さを測定する純粋JSレイアウトライブラリ event.target vs currentTarget — アイコンの線の上を押した時だけ起きるバグ →