Experiments in the SDK
The browser API is synchronous and never throws: whenever the visitor is not enrolled, the key is
unknown or the assignment is not known yet, it answers "control". Projects with experiments load
the small plugin x3.experiments.js automatically (bundled in x3.full.js); sites without
experiments never download it.
Browser API
const arm = x3.variant("hero-cta-text"); // "control" | "b" | …
const info = x3.experiment("hero-cta-text"); // { arm, reason, inExperiment, params }
x3.variant("quiz-contact-split", { expose: false }); // decide now, expose later
x3.expose("quiz-contact-split"); // where the arms really differ
await x3.experiments.ready(300); // assignment known (or timeout)
const off = x3.experiments.onChange((key) => rerender(key));
x3.experiments.all(); // debug: key → armvariant(key, { expose, fallback })logs the exposure (experiment_view) on the first call per pageview unlessexpose: false. Recommended:expose: falseandexpose(key)at the point of difference (the contact step rendered, the modal opened) – more sensitive, no dilution.fallbackis returned for keys the config does not know (e.g. before the experiment exists).reasontells why:assigned,override(QA),rolled_out,not_running,unknown_key,targeting,coverage,no_consent,unit_unavailable,pending,stopped_arm.- Day and visitor units are resolved by
GET /v1/x/{pk}(through your first-party proxy); the browser never sees the unit. Before that answervariant()returns control without exposure andonChangefires when it arrives. - Arm
paramscome from the experiment definition (public): read texts or video ids fromx3.experiment(key).paramsto change copy without a deploy.
Script tag (window.x3)
x3("variant", "hero-cta-text", function (arm, info) {
document.querySelector("#cta").textContent = arm === "b" ? "Jetzt starten" : "Kostenlos testen";
x3("expose", "hero-cta-text");
}, { expose: false });The callback runs once the assignment is known – at most 300 ms after boot, otherwise with control.
After boot window.x3.variant() is also available synchronously.
React
import { useVariant, useExpose, Variant } from "@x3growth/sdk/react";
function Cta() {
const { arm, params, ready } = useVariant("hero-cta-text");
return <button style={{ visibility: ready ? "visible" : "hidden" }}>{arm === "b" ? String(params.label) : "Jetzt anfragen"}</button>;
}
function ContactStep({ visible }: { visible: boolean }) {
useExpose("quiz-contact-split", visible);
return (
<>
<Variant experiment="quiz-contact-split" arm="control" expose={false}><OneStep /></Variant>
<Variant experiment="quiz-contact-split" arm="split" expose={false}><TwoSteps /></Variant>
</>
);
}useVariant re-renders when the assignment arrives and logs the exposure after commit (unless
expose: false). With server assignments from <X3Experiments> the first
client render equals the server render.
Anti-flicker rules
- Anything visible on first paint (hero, headline, video): assign on the server (Next.js).
- Client-only: hide only the experiment slot (keep its size) until
experiments.ready(300); after the timeout render control without exposure. - Never hide the whole page and never rewrite the DOM by selector.
- Steps reached after interaction (quiz steps, modals) need nothing special – the assignment is requested at boot.
Code conventions
- One
variant()/useVariant()per experiment and component tree; branch locally. - Mark every arm:
// x3-experiment:<key> arm=<arm>– the cleanup searches for it. - Arm-only funnel steps send
x3.funnelStep(funnel, step). - Server code must accept both arms (validation, mails, CRM). Prices and offers are assigned on the server (server helper), never trusted from the browser.
QA
?x3_force=<key>:<arm> (comma-separated for several) forces arms on your own browser and marks the
visit as test traffic. Drafts and server-side assignment accept only the signed links from the
experiment's QA links (x3_force_t, 7 days). Never commit QA links or put them into PR texts.