Back to Blog
Implementation Guide

A/B Testing in React: Complete Guide

Updated December 2025
15 min read
TL;DR

The short answer: Use React Context to provide experiment variants app-wide, and a custom hook (useExperiment) to access them. For Next.js, assign variants in middleware to avoid flicker. For client-only React, initialize experiments before ReactDOM.render().

Who this is for

  • React developers implementing A/B testing
  • Teams building experimentation into React apps
  • Anyone migrating from a visual editor to code-based testing

Who this is NOT for

Approaches Compared

ApproachDifficultyFlickerBest For
Context + HookEasyPossibleSimple React apps
Server-Side (Next.js)MediumNoneNext.js apps
Feature Flag ServiceEasyNoneTeams using feature flags

React Context + Hook Implementation

This is the simplest approach for React apps. Create a context provider and custom hook.

1. Create the Experiment Context

// contexts/ExperimentContext.tsx
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';

type Variant = string;
type Experiments = Record<string, Variant>;

interface ExperimentContextType {
  experiments: Experiments;
  getVariant: (experimentId: string) => Variant;
  isLoading: boolean;
}

const ExperimentContext = createContext<ExperimentContextType | undefined>(undefined);

// Simple hash function for consistent assignment
function hashString(str: string): number {
  let hash = 0;
  for (let i = 0; i < str.length; i++) {
    const char = str.charCodeAt(i);
    hash = ((hash << 5) - hash) + char;
    hash = hash & hash;
  }
  return Math.abs(hash);
}

function assignVariant(userId: string, experimentId: string, variants: string[]): string {
  const hash = hashString(`${userId}-${experimentId}`);
  const index = hash % variants.length;
  return variants[index];
}

interface ExperimentProviderProps {
  children: ReactNode;
  userId: string;
  experiments: Record<string, string[]>; // experimentId -> variants
}

export function ExperimentProvider({ 
  children, 
  userId, 
  experiments: experimentConfig 
}: ExperimentProviderProps) {
  const [experiments, setExperiments] = useState<Experiments>({});
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    // Assign variants for all experiments
    const assigned: Experiments = {};
    
    for (const [experimentId, variants] of Object.entries(experimentConfig)) {
      // Check for existing assignment in localStorage
      const storageKey = `exp_${experimentId}`;
      let variant = localStorage.getItem(storageKey);
      
      if (!variant) {
        variant = assignVariant(userId, experimentId, variants);
        localStorage.setItem(storageKey, variant);
      }
      
      assigned[experimentId] = variant;
    }
    
    setExperiments(assigned);
    setIsLoading(false);
  }, [userId, experimentConfig]);

  const getVariant = (experimentId: string): Variant => {
    return experiments[experimentId] || 'control';
  };

  return (
    <ExperimentContext.Provider value=>
      {children}
    </ExperimentContext.Provider>
  );
}

export function useExperiment(experimentId: string): Variant {
  const context = useContext(ExperimentContext);
  if (!context) {
    throw new Error('useExperiment must be used within ExperimentProvider');
  }
  return context.getVariant(experimentId);
}

export function useExperiments() {
  const context = useContext(ExperimentContext);
  if (!context) {
    throw new Error('useExperiments must be used within ExperimentProvider');
  }
  return context;
}

2. Wrap Your App

// App.tsx
import { ExperimentProvider } from './contexts/ExperimentContext';

const EXPERIMENTS = {
  'hero-headline': ['control', 'variant-a', 'variant-b'],
  'pricing-layout': ['control', 'horizontal'],
};

function App() {
  // Get or create user ID
  const userId = localStorage.getItem('userId') || crypto.randomUUID();
  
  return (
    <ExperimentProvider userId={userId} experiments={EXPERIMENTS}>
      <YourApp />
    </ExperimentProvider>
  );
}

3. Use in Components

// components/Hero.tsx
import { useExperiment } from '../contexts/ExperimentContext';

const headlines = {
  'control': 'Build Better Products',
  'variant-a': 'Increase Conversions by 30%',
  'variant-b': 'Stop Guessing. Start Testing.',
};

export function Hero() {
  const variant = useExperiment('hero-headline');
  const headline = headlines[variant as keyof typeof headlines];
  
  return (
    <section>
      <h1>{headline}</h1>
      {/* Track impression */}
      <TrackImpression experiment="hero-headline" variant={variant} />
    </section>
  );
}

4. Track Conversions

// utils/tracking.ts
export function trackConversion(
  experimentId: string, 
  variant: string, 
  event: string
) {
  // Send to your analytics
  fetch('/api/track', {
    method: 'POST',
    body: JSON.stringify({
      experiment: experimentId,
      variant,
      event,
      timestamp: Date.now(),
    }),
  });
  
  // Or use ExperimentHQ
  window.experimenthq?.track(event, {
    experiment: experimentId,
    variant,
  });
}

// Usage in component
function CTAButton() {
  const variant = useExperiment('hero-headline');
  
  const handleClick = () => {
    trackConversion('hero-headline', variant, 'cta_click');
    // ... rest of click handler
  };
  
  return <button onClick={handleClick}>Get Started</button>;
}

Avoiding Flicker in React

The context approach above can cause flicker because variants are assigned after the component mounts. To prevent this:

Option 1: Initialize Before Render

Assign variants in a script that runs before React, then read from localStorage in your context.

Option 2: Server-Side Assignment

For Next.js, use middleware to assign variants. See our Next.js guide.

Option 3: Loading State

Show a loading skeleton until variants are assigned. Not ideal but simple.

Common Mistakes

❌ Assigning variants on every render

Users must see the same variant consistently. Always persist to localStorage or cookies.

❌ Using Math.random() for assignment

Math.random() gives different results on each call. Use a hash function with user ID for deterministic assignment.

❌ Not handling SSR/hydration

If using SSR, ensure server and client assign the same variant to avoid hydration mismatches.

Easier Option: Use ExperimentHQ

Building your own experimentation system is complex. ExperimentHQ handles:

  • Consistent variant assignment across sessions
  • Statistical significance calculations
  • Visual editor for non-developers
  • Anti-flicker technology

FAQ

How do I A/B test in React?
Use a React context to provide experiment variants throughout your app. Assign variants on the server or at app initialization, then use a hook like useExperiment() to access variants in components.
Should I use client-side or server-side testing?
For React SPAs, server-side or edge-based testing is recommended to avoid flicker. If using Next.js, use middleware. For client-only React, initialize experiments before rendering.
How do I track conversions in React?
Call a tracking function when conversion events occur (button clicks, form submissions, etc.). Include the experiment ID and variant in the tracking data.

Share this article

Ready to start A/B testing?

Free forever plan available. No credit card required.