Skip to Content
BuildState

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:

NamespaceWritable?What it holds
useryesAttributes about the person: email, country, and custom fields.
responsesyesCaptured visitor input — the answers your funnel collects.
datayesWorking/scratch state, persisted but kept out of response analytics.
contextno (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 // ... }
HookReads/writesNotes
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 pathHeadless 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.tsstring, 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.registered

Writing 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 on user.email existing.

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 }
HookReturns
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.

Last updated on