X Enterprises
Composables

useXafExperiment

Lightweight A/B testing hook with sticky variant assignment, weighted random pick, deterministic bucketing by userId, TTL-based re-randomization, and exposure-event tracking.

useXafExperiment

Lightweight A/B testing composable. Assigns the current visitor to a variant of a named experiment, persists the assignment in localStorage (so repeat visits see the same variant), and exposes a track() helper that dispatches a xaf:experiment:exposed window event for analytics.

The assignment is sticky for the experiment's TTL. After that, a new random assignment is made — useful for experiments that should be re-randomized after a few weeks.

Variant weights are normalised at runtime, so they don't have to sum to 1, but they must be non-negative. A weight of 0 means "never assign" (the variant is in the list but won't be picked).

Usage

const { variant, isReady, track, reset } = useXafExperiment({
  id: 'homepage_hero_v2',
  variants: [
    { id: 'control', weight: 1 },
    { id: 'treatment', weight: 1 },
  ],
})

// In template:
<div v-if="variant === 'treatment'">New hero design</div>

// Track an exposure:
onMounted(() => track('view'))

Options

OptionTypeDefaultDescription
idstringrequiredExperiment id — also used as the localStorage key (xaf:exp:{id}).
variantsXafExperimentVariant[]requiredVariants to randomly assign between. Each has id and optional weight (default 1).
ttlDaysnumber30TTL in days before re-randomizing the visitor's assignment.
userIdstringWhen provided, deterministic bucketing via djb2 hash so the same visitor always sees the same variant across sessions/devices.
forcestringForce a specific variant (bypasses random assignment). Useful for QA tools and preview environments.

Return value

FieldTypeDescription
variantRef<string | null>The variant id assigned to this visitor. null until isReady.
isReadyRef<boolean>true once the assignment has been computed.
track(eventName?: string) => voidEmit an exposure event. Safe to call before isReady (becomes a no-op).
reset() => voidManually re-assign the variant (drops the persisted assignment and re-picks).

Server-side rendering

On the server, useXafExperiment() does not read localStorage. It falls back to:

  • force if set, otherwise
  • a deterministic pick via userId (if provided), otherwise
  • the first variant.

This avoids hydration mismatches: the client picks deterministically when userId is set, or randomly otherwise. Don't depend on a specific variant being assigned on the server — guard important UI with isReady.

Deterministic bucketing

When userId is provided, the same user always lands on the same variant (modulo TTL). This is useful for:

  • Cross-device experiments (when userId is the logged-in user's id)
  • Server-rendered previews (the server picks the same variant as the client will)

The hash function is djb2, applied mod the sum of weights.

Exposure events

track() dispatches a CustomEvent on window:

window.addEventListener('xaf:experiment:exposed', (e) => {
  const { id, variant, event, ts } = (e as CustomEvent).detail
  // Push to your analytics provider (GA4, Plausible, etc.)
})

Default eventName is 'exposed'. Pass a custom name ('click', 'conversion') for funnel analysis.

Tips

  • Always call track() in onMounted (not setup) — localStorage is only available on the client.
  • Pass force in dev/preview environments so designers see consistent results:
    const { variant } = useXafExperiment({
      id: 'homepage_hero_v2',
      variants: [...],
      force: import.meta.dev ? 'treatment' : undefined,
    })
    
  • Use useState or a Pinia store to share variant across components — useXafExperiment() is independent per call, so wrapping it in a shared composable avoids divergent assignments.

AI Context

composable: useXafExperiment
package: "@xenterprises/nuxt-x-affiliate"
use-when: A/B testing, feature flagging, or experiment assignment with sticky persistence
Copyright © 2026