---
id: 68
title: "The frontend is three packages"
kind: note
status: current
date: 2026-08-30
updated: 2026-08-30
ai_assisted: true
authors:
  - "Theo Zourzouvillys"
tags: [architecture, design, frontend, api, auth]
references:
  - id: rfc9470
    title: "RFC 9470 — OAuth 2.0 Step Up Authentication Challenge Protocol"
    url: https://www.rfc-editor.org/rfc/rfc9470.html
    abstract: "Standards Track, September 2023. Lets a resource server answer a request with 401 and `WWW-Authenticate: Bearer error=\"insufficient_user_authentication\"`, carrying `acr_values` for the assurance level it wants and `max_age` for how recent the authentication must be — a machine-readable statement of what would make the call succeed. The token and introspection response carry `acr` and `auth_time` so the client can tell whether it has been satisfied."
  - id: abramov
    title: "Dan Abramov — Presentational and Container Components (2015)"
    url: https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0
    abstract: "The post that named the split between components that fetch and hold state and components that render. Abramov added an update in 2019 withdrawing the recommendation: he no longer suggests dividing components this way, because hooks separate stateful logic without the arbitrary wrapper. He was careful that what he retracted was the class-era pattern, not separation of concerns."
  - id: spc
    title: "Secure Payment Confirmation (W3C Candidate Recommendation Draft, 2 July 2026)"
    url: https://www.w3.org/TR/secure-payment-confirmation/
    abstract: "A Web API for authentication during a payment. The caller supplies the payee, amount and instrument; the browser guarantees the user is shown them and folds them into `CollectedClientPaymentData`, which replaces the usual client data and cannot be tampered with from JavaScript — so the WebAuthn assertion commits to what was actually on screen, and the verifying server can check that it matches the transaction it is about to execute."
  - id: usesyncexternalstore
    title: "React — useSyncExternalStore"
    url: https://react.dev/reference/react/useSyncExternalStore
    abstract: "The hook for subscribing a component to a store that lives outside React. Takes `subscribe`, `getSnapshot`, and an optional `getServerSnapshot` for SSR and hydration. Its reason to exist is tearing: under concurrent rendering, components reading an external store by other means can render against different versions of it within a single commit."
crossrefs:
  ZFN-66: "The reason the middle layer is where deliberation goes. The hook signature is the boundary with people standing on it; the client behind it and the components above it are both cheap to redo."
  ZFN-14: "Why the base client is generated rather than written. A protocol hand-typed into a client is a second, divergent definition of the protocol."
  ZFN-58: "The argument this note applies to step-up. A challenge is the protocol working, so modelling it as an exception is the same mistake as an untyped error."
  ZFN-38: "The caller that kills the callback design. A script or an agent has no modal to open, and an SDK whose only step-up path is UI cannot serve one."
  ZFN-25: "What the hook layer should wait on after a mutation. A version token beats refetch-and-hope for knowing when a write is visible."
  ZFN-61: "Cancellation as a first-class part of the call, which is what an unmounting component needs from the layer beneath it."
  ZFN-6: "A base-layer decision that the layers above never see — which is the property good layering is bought for."
  ZFN-31: "Why the components have to be replaceable. The people who replace them are the ones who need you most."
  ZFN-22: "The general form of the rule. Each layer here is a seam, and the test is whether a change has one place to live."
  ZFN-23: "What the bottom two packages buy. A component layer you can throw away is only safe if the contract under it holds."
summary: "Three packages, three reasons to change: a transport-agnostic client, headless hooks, components. Step-up auth is the test — the client returns the challenge as data, a host in context resolves it, the caller contributes intent and observes progress, never the mechanism."
supersedes: null
superseded_by: null
aliases: []
---

## TL;DR

**Ship three packages, not one.** A TypeScript client that knows the protocol and nothing about
React. Headless hooks that turn it into React state. Components that render. The layering is not
cosmetic — each layer has a different set of people depending on it and a different reason to
change.

- **The base client is the protocol, in the product's vocabulary, with no React and no DOM.** If it
  cannot run in Node, in a test, or from a cron job, it is not a base layer — it is a UI library
  wearing an SDK label.
- **The headless layer owns everything React makes hard**: subscription, cancellation, out-of-order
  responses, invalidation after a write. It exposes concepts, not endpoints — `useSignIn()`, never
  `usePost('/v1/sign_ins')`.
- **Components are the layer people replace.** Build them as the best example of using the hooks,
  never as the only route to them.
