Cookie・localStorage・sessionStorage — 三つの質問で決まる
同じ画面でフォルダツリーはCookie、スクロール位置はsessionStorageに分かれた理由
UI状態をどこに置くかは三つの質問で決まる。サーバーが読むか? 最初のHTMLに反映が必要か? タブを閉じても残すか? 前の二つのどちらかがyesならCookie、三つ目がyesならlocalStorage、全部noならsessionStorageだ。読み終えるころには「とりあえずlocalStorage」ではなく、理由をつけて選べるようになる。
トークンや秘密値はこのツリーに乗せない。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に気軽に聞いてほしい。うちもこの基準にたどり着くまで同じ画面を何度か直した。
キーポイント
サーバーがその値で分岐するならCookie。他に選択肢はない
HTML属性・CSSで表せる状態(テーマ、開閉)はCookieにしてサーバーに先に描かせる
どのみちJSでしか戻せない値(スクロール位置)はWeb Storage。タブごとに違うならsessionStorage
トークンはHttpOnly Cookie。Web Storageはtry/catchで囲み、キーは画面+IDで分ける