@test3-dev/react
v0.1.0
Published
First-class JSX experiments for React, SSR, and React Native with Test3
Readme
@test3-dev/react
React bindings for Test3 experiments. React 18 and 19 are peer dependencies; the provider owns its own TanStack Query cache. The browser, native, and server entry points keep platform-specific code separate.
Install
npm install @test3-dev/react
# or
bun add @test3-dev/reactThe core SDK is installed automatically. React Native integrations also need
@react-native-async-storage/async-storage and @react-native-community/netinfo.
For Expo, use npx expo install expo-splash-screen @react-native-async-storage/async-storage @react-native-community/netinfo to select compatible versions.
Browser
import {
ExperimentProvider,
Experiment,
Variant,
useExperiments,
} from "@test3-dev/react"
import { browserBootstrap } from "@test3-dev/react/browser"
const manifest = {
exp_signup: {
variantIds: ["var_control", "var_challenger"],
templates: [],
},
}
const { identity, initialSnapshot } = await browserBootstrap()
function Signup() {
const { track } = useExperiments()
return (
<Experiment id="exp_signup">
<Variant id="var_control">
<OriginalHero
onSignup={async () => {
const signup = await createSignup()
await track("signup_completed", { eventId: signup.id })
}}
/>
</Variant>
<Variant id="var_challenger">
<NewHero
onSignup={async () => {
const signup = await createSignup()
await track("signup_completed", { eventId: signup.id })
}}
/>
</Variant>
</Experiment>
)
}
export function App() {
return (
<ExperimentProvider
apiUrl="http://localhost:3001"
publicKey="pk_…"
identity={identity}
manifest={manifest}
initialSnapshot={initialSnapshot}
>
<Signup />
</ExperimentProvider>
)
}Before the application bundle, place the API bootstrap in the document head:
<head>
<script
src="https://api.example.com/sdk/bootstrap.js"
data-api-url="https://api.example.com"
data-public-key="pk_…"
data-manifest='{"exp_signup":{"variantIds":["var_control","var_challenger"],"templates":[]}}'
></script>
</head>This must remain a classic script in head: do not add async, defer, or type="module". The browser pauses HTML parsing for the script download and its synchronous assignment request. The script stores the resulting snapshot before parsing resumes; browserBootstrap() retrieves that same identity and snapshot. If bootstrap fails, the application does not mount an assumed arm.
The experiment and variant IDs come from the dashboard, but the components and everything they display remain in application JSX. Keep identity and the manifest outside rendering. Both arms must be listed in the manifest. Ambiguous or missing bindings render nothing and emit a diagnostic; hidden branches never mount.
The conversion event must match the experiment goal. Supply a stable event ID from the completed business action, not a new ID on each delivery retry. Relevant exposure receipts are attached by the SDK; callers cannot choose the attributed variant. Exposure means a committed mount, not viewport visibility. Reading useExperiment(id) does not track exposure; call its trackExposure() for a non-component decision that was actually used.
useExperiments() exposes ready, error, stale, lastSuccessfulRefresh, queuedEvents, diagnostics, track, refresh, flush, and advanceJourney. The provider does not mount its application subtree without a definitive snapshot, so it can never commit one arm and replace it during initial evaluation. Active, online clients refresh every 30 seconds and on focus/foreground/reconnect. Manual force, finish, pause, and resume decisions apply on refresh. Automatic changes to mounted branches wait for advanceJourney() or a fresh component mount; call that method at a safe navigation/form boundary. A forced change can remount UI and reset component state.
Snapshots are scoped to the environment key, API origin, subject, and release manifest, usable for up to 24 hours during an outage. A bounded persistent queue retains stable event IDs and timestamps for up to seven days and retries individual unacknowledged events. Offline devices cannot receive an immediate remote override. Event and snapshot storage errors fall back to memory and appear in diagnostics.
SSR
Use evaluateExperiments and serializeSnapshot from @test3-dev/react/server. Read/create an opaque subject cookie per request and finish evaluation before rendering HTML. Pass the exact server subject and snapshot as initialSnapshot to the client provider, and safely embed the serialized data in an application/json script for hydration. If evaluation fails, return an application-level error response instead of rendering an assumed arm. Use Cache-Control: private, no-store for personalized HTML. No exposure is sent on the server; hydration commits it. Server Components can call the helper and pass results to a client provider boundary.
React Native
import AsyncStorage from "@react-native-async-storage/async-storage"
import NetInfo from "@react-native-community/netinfo"
import { AppState } from "react-native"
import * as SplashScreen from "expo-splash-screen"
import {
nativeBootstrap,
nativeIdentity,
nativeLifecycle,
} from "@test3-dev/react/native"
const identity = nativeIdentity({
storage: AsyncStorage,
lifecycle: nativeLifecycle({ AppState, NetInfo }),
})
await SplashScreen.preventAutoHideAsync()
const { initialSnapshot } = await nativeBootstrap({
apiUrl,
publicKey,
identity,
manifest,
})Pass initialSnapshot to ExperimentProvider, render it, and hide the native splash only after the root layout commits. Use native UI in Variant render props and pass active={isFocused} to each screen's Experiment. Foregrounding refreshes delivery, but automatic replacement waits for a safe journey boundary. Map approved theme tokens to native style properties; CSS variables do not style native views. Default anonymous identity remains device-specific. For cross-device identity, supply the same opaque subjectId to the identity adapter before mounting the provider. Do not send email addresses or merge anonymous histories automatically.
See the monorepo examples for full browser, SSR, and Expo integrations. Installation packages contain their public wire types and have no private workspace runtime dependency.
