Hooks
Every page reaches the funnel through hooks. There is no sdk prop and no god object — you import exactly the hook you need from @appfunnel-dev/sdk, and each one subscribes to just its slice of state. All hooks must run inside a page or the layout.tsx.
The hooks group into five areas: navigation, state, commerce, i18n, and assets.
This page explains the hooks you reach for day to day. For the complete list — every export with its exact signature, straight from the SDK source — see the Full API index.
Navigation
useNavigation
function useNavigation(): NavigationState
interface NavigationState {
page: PageContext // current page's read-only context
next: () => void // advance via the current page's routing, else linear next
back: () => void // go to the previous page in flow history
go: (key: string) => void // jump to a specific page key
prefetch: (key: string) => void // warm a code-split chunk ahead of navigation
nextCandidates: string[] // page keys reachable next
canGoBack: boolean // flow history depth > 1
isNavigating: boolean // true while a cold lazy chunk resolves
}The primary navigation handle. Throws if used outside the funnel view (which never happens in a real page — the build mounts it there).
import { useNavigation } from '@appfunnel-dev/sdk'
function Continue() {
const nav = useNavigation()
return (
<button onClick={nav.next} onMouseEnter={() => nav.prefetch(nav.nextCandidates[0])}>
{nav.isNavigating ? 'Loading…' : 'Continue'}
</button>
)
}| Member | Behavior |
|---|---|
next() | Resolves the current page’s next routes against the live snapshot; with no match, advances to the linear next page in file order. Wrapped in a React transition so the current page stays on screen — no Suspense flash. |
back() | Pops the in-memory funnel history (not browser history). |
go(key) | Pushes a specific page key onto history. Remaps to the experiment slot if the target is an experiment page. |
prefetch(key) | Warms the code-split chunk for key (idempotent; a no-op for eager pages). Call it on hover to hide the chunk load. |
page | The live PageContext. |
nextCandidates | The page keys reachable next — Appfunnel preloads these. |
canGoBack | true when flow history depth is greater than 1. |
isNavigating | true while a cold lazy chunk is resolving. Show a working affordance while it’s true. |
back() walks the funnel’s own history, not the browser’s. It does not touch the URL bar or the browser back button.
usePage
function usePage(): PageContext
interface PageContext {
key: string
slug: string
index: number
total: number
progressPercentage: number
startedAt: number
}Sugar for useNavigation().page — the reactive context of the current page. Safe to call in layout.tsx for a progress bar, because the layout is mounted once outside the page swap and re-reads this on every navigation.
import { usePage } from '@appfunnel-dev/sdk'
function ProgressBar() {
const page = usePage()
return <div style={{ width: `${page.progressPercentage}%` }} />
}total is the expected path length for this visitor’s current answers — traced through the routing graph — not the raw page count. So progressPercentage reaches 100% on whichever branch the funnel actually takes for that visitor. It’s clamped to 100.
useGroupProgress
function useGroupProgress(): GroupProgress
interface GroupProgress {
groups: PageGroup[] // ordered segments, by first appearance along the routed path
activeIndex: number // index of the current group; -1 when the page is ungrouped
activeFraction: number // the active group's within-group fill, 0–1
}
interface PageGroup {
key: string // the slug from the page's `group` meta — NOT a display string
pageCount: number
completedCount: number
active: boolean
}Grouped progress for a segmented progress bar (“My profile · Goals · Plan”), built from each page’s group. It is visitor-routed and branch-aware: it counts only the pages this visitor will actually reach given their answers, so the bar doesn’t promise steps they’ll skip. A funnel with no group keys returns { groups: [], activeIndex: -1, activeFraction: 0 }.
key is a stable identifier, not copy — resolve it to a label in your own code so grouping survives copy and locale changes:
import { useGroupProgress, useTranslation } from '@appfunnel-dev/sdk'
function Segments() {
const { groups, activeIndex, activeFraction } = useGroupProgress()
const { t } = useTranslation()
return (
<ol className="flex gap-1">
{groups.map((g, i) => (
<li key={g.key} className="flex-1">
<span>{t(`groups.${g.key}`)}</span>
<progress value={i < activeIndex ? 1 : i === activeIndex ? activeFraction : 0} />
</li>
))}
</ol>
)
}useExperiment
function useExperiment(id: string): string | undefinedReturns the variant key this visitor is bucketed into for the given experiment, or undefined if they aren’t enrolled.
const variant = useExperiment('welcome-headline')
return <h1>{variant === 'b' ? 'New copy' : 'Original copy'}</h1>Whole-page variant swapping is automatic (a pages/welcome@b.tsx sibling is swapped in for you — see @variant files). Use useExperiment only when you need to branch within a page or log something custom. Experiment weights and status live in the dashboard, not in code. Platform guide: Experiments.
State
The writable state is three namespaces — user, responses, data — each declared in funnel.ts and exposed through a useState-shaped hook. context is a fourth, read-only, computed namespace. Entitlements are not here; that’s the separate app-side IdentitySDK.
Writable field hooks
| Hook | Signature | Behavior |
|---|---|---|
useResponse(name) | [value, setValue] | One funnel answer, backed by funnel.ts responses. The primary way to capture and read visitor input across pages. Writing persists and feeds analytics aggregation. |
useUserAttribute(name) | [value, setValue] | Any field on user. The platform routes known fields to typed columns and custom fields to attributes automatically. Writing email also identifies the visitor (fires the user.registered event). |
useData(name) | [value, setValue] | Working / scratch state (data.*). Persisted across reload and return, but kept out of the responses analytics aggregation. |
useField(path) | { value, set } | Headless building block — takes a full namespaced dotted path. The seam <Choice> wraps. Advanced. |
import { useResponse, useUserAttribute } from '@appfunnel-dev/sdk'
function EmailStep() {
const [email, setEmail] = useUserAttribute('email') // writing this identifies the visitor
const [goal, setGoal] = useResponse('goal')
return <input value={email ?? ''} onChange={(e) => setEmail(e.target.value)} />
}useResponse, useUserAttribute, and useData each take a bare key — the namespace is implied. useField takes the full <ns>.<key> path ('responses.goal') and returns { value, set } instead of a tuple.
useField(path) throws if path isn’t a writable namespace — the path must start with user., responses., or data.. It’s the headless seam <Choice> is built on; reach for it only when you’re building your own bound input.
Read-only context hooks
context is computed by the platform on entry — device, locale, acquisition, session, identity. It’s read-only. Each slice has its own hook, plus a generic path reader.
| Hook | Returns | Notes |
|---|---|---|
usePage() | PageContext | Reactive to navigation (documented above). |
useDevice() | DeviceContext | Viewport and device type. Non-reactive. |
useLocale() | LocaleContext | Resolved locale, language, region, timezone. |
useUtm() | UtmContext | The five UTM params captured on entry, plus extras. |
useClickIds() | ClickIdContext | Per-network ad click-ids captured on entry. |
useSystem() | SystemContext | funnelId, campaignId, mode, and a stable build clock. |
useContextValue(path) | unknown | Read any dotted path out of context, e.g. useContextValue('device.isMobile'). Advanced. |
useMeasurementSystem() | 'metric' | 'imperial' | The units the visitor likely expects, for seeding a height/weight toggle. SSR-safe: 'metric' on first paint, resolved post-mount — so treat it as a default, not a value to render before hydration. |
import { useDevice, useUtm } from '@appfunnel-dev/sdk'
const { isMobile } = useDevice()
const { source, campaign } = useUtm()FunnelContext — the whole computed context, and exactly what a routing predicate reads as s.context.*:
{ device, browser, os, locale, page, utm, clickIds, session, identity, system }The sub-shapes you’ll read most:
| Slice | Shape |
|---|---|
DeviceContext | type: 'mobile' | 'tablet' | 'desktop', isMobile, isTablet, screenWidth/Height, viewportWidth/Height, colorDepth, pixelRatio |
LocaleContext | locale (en-US), language (en), region (US), timeZone (IANA) |
UtmContext | source, medium, campaign, content, term, plus arbitrary [key] |
ClickIdContext | fbclid, gclid, gbraid, wbraid, msclkid, ttclid, twclid, sccid, li_fat_id, epik, rdt_cid, plus [key] |
SystemContext | funnelId, campaignId, mode: 'test' | 'live', now (epoch ms, build-time, stable) |
SessionContext | id: string | null, startedAt, referrer |
IdentityContext | customerId: string | null, visitorId: string | null (the experiment bucketing seed) |
useFunnel
function useFunnel(): Funnel
// { user, responses, data, context, experiments, track, snapshot() }A non-subscribing imperative handle for event handlers and effects — not for reads during render. The user / responses / data members are Namespace objects with .get() and .set({ … }).
const funnel = useFunnel()
function onSubmit() {
funnel.responses.set({ goal: 'gain' })
funnel.track('quiz_submitted', { answers: 3 })
}useFunnel is not reactive. Reading state through it for render (useFunnel().responses.get('goal')) will not re-render when the value changes — use the field hooks (useResponse, useData, …) for anything you render. Reach for useFunnel only inside handlers and effects, where you need the current value once without subscribing.
Commerce
useOffering, useOfferings
function useOffering(id: string | undefined): Offering | undefined // one offering, formatted for the active locale
function useOfferings(): Offering[] // all offerings, in slot orderuseOffering takes the slot id (from funnel.ts offerings) and returns a display-ready Offering, or undefined if the slot is unassigned or not found. It re-formats automatically when the active locale changes.
import { useOffering } from '@appfunnel-dev/sdk'
function Price() {
const offering = useOffering('plan')
if (!offering) return <p>Select a plan</p> // slot unassigned → undefined
return (
<p>
{offering.price.formatted}
{offering.period !== 'one-time' ? ` / ${offering.period}` : ''}
</p>
)
}The argument is a slot key, not an offering key. useOffering('plan') looks up the slot plan; the charge uses whatever catalogKey that slot is assigned to. A null slot (offerings: { plan: null }) returns undefined — you must assign the slot in the editor’s Offerings view first, or the paywall renders nothing. Always guard on the undefined return before reading price.formatted.
Offering shape
| Field | Type | Notes |
|---|---|---|
id | string | The slot / display identity. |
catalogKey | string | The offering key charged — the charge identity. |
name | string | |
displayName | string | |
provider | string | Settlement provider (opaque string). |
price | Money | The list price. price.formatted → $9.99 / €9,99 / kr 79,00. |
period | string | month, quarter, 6 months, 2 weeks, 3 months, one-time, … |
periodly | string | monthly, quarterly, semiannually, biweekly, one-time, … |
perDay | Money | Period-normalized — for “just $0.27/day” copy. |
perWeek | Money | |
perMonth | Money | |
perYear | Money | |
hasTrial | boolean | true if trialDays > 0 or a trial price exists. |
trialDays | number | |
trialPrice | Money | null | null when there’s no trial. |
Money shape
interface Money {
amount: number // major units, e.g. 79
minor: number // minor units, e.g. 7900
currency: string
formatted: string // locale-formatted, e.g. "kr 79,00"
}For a one-time offering, period is the literal string 'one-time' — so a naïve {'/' + offering.period} renders /one-time. Guard on offering.period !== 'one-time' before appending the period suffix (as in the example above).
The period-normalized prices (perDay…perYear) are derived from an exact daily rate — the day is canonical (year = 365 days, month = 365/12, week = 7) and each period is rounded independently, so there’s no compounding drift between them.
useCheckout
function useCheckout(options?: UseCheckoutOptions): CheckoutHandle
interface CheckoutHandle {
open: (offering: string, surface?: CheckoutSurface) => Promise<CheckoutResult>
status: CheckoutStatus // 'idle' | 'loading' | 'requires_action' | 'success' | 'error'
error: CheckoutError | null
isLoading: boolean
reset: () => void
}The headless hook underneath <Checkout>, <CheckoutButton> and <UpsellButton>. Reach for asChild on the buttons first; use this when you need more than a trigger. open(offering) starts the charge for an offering slot and resolves with the result — open('plan', 'sheet') opens the funnel’s sheet, and open('plan') with no second argument charges off-session, which is what an upsell does.
import { useCheckout } from '@appfunnel-dev/sdk'
function BuyButton() {
const checkout = useCheckout({ onSuccess: () => console.log('charged') })
return (
<button disabled={checkout.isLoading} onClick={() => checkout.open('plan', 'sheet')}>
{checkout.error ? 'Try again' : 'Subscribe'}
</button>
)
}UseCheckoutOptions (intent, kind, managed, advanceOnSuccess, onSuccess, onError, onFailed) are documented in full under Commerce — the components take the same options. Guide: Offerings & Checkout.
i18n
useTranslation
function useTranslation(): Translation
interface Translation {
t: TFunction
fmt: Formatters
locale: string
setLocale: (locale: string) => void
dir: 'ltr' | 'rtl'
locales: string[]
}The main text-localization hook. Looks a key up through the fallback chain (active locale → fallback → default) and interpolates {{var}} placeholders. Missing keys fall through to the key itself (dev-warned). Throws if used with no provider (never happens in a real page).
import { useTranslation } from '@appfunnel-dev/sdk'
function Welcome() {
const { t, fmt } = useTranslation()
return (
<>
<h1>{t('welcome.title')}</h1>
<p>{t('greeting', { name: 'Alex' })}</p> {/* interpolates {{name}} */}
<p>{t.plural(3, { one: '# day left', other: '# days left' })}</p>
<p>{fmt.number(1234.5)} · {fmt.percent(0.2)}</p>
</>
)
}t(key, params?)— returns the translated string; interpolates{{var}}fromparams.t.plural(n, forms, params?)— locale-correct plural viaIntl.PluralRules.#in a form is replaced withn. Forms are keyed byPluralCategory('zero' | 'one' | 'two' | 'few' | 'many' | 'other').fmt—number(n, opts?),date(d, opts?),percent(n, opts?), allIntl-backed; they degrade to plain rendering on invalid input.setLocale(locale)— switch the active locale at runtime (a language picker, or preview).dir—'ltr'or'rtl'for the active locale.
There’s no ICU and no rich markup in catalog strings — keep markup in your TSX and translate the text around it. Guide: Localization.
useActiveLocale
function useActiveLocale(): stringReturns just the resolved locale string (or 'en' with no provider). Lighter than useTranslation — use it when all you need is the current locale, e.g. to key a formatter.
Assets
useAsset, useAssets
function useAsset(name: string): string | undefined // one asset's CDN URL
function useAssets(): Record<string, string> // the whole name → URL mapUpload an asset in the editor, then reference it by its logical name. useAsset returns the asset’s served URL, or undefined if nothing was uploaded under that name.
import { useAsset } from '@appfunnel-dev/sdk'
function Logo() {
const logo = useAsset('logo.svg')
return logo ? <img src={logo} alt="" /> : null
}Guide: Styling & Assets.