State
Funnel state is a set of hooks, not a single object you thread through props. This guide covers the three writable namespaces, the read-only context, how writing user.email identifies a visitor, session persistence, and the imperative handle for event handlers. Full signatures are in the hooks reference.
The four namespaces
There are three writable namespaces and one read-only computed one:
| Namespace | Writable? | What it holds |
|---|---|---|
user | yes | Attributes about the person: email, country, and custom fields. |
responses | yes | Captured visitor input — the answers your funnel collects. |
data | yes | Working/scratch state, persisted but kept out of response analytics. |
context | no (computed) | Device, locale, UTM, click-ids, session, system — read-only. |
You read and write the first three with useState-shaped hooks. You read context with dedicated hooks. Entitlements are not here — that’s the separate app-side IdentitySDK, not the funnel.
Writing and reading — the field hooks
Each writable hook takes a bare key (the namespace is implied by the hook) and returns a [value, setValue] pair, like useState:
import { useResponse, useUserAttribute, useData } from '@appfunnel-dev/sdk'
function GoalStep() {
const [goal, setGoal] = useResponse('goal') // responses.goal
const [email, setEmail] = useUserAttribute('email') // user.email
const [score, setScore] = useData('quizScore') // data.quizScore
// ...
}| Hook | Reads/writes | Notes |
|---|---|---|
useResponse(name) | responses.<name> | The primary way to capture and read visitor answers across pages. Persisted; feeds analytics. |
useUserAttribute(name) | user.<name> | Any field on the user. Writing email also identifies the visitor (see below). |
useData(name) | data.<name> | Working state. Persisted across reload/return, but excluded from response aggregation. |
useField(path) | any writable path | Headless building block taking a full "<ns>.<key>" path; returns { value, set }. This is the seam <Choice> wraps. Throws if the path isn’t a writable namespace. |
Writes are reactive per key: a component reading responses.goal re-renders when goal changes, and only then.
Values are typed by the declaration in funnel.ts — string, number, boolean, or stringArray. A response you never declared reads as its default (or undefined). Declare the variables you use in funnel.ts responses/data; see Funnel Config.
Writing user.email identifies the visitor
This is the one write with a side effect worth understanding before you rely on it.
const [, setEmail] = useUserAttribute('email')
setEmail('you@example.com') // also fires identify + user.registeredWriting user.email doesn’t just store a string — it identifies the visitor, and on the first email write it fires user.registered. The consequence to reason about: identity is merge-on-email. Two visits that write the same email are treated as the same person, so their data merges into one customer. If a visitor enters one email, then goes back and enters a different one, you’ve now written two.
Practically:
- Put the email write behind validation (the template validates the address before advancing). Don’t identify on every keystroke of a half-typed address.
- Treat the email step as the identity commit point. That’s why it’s a distinct page type —
type: 'email-capture'— and why paywall/upsell pages guard onuser.emailexisting.
Reading context
The read-only context namespace is computed for you from the visitor’s request and environment. Read pieces of it with dedicated hooks:
import { useDevice, useLocale, useUtm, useSystem } from '@appfunnel-dev/sdk'
const device = useDevice() // { type, isMobile, viewportWidth, ... }
const locale = useLocale() // { locale, language, region, timeZone }
const utm = useUtm() // { source, medium, campaign, content, term }
const system = useSystem() // { funnelId, campaignId, mode: 'test'|'live', now }| Hook | Returns |
|---|---|
useDevice() | Device type, isMobile/isTablet, viewport and screen dimensions, pixel ratio. |
useLocale() | Resolved locale (en-US), language (en), region (US), IANA timeZone. |
useUtm() | The five UTM params captured on entry, plus any extras. |
useClickIds() | Per-network ad click-ids captured on entry (fbclid, gclid, ttclid, …). |
useSystem() | funnelId, campaignId, mode ('test' or 'live'), and now (a stable build-time clock). |
useContextValue(path) | Read any dotted context path directly, e.g. useContextValue('device.isMobile'). |
These are the same values your routing conditions read as context.* — so { field: 'context.device.isMobile', op: 'eq', value: true } in funnel.ts and useDevice().isMobile in a page see the same thing.
Session persistence
Funnel state persists. A visitor who reloads mid-funnel, or leaves and returns later, resumes with their responses, data, and user intact. This is why data is described as “persisted but out of analytics”: it survives the same way responses do; it just isn’t aggregated as an answer.
Deep-link restoration is where entry guards apply — a restored session landing on a guarded page (like a paywall without a captured email) is bounced to the start. See Pages & Navigation.
The imperative handle — useFunnel
useFunnel() returns a non-subscribing imperative handle. Use it in event handlers and effects, never for render reads.
import { useFunnel } from '@appfunnel-dev/sdk'
function SubmitButton() {
const funnel = useFunnel()
function handleClick() {
const answers = funnel.responses.get() // read once, in a handler
funnel.data.set({ submittedAt: Date.now() }) // imperative write
funnel.track('quiz_submitted', { count: Object.keys(answers).length })
}
return <button onClick={handleClick}>Submit</button>
}The handle exposes { user, responses, data, context, experiments, track, snapshot() }, where the namespace objects have .get() and .set({...}).
useFunnel is not reactive. Reading funnel.responses.get() at render time won’t re-render your component when the value changes — you’ll read a stale snapshot. Use the field hooks (useResponse, useUserAttribute, useData) for anything you render, and reserve useFunnel for imperative reads and writes inside handlers.
Localized copy — a teaser
If your funnel is localized, replace hardcoded strings with t('key'):
import { useTranslation } from '@appfunnel-dev/sdk'
const { t } = useTranslation()
return <h1>{t('welcome.title')}</h1>You don’t hand-write the catalogs — enabling localization in the editor extracts every string into t() calls and writes the source messages/<locale>.json. The lookup falls back through active → fallback → default, so a missing translation never renders empty. The full workflow is in Localization.