- **The interruption test decides whether the layering is real.** Anything that stops mid-call and
  needs a person — a second factor, a step-up, a consent screen, a device approval — is where naive
  layering breaks. The client returns it as a typed, resumable value, because a headless client has
  nothing else it could honestly do. Not a thrown error, not an `onNeedsMFA` callback.
- **Above that, the caller must not have to *handle* it.** No branching on an MFA case, no
  control-flow handler registered at the call site. A cross-cutting **interaction host**, mounted
  once in application context, owns every challenge kind. The hook hands the challenge to it, and
  the call's promise stays pending until it is satisfied.
- **Transparent is the baseline, not the ceiling.** A caller can subscribe to what is happening —
  refreshing credentials, awaiting a second factor — and can contribute the intent the dialog should
  show, because the call site is the only layer that knows what is being authorised. It supplies
  content and observes status; it never owns the mechanism. `create(body, { intent })` keeps the
  layering, `create(body, { onNeedsMFA })` breaks it.
- Agonize over the middle boundary ([ZFN-66](/zfn/66-agonize-over-the-interface/)). The client
  behind it and the components above it are both cheap to redo; the hook signature is the one with
  strangers standing on it.

## Context

Plenty of engineers who would never let an HTTP handler open a database connection will happily
write a React component that calls `fetch`, parses the response, owns the retry policy, holds the
error state and renders a spinner. The same person, the same week, with a completely different
standard applied on each side of the network.

Part of why is that React is a good enough state container to feel like the architecture. It isn't.
It is the rendering layer, which happens to have state in it. Give a component `useState` and
`useEffect` and it will cheerfully absorb the protocol, because nothing stops it and the first
version works.

The idea has an ancestor. [Presentational and container components](ref:abramov) named a version of
this split in 2015, and Abramov withdrew the recommendation in 2019 — hooks made the wrapper
component an arbitrary division, and he was right that it was. But what he retracted was a split by
*shape*: does this component have markup in it. The split worth keeping is by **who depends on it
and what makes it change**:

- the **client** changes when the API changes;
- the **hooks** change when the product's concepts change;
- the **components** change when the design changes.

Three different rates, three different audiences. Collapsing them means a redesign touches your
retry logic, and an API version bump touches your markup.

The cost lands hardest if you ship an SDK to anyone. Somebody will want your protocol and none of
your components — they have a design system, or a native app, or a framework you have never heard
of. If the only way to get your protocol is to mount your UI, they will reimplement it from the
network tab, badly, and then you own a support burden for a client you did not write and cannot
version.

> [!aside]
>
> The version of this I have regretted most is the callback. It looks so reasonable at the time: the
> client needs a second factor, the app has a modal, wire the two together and move on. It survives
> right up until the first person tries to call the same method from a background job — and then the
> honest fix is the one that was avoided at the start. Make the interruption a value, and let each
> caller decide what "ask the user" means for them.

## Recommendation

### The base client

Plain TypeScript. Generate it from the schema wherever you can
([ZFN-14](/zfn/14-schema-first-apis-generate-clients/)) — a protocol hand-typed into a client is a
second definition of the protocol, and the two will drift.

- **Take a credential provider, not a credential.** An async function that returns a token, supplied
  at construction. Not a React context, not a module global. That single decision is what lets the
  same client run in a browser, on a server, in a test and in a job.
- **Speak the product's nouns.** The client owns retries, idempotency keys, the error taxonomy
  ([ZFN-58](/zfn/58-errors-are-part-of-the-contract/)), deadlines and `AbortSignal`
  ([ZFN-61](/zfn/61-propagate-the-deadline/)).
- **Own the session as one object with one refresh in flight.** Not seven hooks racing to refresh the
  same expired token.
- **No React import, enforced by a rule.** A lint rule or a dependency check, in CI. The invariant is
  directional and it lasts precisely as long as something checks it — someone in a hurry will violate
  it once, and after that it is structural.

### The headless layer

This is the real API, and the layer you cannot take back once people build on it. Spend the
deliberation here ([ZFN-66](/zfn/66-agonize-over-the-interface/)).

- **Expose the product's concepts.** `useSignIn()` returning `{ status, next, submit, error }`, where
  `status` includes the interrupted states as first-class values rather than as an error string
  somebody has to match on.
- **Subscribe to client-owned state with [`useSyncExternalStore`](ref:usesyncexternalstore)**, rather
  than copying it into `useState` from an effect. Copying gives you two sources of truth, and under
  concurrent rendering it gives you tearing — two components in one commit rendering different
  versions of the same session.
