Skip to content

useState, minus the client

State on the server, with a setter you can click.

React is UI = f(state), with a runtime that re-runs f when state changes. This page is the same equation: the state is a Rust struct in a map, the setter is a POST, and the re-render is the response to the redirect that follows it.

Your session

sid 97a242ec…27 live on the server

session.counter

0

session.todos

0 left

Nothing here yet. Add an item and note that it survives navigating away and coming back — but not a server restart, which is the whole argument for a shared store.

POST → 303 → GETEach button is a real form. The handler mutates the session, redirects, and the browser re-requests the page — which is why a refresh never resubmits anything.

The same idea, two languages

Read, write, re-render. The difference is what triggers the re-render.

React

const [count, setCount] = useState(0)

<button onClick={() => setCount(count + 1)}>
  {count}
</button>

// setCount schedules a re-render
// of this component.

axum + maud

async fn page(session: Session) -> Markup {
    view(session.get().counter)
}

async fn bump(session: Session) -> Redirect {
    session.update(|s| s.counter += 1);
    Redirect::to("/state")   // re-render
}

the runtime is the browserReact's runtime re-invokes your component; here the browser re-requests the document. Same equation, different scheduler — and a round trip instead of a microtask.

Where should the state actually live?

This page keeps it in server memory behind a session id. The alternative is to encrypt the state itself into the cookie and keep the server stateless. Both are defensible; they fail differently.

Session id + server storeEncrypted cookie (or JWT)
Size limitNone — it never leaves the server.~2.9 kB after base64 and AEAD overhead.
Cost per requestOne id (32 bytes) plus a map lookup.The whole payload, on every request to the origin.
RevocationDelete the entry; the next request is anonymous.Impossible. Valid until it expires.
Replay / rollbackServer is authoritative; old copies mean nothing.A saved cookie can be restored to rewind state.
Two tabs at onceOne copy, lockable, last write is coherent.Each tab holds its own copy; last write clobbers.
Server restartLost, unless the store is Redis or a database.Survives — the state was never on the server.
Best forAuth, permissions, anything counted or sensitive.Theme, locale, dismissed banners, form drafts.

4096 bytesRFC 6265 asks browsers to support only 4 kB per cookie, counting name and attributes — and the cookie rides along on every request to the origin, static files included.

The budget, in full

4096   RFC 6265 per-cookie floor
 -66   name + Path/HttpOnly/SameSite/Max-Age
────
4030   bytes for the value
×3/4   base64 (3 bytes -> 4 chars)
────
3022   bytes of ciphertext
 -28   AEAD nonce (12) + tag (16)
────
~2.9 kB of serialized state, total.
Budget under 1 kB in practice.

Two things that decide it

An encrypted cookie cannot be revoked — it stays valid until it expires, so you cannot log anyone out — and it can be replayed: a user can restore an old copy and rewind their own state. Fine for a theme; not fine for a credit balance.

A JWT is the same trade with a standard envelope. Signed, not encrypted, so the payload is readable. Its real advantage is verification with a public key across services — which a monolith issuing its own cookies does not need.