はじめに
複数タブで同じWebアプリを操作すると、あるタブの検索状態が別タブへ混入するケースがあります。この種の不具合は、Piniaの反応性や画面遷移の問題に見えます。しかし原因は、タブ固有であるべき検索・遷移状態を、同一サイト内で共有されるCookieに保存していたことかもしれません。
この記事では、検索画面から詳細画面へ渡す一時状態を例に、Cookie、Pinia、sessionStorageの責務を整理します。状態を「どこに置けるか」ではなく、「誰と共有し、いつまで残すか」で選べるようになることが目的です。
結論
Cookieが悪いのではありません。問題は、Cookieの共有スコープと検索状態のスコープが一致していないことです。HTTPリクエストに自動送信したい認証セッションなどはCookieに置き、タブごとに分離したい画面状態は、要件に応じてPiniaまたはsessionStorageに置きます。MDN Web Docs: Using HTTP cookies
| 状態の要件 | 第一候補 | 理由 |
|---|---|---|
| コンポーネントの表示中だけ使う |
ref / reactive
|
コンポーネントの寿命と一致します。 |
| SPA遷移中だけ画面間で使う | Pinia | 現在動いているアプリケーションの状態を共有できます。 |
| タブごとに分離し、リロード後も復元したい |
sessionStorage + Pinia |
sessionStorageはオリジンとトップレベルのブラウジングコンテキストごとに分離されます。 |
| 複数タブ・再起動後も共有したい設定 | localStorage |
オリジン単位で永続し、タブ固有にはなりません。 |
| HTTPリクエストに自動送信したい状態 | Cookie | ブラウザが対象リクエストにCookieを送信します。 |
| ブラウザへ実値を保存したくない | サーバー側ストア + 不透明なID | 実値をサーバー側で管理できます。 |
最小再現:共有Cookieからタブ固有の状態を復元する
検索成功時に、次のような画面遷移用の状態を固定名のCookieへ保存しているとします。
type PendingSearchState = {
recordId: string
searchInput: string
source: 'search' | 'other'
}
const pendingSearchState = useCookie<PendingSearchState>(
'pending_search_state'
)
pendingSearchState.value = {
recordId: 'record-a',
searchInput: 'A社',
source: 'search',
}
タブAとタブBが同じサイトを開くと、操作の順番によって次の状態になります。
タブAでデータAを検索
pending_search_state = record-a
タブBでデータBを検索
pending_search_state = record-b
タブAで詳細画面へ遷移し、CookieからPiniaを復元
PiniaのrecordId = record-b
状態の流れを図にすると、共有領域が汚染源であることが分かります。
この時点では、タブAとタブBのPinia自体は別々に存在していても不思議ではありません。問題は、画面遷移や再初期化のタイミングで、両タブに共通のCookieを読み直し、タブごとのPiniaへコピーしていることです。
Cookieはタブの保存領域ではない
Cookieは、サーバーがSet-Cookieレスポンスヘッダーでブラウザへ渡し、その後の対象リクエストにブラウザが返送する仕組みです。MDN Web Docs: Set-Cookie Cookieの送信範囲は主にDomain、Path、Secure、SameSiteなどで制御されますが、タブA用とタブB用を分ける属性はありません。
たとえば、次のCookieは/search配下へのリクエストで送信されます。
Set-Cookie: pending_search_state=...; Path=/search
しかしPath=/searchは、同じサイトの別タブを分離しません。タブAとタブBがともに/searchを開いていれば、同じ条件に一致するCookieを利用します。PathはCookieをどのURLパスへ送るかを制御するものであり、タブ分離のための境界ではありません。
SameSite、Secure、HttpOnlyはいずれも重要な属性です。しかし、同一サイトを開いた複数タブの画面状態を分ける機能ではありません。認証セッションのようにHTTPリクエストで共有すべき状態にはCookieを使い、タブごとの検索文脈には使わないことが重要です。
Piniaは原因ではなく、状態のコピー先になりやすい
Piniaはアプリケーションの状態をstate()で定義し、ストアインスタンスを通じて参照・更新する仕組みです。Pinia: State 通常、別タブは別のJavaScript実行環境で動くため、タブAとタブBが同じPiniaインスタンスを直接共有するわけではありません。
そのため、次のような構造なら、Piniaを疑う前に復元元を確認します。
共有Cookie
↓ 画面初期化・遷移時に読み直す
タブごとのPinia
↓
画面
Piniaは通常のSPA遷移では状態を保てますが、ページをリロードするとアプリケーションが初期化されます。永続化が必要なら、別の保存先から明示的に復元する必要があります。Piniaの永続化プラグインを使う場合も、本質的には別のストレージへ書き出しているだけです。
sessionStorageはタブ単位の復元に向く
sessionStorageは、オリジンに加えてトップレベルのブラウジングコンテキスト、実質的にはタブごとに分離されます。ページのリロードや復元をまたいで残り、タブまたはウィンドウを閉じると破棄されます。MDN Web Docs: Window: sessionStorage property
この性質は、「タブAではデータA、タブBではデータBを扱い、各タブでF5後も検索文脈を戻したい」という要件と合います。
タブA
sessionStorage.pending_search_state = record-a
タブB
sessionStorage.pending_search_state = record-b
実装例:Piniaを通常の参照先にし、sessionStorageを復元用に限定する
保存先を増やすと、どちらが正なのかが曖昧になりがちです。通常の画面表示ではPiniaだけを読むようにし、sessionStorageはリロード直後にPiniaを復元するためだけに使うと、責務を分けやすくなります。
まず、保存する値を最小限に絞ります。詳細データ全体ではなく、必要なら短命な識別子と画面制御に必要な値だけを持たせます。
export type PendingSearchState = {
recordId: string
searchInput: string
source: 'search' | 'other'
}
次に、sessionStorageを扱う関数を一箇所へ閉じ込めます。Nuxtではブラウザでのみ実行するため、import.meta.clientでガードします。
const KEY = 'pending_search_state'
export function savePendingSearchState(state: PendingSearchState) {
if (!import.meta.client) return
sessionStorage.setItem(KEY, JSON.stringify(state))
}
export function restorePendingSearchState(): PendingSearchState | null {
if (!import.meta.client) return null
const raw = sessionStorage.getItem(KEY)
if (!raw) return null
try {
return JSON.parse(raw) as PendingSearchState
} catch {
sessionStorage.removeItem(KEY)
return null
}
}
export function clearPendingSearchState() {
if (!import.meta.client) return
sessionStorage.removeItem(KEY)
}
Piniaには、画面が使う現在の状態だけを持たせます。
export const useSearchStore = defineStore('search', {
state: () => ({
pending: null as PendingSearchState | null,
}),
actions: {
setPending(state: PendingSearchState) {
this.pending = state
},
},
})
検索成功時には、Piniaを先に更新し、同じ値を復元用ストレージへ保存します。
const store = useSearchStore()
function onSearchSucceeded(state: PendingSearchState) {
store.setPending(state)
savePendingSearchState(state)
}
詳細画面で必要なら、クライアント側の初期化時だけ復元します。すでにPiniaに値がある場合は、sessionStorageで上書きしないことが重要です。
onMounted(() => {
if (store.pending) return
const restored = restorePendingSearchState()
if (restored) {
store.setPending(restored)
}
})
状態が一回限りの遷移用で、詳細画面を閉じた後に不要なら、利用後にclearPendingSearchState()を呼びます。無期限に残さず、業務上の寿命に合わせて消す方針を決めてください。
サーバーがCookieを書いている場合は、責務を移し替える
BFFやサーバーサイドの処理はsessionStorageを直接操作できません。サーバーがCookieへ一時状態を書いていたなら、必要最小限の状態をレスポンス本文として返し、ブラウザ側でPiniaとsessionStorageへ保存するように受け渡しを変えます。
URLに識別子を置けず、ブラウザに実値も保存したくない場合は、サーバー側に短いTTL付きで状態を保存し、ブラウザには推測困難なstateIdだけを渡す方法を検討します。
Browser: stateId
↓
BFF: ログイン中の利用者とstateIdを検証
↓
Server-side store: 検索条件やレコード識別子をTTL付きで保持
この方式では、stateIdと利用者・権限を必ず対応付けます。単にIDを知っているだけで他人の状態を読める設計にしてはいけません。
sessionStorageはJavaScriptから読めるため、認証トークンや詳細データ全体を複製せず、保存する値を最小限にします。
修正後に確認するテスト
この不具合は、単一画面の単体テストだけでは見落としやすい問題です。同一ブラウザコンテキスト内で複数タブを使うE2Eテストを追加し、共有状態が混入しないことを確認します。
| 観点 | 操作 | 期待結果 |
|---|---|---|
| タブ分離 | タブAでデータA、タブBでデータBを検索する | それぞれの詳細画面に対応する対象データだけが表示されます。 |
| タブAの遷移 | タブBの検索後に、タブAを詳細画面へ遷移させる | タブAはデータAのままです。 |
| リロード | 各タブでリロードする | 要件が復元を求めるなら、各タブで元の検索状態を復元します。 |
| 破棄 | タブを閉じて新しいタブを開く | タブ単位の一時状態を復元しません。 |
| 移行確認 | 検索後にCookie一覧を確認する | 廃止対象の画面状態Cookieが作られません。 |
Playwrightでは、同じBrowserContextから2つのPageを作ると、Cookieを共有する複数タブの状況を再現できます。セレクタとURLは実際のアプリケーションに合わせて置き換えます。
import { expect, test } from '@playwright/test'
test('別タブの検索状態を混ぜない', async ({ browser }) => {
const context = await browser.newContext()
const tabA = await context.newPage()
const tabB = await context.newPage()
await tabA.goto('/search')
await tabA.getByLabel('検索条件').fill('record-a')
await tabA.getByRole('button', { name: '検索' }).click()
await tabB.goto('/search')
await tabB.getByLabel('検索条件').fill('record-b')
await tabB.getByRole('button', { name: '検索' }).click()
await tabA.getByRole('link', { name: '詳細' }).click()
await expect(tabA.getByTestId('record-id')).toHaveText('record-a')
await tabA.reload()
await expect(tabA.getByTestId('record-id')).toHaveText('record-a')
})
再現サンプル
この記事で扱ったCookie共有による状態混入と、メモリおよびsessionStorageによるタブ分離は、cookie-tab-state-reproduction で実際に確認できます。ブラウザ画面では「不具合を再現」と「修正後を検証」を切り替えられ、PlaywrightのE2Eテストでは同一BrowserContextの二つのタブを使って両方の条件を検証しています。
git clone https://github.com/tonbiattack/cookie-tab-state-reproduction.git
cd cookie-tab-state-reproduction
pnpm install
pnpm test:e2e
不具合条件では、タブAでrecord-aを検索した後、タブBでrecord-bを検索すると、タブAの詳細にrecord-bが表示されます。修正後の条件では、タブAにはrecord-aが残り、リロード後にもタブごとの状態が復元されます。
まとめ
複数タブで画面状態が混ざる不具合は、状態の値ではなく、状態の共有範囲が過剰であることから起こります。CookieはHTTP通信に必要な状態と相性がよく、Piniaは実行中のSPAの状態と相性がよく、sessionStorageはタブごとに分離された短命な復元状態と相性があります。
実装前には、次の順序で判断します。
- この状態を誰と共有したいかを決めます。コンポーネント、現在のタブ、全タブ、サーバーのどれかを明確にします。
- いつまで残す必要があるかを決めます。SPA遷移中だけか、リロード後も必要か、タブを閉じるまでかを確認します。
- 保存する情報を最小限にし、ブラウザ側に実値を置く必要性を評価します。
- 複数タブを使うE2Eテストで、共有範囲が要件どおりであることを確認します。
状態管理では、保存先の便利さよりも、状態の寿命とスコープを合わせることが優先です。この基準で選べば、Cookie、Pinia、sessionStorageを競合させずに使い分けられます。