- **Own the concurrency problems.** Cancel on unmount. Resolve out-of-order responses by request
  identity rather than arrival order. Invalidate after a mutation — and if your API returns a version
  token, wait on that instead of refetching and hoping
  ([ZFN-25](/zfn/25-read-your-writes-version-token/)).
- **No DOM, no styling, and no strings a designer would want to change.** If a hook returns "Please
  enter your verification code," the hook is doing the component's job and someone's translation file
  is about to acquire a very strange entry.

### The components

- **Composition over configuration.** A `<SignIn />` with forty props is the layering failing
  quietly: every prop is somebody asking for a change you should have let them make by dropping to
  the hook.
- **Ship them as the reference implementation.** Document the hook path as a first-class way to use
  the library rather than an escape hatch, because the people taking the escape hatch are usually the
  ones who need you most ([ZFN-31](/zfn/31-own-your-components/)).
- Accessibility and internationalisation live here, and nowhere else.

### Interruptions that need a person

A call at the base layer — `client.account.delete()`, `client.transfers.create()` — can come back
saying *not until this person proves who they are again*. There are three ways to model that, and
two of them quietly destroy the layering.

- **Throw it.** Now a second-factor request is an error, and every caller writes a catch block that
  reconstructs a flow from a string. But nothing went wrong: the server asking for stronger
  authentication is the protocol working exactly as designed
  ([ZFN-58](/zfn/58-errors-are-part-of-the-contract/)).
- **Call back into the UI.** Pass `onNeedsMFA` into the client and let it open a modal. The base
  layer now knows about UI, so it cannot be called from a test, a job, or an agent
  ([ZFN-38](/zfn/38-agents-are-principals/)) — none of which has a modal. This inverts the dependency
  in the exact direction the layering exists to prevent.
- **Return it.** The call resolves to a discriminated union: the result, or a **challenge** — a typed
  value naming what is required, what it is for, and a token to resume with. A component can render
  it. A test can satisfy it programmatically. A script can refuse it and say clearly why it stopped.

The protocol already does it the third way. [RFC 9470](ref:rfc9470) has the resource server answer
`401` with `WWW-Authenticate: Bearer error="insufficient_user_authentication"`, plus `acr_values` for
the assurance level it wants and `max_age` for how fresh the authentication has to be. That is a
machine-readable statement of what would make this call succeed. Your client's only job is to keep it
machine-readable all the way up, instead of flattening it into an exception at the first layer and
making every consumer reconstruct what the server already said precisely.

That settles the *client's* obligation, and it is only half the problem. A value the client returns
is a value something above it has to deal with, and the obvious next move — surface the union to
whoever called `submit()` — is a mistake with a long tail. Step-up is a cross-cutting concern.
Handing it to call sites means every one of them branches on it, and the day you add device approval
you are editing all of them, or worse, finding out which ones quietly fell through.

### The interaction host

**The caller must not have to learn that a second factor happened.** Not a branch on a union, not a
control-flow handler, not a prop threaded down. Application code calls `submit()` and awaits a
result, and by default the interruption is resolved underneath it. *Must not have to* is the
operative phrase: a caller that wants to take part has ways to, and they are the next section.

What makes that work is a host: one component, mounted once in application context, that knows how
to satisfy each kind of challenge. The hook hands the challenge to it and — this is the mechanical
core — **the call's promise stays pending throughout**. It never resolves *to* a challenge. It
resolves to the result, or it rejects. The interaction happens in between, and the only thing the
caller can observe about it is a status on the hook.

This is the same inversion an HTTP client already makes for token refresh. Nobody writes
`if (result.kind === 'token_expired') refresh()` at every call site; the client does it, and the
caller sees a slightly slower call. Step-up is token refresh with a person in the loop, and it
earns the same treatment — as do consent screens, device approvals, and re-auth on a sensitive
field. These are the frontend's cross-cutting concerns, and they belong with the other things you
mount once and stop thinking about: the toast host, the error boundary, the router.

**Where the host sits in the three packages.** The contract belongs to the headless layer — the
challenge types, the context, and the progress states a hook is allowed to report. The *default*
host, the one that actually renders a modal, ships in the components package, so an app can replace
it without touching either layer beneath. And because it is context rather than a singleton, nesting
gives scoped override for free: a settings pane that wants re-auth inline rather than in a global
modal mounts its own host, and only the calls inside it change behaviour.

