Skip to Content

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.

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> ) }
MemberBehavior
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.
pageThe live PageContext.
nextCandidatesThe page keys reachable next — Appfunnel preloads these.
canGoBacktrue when flow history depth is greater than 1.
isNavigatingtrue 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 | undefined

Returns 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

HookSignatureBehavior
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.

HookReturnsNotes
usePage()PageContextReactive to navigation (documented above).
useDevice()DeviceContextViewport and device type. Non-reactive.
useLocale()LocaleContextResolved locale, language, region, timezone.
useUtm()UtmContextThe five UTM params captured on entry, plus extras.
useClickIds()ClickIdContextPer-network ad click-ids captured on entry.
useSystem()SystemContextfunnelId, campaignId, mode, and a stable build clock.
useContextValue(path)unknownRead 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:

SliceShape
DeviceContexttype: 'mobile' | 'tablet' | 'desktop', isMobile, isTablet, screenWidth/Height, viewportWidth/Height, colorDepth, pixelRatio
LocaleContextlocale (en-US), language (en), region (US), timeZone (IANA)
UtmContextsource, medium, campaign, content, term, plus arbitrary [key]
ClickIdContextfbclid, gclid, gbraid, wbraid, msclkid, ttclid, twclid, sccid, li_fat_id, epik, rdt_cid, plus [key]
SystemContextfunnelId, campaignId, mode: 'test' | 'live', now (epoch ms, build-time, stable)
SessionContextid: string | null, startedAt, referrer
IdentityContextcustomerId: 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 order

useOffering 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

FieldTypeNotes
idstringThe slot / display identity.
catalogKeystringThe offering key charged — the charge identity.
namestring
displayNamestring
providerstringSettlement provider (opaque string).
priceMoneyThe list price. price.formatted$9.99 / €9,99 / kr 79,00.
periodstringmonth, quarter, 6 months, 2 weeks, 3 months, one-time, …
periodlystringmonthly, quarterly, semiannually, biweekly, one-time, …
perDayMoneyPeriod-normalized — for “just $0.27/day” copy.
perWeekMoney
perMonthMoney
perYearMoney
hasTrialbooleantrue if trialDays > 0 or a trial price exists.
trialDaysnumber
trialPriceMoney | nullnull 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 (perDayperYear) 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}} from params.
  • t.plural(n, forms, params?) — locale-correct plural via Intl.PluralRules. # in a form is replaced with n. Forms are keyed by PluralCategory ('zero' | 'one' | 'two' | 'few' | 'many' | 'other').
  • fmtnumber(n, opts?), date(d, opts?), percent(n, opts?), all Intl-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(): string

Returns 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 map

Upload 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.

Last updated on