@k8ordo/state

サーバーが読む設定をCookieに置く

localStorageに置いた好みはサーバーに届かないので、サーバーは既定値で描き、ハイドレーションのあとで本当の値に切り替わります。表示密度のように、その切り替わりが目に見える好みはCookieに置きます。Cookieはリクエストのたびに届くので、サーバーが最初から本当の値で描けます。

このページの内容

Cookieの状態を定義する

defineCookieStateに、キーとスキーマを渡します。書き方はdefineLocalStateと同じで、スキーマは自分が出した値を受け付けなければなりません。

state.ts
import { defineCookieState } from '@k8ordo/state';
import * as z from 'zod';

export const density = defineCookieState(
  'density',
  z.object({
    density: z
      .enum(['comfortable', 'compact'])
      .default('comfortable'),
  }),
);

値はk8ordo-state.densityという1つのCookieに、スキーマに書いたフィールドのJSONとして保存されます。この名前は、定義のcookieNameで読めます。区切りが:ではなく.なのは、Cookieの名前に:を使えないからです。

同じ理由で、キーに使えるのは英数字と、HTTPのtokenに入る一部の記号だけです。空白や:、;を含むキーは、モジュールの読み込みで拒まれます。

サーバーで読む

@k8ordo/serverでは、ページとレイアウトがリクエストをrequestとして受け取ります。そのrequest.cookiesをparseCookiesに渡すと、スキーマを通した値が返ります。

routes/layout.tsx
import type { LayoutProps } from '@k8ordo/router';

import { density } from '../state';
import { Shell } from './shell';

export default function Layout({
  request,
  children,
}: LayoutProps<'/'>) {
  return (
    <Shell initialCookie={density.parseCookies(request.cookies)}>
      {children}
    </Shell>
  );
}
routes/shell.tsx
'use client';

import { useAppState } from '@k8ordo/state';
import type { OutputOf } from '@k8ordo/state';
import type { ReactNode } from 'react';

import { density } from '../state';

type Props = {
  initialCookie: OutputOf<typeof density.schema>;
  children: ReactNode;
};

export function Shell({ initialCookie, children }: Props) {
  const [values] = useAppState(density, { initialCookie });

  return <div data-density={values.density}>{children}</div>;
}

parseCookiesが受け取るのは、値をパーセントデコードしたReadonlyMap<string, string>です。request.cookiesはこの形なので、そのまま渡せます。Cookieが無いときやJSONが壊れているとき、スキーマに合わないときは、フィールドごとに既定値に戻ります。

読んだ値は、initialCookieとしてuseAppStateに渡します。サーバーの描画とハイドレーションの描画がその値で行われるので、既定値がちらつきません。

読んだ値を渡す範囲

initialCookieが効くのは、それを渡したuseAppStateだけです。

サーバーで描かれるのにinitialCookieを受け取っていないコンポーネントは、サーバーでは既定値を描きます。そのため、レイアウトのような上の方で一度だけ読み、下へ渡してください。

@k8ordo/staticにはリクエストがありません。サーバーの描画は既定値で行われ、ハイドレーションのあとでCookieの値に切り替わります。localStorageと同じ振る舞いです。

ブラウザが書くCookie

update()は、Cookie Store APIでCookieを書きます。付ける属性は次のとおりで、書き込むたびに付け直します。

  • Path=/:サイトのどのパスへのリクエストにも付きます。
  • SameSite=Lax:ほかのサイトのリンクから来た最初のリクエストにも付きます。APIの既定のStrictでは、まさにそのリクエストでサーバーが既定値を描いてしまいます。
  • Max-Age:400日です。ブラウザがCookieを保つ上限で、書くたびに延びます。
  • Secure:APIが必ず付けるので、ページはHTTPSで配信します。ChromiumとFirefoxはhttp://localhostでも保ちますが、Safariはそこでも捨てます。Safariで確かめるなら、開発中もHTTPSで配信してください。

読むときは、document.cookieから同期的に読みます。描画の途中ではAPIのPromiseを待てないからです。ほかのタブの書き込みや、サーバーの応答が設定したCookieは、APIのchangeイベントで届きます。

Cookieはリクエストのたびに送られるので、小さく保ってください。名前と値を合わせて4KBを超えるCookieは拒まれます。そのときはupdate()のハンドルがAPIのTypeErrorでrejectし、描画された値はそのまま残ります。

サーバーから同じCookieを書く

ページは描画するだけで、応答にCookieを書きません。Cookieを書くのは、リクエストに答える場所です。@k8ordo/serverでは、guard.tsとroute.ts、Server Actionがフレームワークのcookies()で書きます。

JavaScriptが無くても好みを変えられるフォームのように、Cookieの状態をサーバーから書きたいこともあります。そのときは、名前にcookieNameを、値にcookieValue()の結果を渡します。cookieValue()は値をスキーマに通し、指定しなかったフィールドを既定値で埋めます。

actions.ts
'use server';

import { cookies } from '@k8ordo/server/runtime';

import { density } from './state';

export async function compact() {
  cookies().set(
    density.cookieName,
    density.cookieValue({ density: 'compact' }),
    { httpOnly: false, maxAge: 34_560_000 },
  );
}

httpOnly: falseは外さないでください。HttpOnlyのCookieはスクリプトから見えないので、ブラウザのストアが読めなくなります。cookies()の既定はPath=/とSameSite=Laxなので、そのほかの属性はブラウザが書くものとそろいます。開いているほかのタブには、changeイベントで新しい値が届きます。

cookieValue()は、エンコードしていないJSONを返します。cookies().setが書き出すときにパーセントエンコードし、parseCookiesはそれを戻した値を受け取るからです。Set-Cookieヘッダーを自分で書くときは、encodeURIComponentを1回だけ通してください。

秘密は置かない

ブラウザが書くCookieは、HttpOnlyにできません。

警告

落とし穴

ページ上のどのスクリプトもこのCookieを読み書きでき、訪問者もURLと同じように書き換えられます。セッションやトークンのように、漏れたり偽造されたりすると困るものは置かないでください。そうしたCookieはcookies()でHttpOnlyとして書き、このパッケージには触らせません。

サーバーで読んだ値も、信頼できる状態ではなく入力として扱ってください。parseCookiesがスキーマを通してから値を返すのは、そのためです。

k8ordo

Baselineに入った機能を、制限なく使うReactのライブラリ群。

© 2026 k8o — MIT License

組版:Noto Sans JP / M PLUS 2