**A challenge with no host has to fail loudly.** Reject quickly, naming what was needed, rather than
leaving a promise pending forever — a hang here is indistinguishable from a slow network. That case
is real in tests and in server rendering. It is *not* the script-and-agent case
([ZFN-38](/zfn/38-agents-are-principals/)): those callers use the base client directly and get the
returned value, never a hook, which is precisely why the client's shape had to stay honest.

**Resumption is the hard part, and a pending promise is only its in-tab version.** Inside one page
the host resumes the call that raised the challenge and the `await` completes. But a second factor
can take the person out of your tab entirely — to an authenticator, an email client, a bank app —
and a page reload destroys every pending promise there was. So the flow cannot live in the promise.
It lives in the host and the client's own state: the challenge, its resumption token and its
progress are durable, a remounted host picks the flow back up, and the UI re-derives what to show
from the status signal rather than from a continuation it no longer holds. A design that only
resumes inside the React tree that raised it works in the demo and fails on a phone.

### What the caller contributes

Default-transparent is the floor, not the ceiling. A call that contributes nothing still works — the
host shows a generic prompt, the flow completes, the dialog is merely worse. Everything below is
opt-in on top of that, and none of it changes what the promise resolves to: intent is an input at
call time, status is a side channel, and the call still resolves to the result or rejects.

**Status is observable.** A caller can subscribe to what is happening — refreshing credentials,
awaiting a second factor, waiting on the authenticator. This matters more than it sounds. A silent
eight-second pause while somebody hunts for their phone is indistinguishable from a hang, and a
button that can say *check your phone* is the difference between a considered interface and an
apparently broken one. Place it like everything else: the base client emits progress as events,
because events are data and a headless client can emit them; the hook surfaces the status of its own
call; the app can subscribe globally for a top-level indicator.

**Intent is contributable.** The host owns the mechanism — which authenticator API, the retry, the
modal chrome, the accessibility. The call site owns the meaning, and it is the only layer that has
it. A dialog reading *Confirm with your passkey* and nothing else is phishing-shaped by
construction: it asks somebody to authorise something they cannot see. When the server wants a
second factor before it will sign over a payload, the call that knows what that payload is should be
able to say so, and the host should render it.

**The line that keeps this layered** is that content and status cross it, and mechanism and control
flow do not. Contributions are declared as part of the call rather than registered as a handler
beside it, which is the entire distinction in one line: `transfers.create(body, { intent })` stays
layered, `transfers.create(body, { onNeedsMFA })` does not. The first passes data the host will
render. The second hands a call site a job that belongs to the host, and it is what comes back to
bite the day you add a second challenge kind.

**Bind what you display, or it is only decoration.** A contributed description is a string, and a
string can be wrong — stale, mistranslated, or swapped by an attacker who reached the page. [Secure
Payment Confirmation](ref:spc) is the worked example of doing this properly: the caller supplies the
payee, the amount and the instrument, the browser guarantees the person is shown them, and it folds
them into the signed client data, so the assertion commits to what was on screen. The server
verifying that assertion can then check that what the person saw matches what it is about to do.
That turns a courtesy into a property somebody can verify.

There is no general mechanism for this outside payments, and the history explains why. WebAuthn
Level 1 carried a `txAuthSimple` extension for exactly this purpose — a prompt string for the
authenticator to display — and Level 2 dropped it, because browsers never implemented it and almost
no authenticator has a trusted display to render it on. SPC works precisely by moving both the
display and the binding into the browser. For anything that is not a payment, the job is left with
you: your host renders the contributed context, and you bind it into whatever the server verifies.
Do the first without the second and you have built a confirmation dialog capable of lying.

### Session and credentials

- **One session object, at the base layer.** Refresh deduplication happens there. A React context
  *reads* the session; it does not own it.
- **Bind the credential to a key the client holds** where you can
  ([ZFN-6](/zfn/6-sender-constrained-tokens-dpop/)). That is a base-layer decision, invisible to
  everything above it — which is exactly the property the layering was bought for.
- **Sign-out, expiry and revocation are events the client publishes** and the hooks subscribe to.
  Anything else and half the UI keeps confidently rendering a signed-in state for a session that
  ended.

## Consequences

**Easier:**

- Someone can adopt your protocol and none of your UI. Treat that as the product working, not as a
  defection.
- Testing splits along the seams: contract tests for the client with no renderer at all, a test
  renderer plus a fake client for the hooks, and whatever visual testing you do for the components.
