Skip to main content
Version: v1 (Current)

Experience Flow

The Experience Flow system is a state-machine-driven orchestrator for multi-stage interactive apps (kiosk flows, photo booths, check-ins, AI experiences). It replaces the legacy experience-builder plugin with a clean Vue 3 composable API and a set of customizable prebuilt page components.

What you get

ExportWhat it does
useExperience(config)The orchestrator. Owns navigation, context, busy/error state, action dispatch.
<ExperienceFlow :flow />Renderer component. Mounts the current page, transitions, loading/error slots.
useExperienceApi({ callApi })Optional adapter that turns callApi into typed named operations.
withExperienceDefaultsHelper to attach a default action / result key to a page component.
20 prebuilt pagesWelcome, Terms, Camera, Drawing, Form, … See Pages reference.

A minimal flow

import { callApi } from '@gxp-dev/devtools'
import {
useExperience,
useExperienceApi,
WelcomePage,
TermsPage,
CameraPage,
CameraReviewPage,
FinalPage,
} from '@gxp-dev/uikit'

const api = useExperienceApi({ callApi })

const flow = useExperience({
api,
pages: [
{ name: 'welcome', component: WelcomePage },
{ name: 'terms', component: TermsPage, props: { html: tos } },
{ name: 'capture', component: CameraPage }, // → ctx.photoBlob
{ name: 'review', component: CameraReviewPage }, // default action: publishPost → ctx.post
{ name: 'final', component: FinalPage, props: { qrCaption: 'Scan to save' } },
],
})
<ExperienceFlow :flow="flow" />

That's the whole wiring. Pages emit data; the flow stores results in ctx; the optional api adapter executes named operations between pages.

Concepts

Pages

A page is any Vue component that:

  • Receives its config via props (declared in the page definition).
  • Emits next(data?) to advance, back() to go back, and optionally exit().
  • May expose named slots for customization (each built-in page does).

There is no registry — built-in and user-defined pages plug in identically:

import MyCustomReview from '@/components/MyCustomReview.vue'

useExperience({
pages: [
{ name: 'capture', component: CameraPage },
{ name: 'review', component: MyCustomReview, action: 'publishPost' },
{ name: 'final', component: FinalPage },
],
})

Page definitions

interface ExperiencePageDef {
name: string // string key for goTo()
component: Component // any Vue component
props?: Record<string, unknown> // forwarded to the page
when?: (ctx: ExperienceContext) => boolean // skip the page when false
action?: ExperienceAction // see "Actions" below
resultKey?: string // where to stash the action result
}

The composable exposes:

flow.currentPage // ComputedRef<ExperiencePageDef | null>
flow.pageName // ComputedRef<string | null>
flow.index // Ref<number>
flow.isFirst, flow.isLast

flow.next(data?) // run action with `data`, stash result, advance to next visible page
flow.back() // go to previous visible page
flow.goTo(name) // jump to a named page (throws if hidden by `when`)
flow.reset() // back to first visible page, clear context (keep initialContext)

Context

flow.context is a reactive object pages can read from and write to. Action results are written under resultKey (defaults to the page's name). Pages that need a previously captured value (CameraReviewPage reading photoBlob, FinalPage reading post.file_url, etc.) read from context.

You can also seed it:

useExperience({
pages,
initialContext: { user: currentUser, eventId: 42 },
})

reset() restores initialContext exactly.

Skip conditions (when)

{ name: 'terms', component: TermsPage, when: (ctx) => !ctx.alreadyAcceptedTerms }

when() is evaluated whenever the flow moves, so next() and back() automatically loop past pages that aren't currently visible.

Actions

Every transition can run an async action between two pages. There are three forms:

// 1. Named action — looked up on the api adapter
{ name: 'review', component: CameraReviewPage, action: 'publishPost' }

// 2. Inline function — receives data, context, api
{ name: 'review', component: CameraReviewPage,
action: async (data, ctx, api) => {
const post = await api!.publishPost(data)
await api!.tagPost(post.id, ctx.taggedAttendees)
return post
} }

// 3. Disabled — the page's emitted data is stored directly
{ name: 'capture', component: CameraPage, action: false }

The action's return value is stashed in flow.context[resultKey]. While the action is in flight flow.busy is true; on error flow.error is set and the page stays mounted.

See API adapter for the named operations.

Default actions

Built-in pages declare defaults via withExperienceDefaults so common flows need zero wiring. For example, CameraReviewPage defaults to action: 'publishPost', resultKey: 'post'. Override per-page with action/resultKey in the page def, or attach defaults to your own pages:

import { withExperienceDefaults } from '@gxp-dev/uikit'
import MyPage from './MyPage.vue'

export default withExperienceDefaults(MyPage, {
action: 'publishPost',
resultKey: 'post',
})

Loading / error UI

<ExperienceFlow> automatically renders an overlay while flow.busy is true, and a dismissible error card when flow.error is set. Both are slot-overridable:

<ExperienceFlow :flow="flow">
<template #loading>
<MyBrandedSpinner />
</template>

<template #error="{ error, retry }">
<ErrorCard :error="error" @dismiss="retry" />
</template>
</ExperienceFlow>

onError in the flow config can also swallow the error and keep the page mounted:

useExperience({
pages,
onError: (err, ctx) => {
logToSentry(err, { ctx })
return true // → don't surface in flow.error, just stay on the page
},
})

Lifecycle hooks

useExperience({
pages,
onComplete: (ctx) => router.push('/done'),
onError: (err, ctx) => true,
})

onComplete fires when next() is called on the last visible page.

Branching paths

There's no special "path" API — goTo(name) plus when() covers branching cleanly:

const flow = useExperience({
pages: [
{ name: 'pick', component: OptionsPage, props: { options: PATHS } },
{ name: 'video', component: VideoCapturePage, when: (c) => (c.choice as any)?.key === 'video' },
{ name: 'audio', component: AudioCapturePage, when: (c) => (c.choice as any)?.key === 'audio' },
{ name: 'text', component: TextPage, when: (c) => (c.choice as any)?.key === 'text' },
{ name: 'final', component: FinalPage },
],
})

OptionsPage stashes the chosen option in ctx.choice; the three capture pages only render for their matching path. After whichever capture page runs, the flow naturally advances to final.

Custom pages

A custom page is just a Vue component. The flow will call its props, listen for next / back / exit, and pass a context prop automatically:

<!-- MyPage.vue -->
<script setup lang="ts">
defineProps<{
title?: string
context?: Record<string, unknown>
}>()

const emit = defineEmits<{
next: [data: unknown]
back: []
exit: []
}>()
</script>

<template>
<section>
<h1>{{ title }}</h1>
<button @click="emit('next', { ok: true })">Continue</button>
</section>
</template>

Slot the page in like any built-in:

{ name: 'mine', component: MyPage, props: { title: 'Hello' } }

Try it

A complete, heavily annotated example flow is included in every project created by gxdev init as src/DemoExperience.vue — branching paths, every action shape, a live state inspector, custom loading/error slots, and a callApi bridge to the real platform. See Template Demos in the devtools docs.

See also

  • Pages reference — every built-in page's props, emits, slots, default action.
  • API adapteruseExperienceApi operations, overrides, endpoints.
  • Template Demos — the DemoExperience.vue template that ships with every new plugin.