- Replacing the component layer — a redesign, a different framework, a native shell — stops being a
  rewrite ([ZFN-23](/zfn/23-iterate-and-rewrite-implementations/)). The bottom two packages are the
  asset.
- Non-browser callers work by construction, because they were never retrofitted.
- A slow step-up stops looking like a hang, because the status channel exists whether or not a given
  call chooses to render it.
- Adding a challenge kind is a change in one host, not a migration across every call site. Hand a
  discriminated union to callers instead and each new variant becomes a breaking change — or a
  silently unhandled case, which is worse.

**Harder:**

- Three packages to version, publish and keep in step, and somebody will end up running a mismatched
  set of them.
- The headless layer is hard to design and expensive to change once shipped. That is the point of
  putting it in the middle, but it does mean the difficult work arrives early.
- Every convenience arrives as pressure to break a layer — "just let the client take a router so it
  can redirect." The answer is no, followed immediately by the seam that gets them what they wanted.
- Contributed display text becomes a security surface. If what the dialog shows is not bound into
  what the server verifies, you have two representations of one action, free to drift, and the
  cheerful failure mode is a dialog that authorises something other than what it describes.
- The host becomes a dependency of anything interruptible, so a test rendering such a hook has to
  mount one, and a challenge kind nobody registered has to reject loudly instead of hanging. Both
  failures are quiet by default and worth a deliberate test each.
- For a small app that will never expose an SDK, this is more indirection than the problem deserves.
  The layering pays off when there is a second consumer; be honest about whether there will be one.

**New obligations:**

- A dependency rule enforcing the direction of imports, running in CI. One-directional invariants
  survive only while something checks them ([ZFN-22](/zfn/22-extract-complexity-at-the-seam/)).
- Interruptions become a design surface with an owner. Every new one — another factor, a consent
  screen, a device approval, a re-auth on a sensitive field — has to be expressible as a challenge
  value *and* registrable with the host, so adding one reaches neither the call sites nor the
  client. The first one that cannot be will arrive as a callback and take the layering with it.

## References

- [RFC 9470](ref:rfc9470) — step-up authentication as a machine-readable challenge rather than a
  failure. The precedent for returning an interruption as data, from the layer that had the same
  problem first.
- [Abramov, Presentational and Container Components](ref:abramov) — the ancestor idea, and the
  author's own 2019 retraction of it. Worth reading for what he withdrew: a split by component shape,
  not a split by rate of change.
- [Secure Payment Confirmation](ref:spc) — caller-supplied transaction detail, displayed by the
  browser and bound into the assertion. The model for letting a call site contribute to a dialog
  without the contribution being merely cosmetic.
- [`useSyncExternalStore`](ref:usesyncexternalstore) — the supported seam between a store React does
  not own and a component tree that renders from it.
- [ZFN-66](/zfn/66-agonize-over-the-interface/) — why the middle boundary gets the deliberation and
  the two layers around it do not.
- [ZFN-14](/zfn/14-schema-first-apis-generate-clients/) — generate the base client; a hand-written one
  is a second definition of the protocol.
- [ZFN-58](/zfn/58-errors-are-part-of-the-contract/) — a challenge is not an error, and the taxonomy
  is what tells a caller which it has.
- [ZFN-38](/zfn/38-agents-are-principals/) — the non-interactive caller that a UI callback locks out.
- [ZFN-25](/zfn/25-read-your-writes-version-token/) /
  [ZFN-61](/zfn/61-propagate-the-deadline/) — what the hook layer should wait on, and what it should
  cancel.
- [ZFN-31](/zfn/31-own-your-components/) — the component layer is the replaceable one, on purpose.

## Changelog

- **2026-08-30**: First published as a Field Note.
- **2026-08-30**: Reworked the interruption argument. The first version had the challenge surface to
  the caller as a discriminated union to branch on, which pushes a cross-cutting concern straight
  back into every call site. Replaced with an interaction host in application context: the hook
  delegates to it, the call's promise stays pending, and the caller sees only a progress signal.
  Added where the host sits across the three packages, and separated in-tab resumption from
  resumption across a page reload.
- **2026-08-30**: Softened "the caller never learns" to "never *has to*". Transparent by default was
  right; mandatory ignorance was not. Added what a caller may opt into — subscribing to status, and
  contributing the intent a dialog renders — with the line drawn at content and status crossing the
  boundary while mechanism and control flow stay behind it. Added the requirement to bind what is
  displayed, with Secure Payment Confirmation as the worked example.
