Use Optimizely Feature Experimentation Feature Flags in React
David SertillangeIndependent experimentation specialistTL;DR
- →Create the SDK client once at module scope and wrap the tree in OptimizelyProvider with a stable user id
- →Gate rendering on clientReady so visitors never see the control resolve into the variation
- →Avoid the two React traps: hydration mismatch on server-rendered markup and impressions lost inside suspense
A feature flag in a React application is not a branch in a function. It is a value that a component renders against, and that means the interesting questions are React questions: when is the flag first available, what does the tree render before it arrives, and what happens when the same component renders twice — once on the server and once in the browser — with two different answers.
This guide covers installing the Optimizely Feature Experimentation React SDK, reading a flag with useDecision, sending conversion events from a component, and the two rendering problems — hydration and suspense — that turn a working flag into a flickering one. If the flag is carrying an experiment rather than a rollout, size it on the sample size calculator before you ship it, and read how long to run an A/B test if the answer looks longer than the release you were planning.
How the React SDK Decides a Flag
The React SDK wraps the JavaScript SDK. It fetches a datafile — a JSON document describing every flag, rule and audience in the environment — and then evaluates decisions locally, in the browser, with no network call per decision. OptimizelyProvider holds the client and the current user; useDecision subscribes a component to one flag for that user.
flowchart TD
A[App mounts] --> B[OptimizelyProvider created with SDK key and user]
B --> C[Client fetches the datafile]
C --> D{Datafile ready?}
D -->|no| E[useDecision returns the off state]
D -->|yes| F[Local evaluation against rules and audiences]
F --> G[Component renders the decided variation]
G --> H[Impression event queued]
H --> I[Batched dispatch to Optimizely]The important consequence is in the no branch. A decision is never pending in the React sense — it always returns something. Before the datafile arrives, that something is the off state. A component that renders the off state and then re-renders into the on state has just produced a flicker, and it did so without any error to tell you.
Where the Datafile Comes From
Three options, in increasing order of how much control you want:
Fetch by SDK key (
sdkKeyon the client). Simplest, and adds a network round trip before the first decision is real.Ship a datafile (
datafileon the client). Zero latency to the first decision, and the flags are as fresh as your last deploy.Both. Pass a datafile for the synchronous first render and an SDK key so the client updates itself afterwards. This is what most production apps should do.
Prerequisites
An Optimizely Feature Experimentation project, with the SDK key for the environment you are integrating.
A flag created in the Optimizely UI, with its key noted.
useDecision('checkout_redesign')against a key that does not exist returns the off state silently — the same shape as a real "not enabled" answer.A stable user identifier. Anything durable per visitor: your own user id when signed in, a first-party cookie id when not. A new random id per page load makes every visitor a new visitor and every experiment meaningless.
Conversion events created in Optimizely, with API names noted, for anything you intend to measure.
React 16.8 or later, because the SDK's surface is hooks.
Step 1: Install and Initialize the React SDK
Add @optimizely/react-sdk to the application with your package manager, then create the client once, at module scope, and pass it to a provider that wraps everything that reads a flag.
import {
createInstance,
OptimizelyProvider,
} from '@optimizely/react-sdk'
const optimizely = createInstance({
sdkKey: process.env.REACT_APP_OPTIMIZELY_SDK_KEY,
datafileOptions: { autoUpdate: true, updateInterval: 300000 },
})
export function App({ userId, plan }) {
return (
<OptimizelyProvider
optimizely={optimizely}
user={{ id: userId, attributes: { plan } }}
>
<Routes />
</OptimizelyProvider>
)
}
Two details in that snippet earn their place. createInstance is at module scope, not inside App — creating it in a component body makes a new client, and a new datafile fetch, on every render. And attributes is where audience targeting reads from: a rule that targets plan == "enterprise" sees nothing unless plan is passed here.
Step 2: Read a Flag with useDecision
import { useDecision } from '@optimizely/react-sdk'
export function CheckoutButton() {
const [decision, clientReady] = useDecision('checkout_redesign')
if (!clientReady) return <CheckoutButtonSkeleton />
return decision.enabled ? (
<NewCheckoutButton label={decision.variables.label} />
) : (
<LegacyCheckoutButton />
)
}
decision carries four things worth knowing: enabled, variationKey, variables (the typed configuration you defined on the flag) and ruleKey (which rule made the call — invaluable when a decision surprises you). clientReady is the second element of the tuple and is the whole flicker fix: gate on it, render a skeleton the same size as both variations, and the visitor never sees the off state resolve into the on state.
Reading the flag also records an impression. That is what makes the visitor a participant in the experiment, so read the flag where the visitor actually sees the difference — not in a layout component that renders on every route, which would enrol people who never reached the page under test.
Step 3: Send Conversion Events
The metric has to come from the same client, for the same user, or Optimizely cannot attribute it to the decision.
import { useOptimizely } from '@optimizely/react-sdk'
export function useCheckoutTracking() {
const optimizely = useOptimizely()
return (order) =>
optimizely.track('purchase_completed', {
revenue: Math.round(order.totalUsd * 100),
order_size: order.lineItems.length,
})
}
revenue is in cents, as an integer, and is the field Optimizely's revenue metrics read. Everything else in that object is an event property, and each one has to exist in Optimizely before it means anything — an undeclared property is dropped at ingestion without an error.
Step 4: Handle Hydration and Suspense
Two React-specific failure modes, both of which look like "the flag does not work" and neither of which is a flag problem.
Hydration mismatch. If the markup is server-rendered, the server has no datafile unless you gave it one, so it renders the off state; the browser then hydrates with the on state and React discards the server HTML for that subtree. Fix it by rendering the same thing in both places: pass a datafile snapshot into createInstance on the server and serialise it into the client bootstrap, so both sides decide identically.
Suspense boundaries. A component that reads a flag inside a suspended subtree does not record its impression until the boundary resolves. If the data it was waiting on never arrives, you have a visitor who saw a variation with no impression to prove it. Read the flag outside the boundary and pass the decision down as a prop.
The general rule behind both: decide once, as high in the tree as the difference is visible, and pass the answer down. Repeated useDecision calls for the same flag are cheap — the SDK deduplicates the impression — but they multiply the number of places that can disagree about readiness.
Verifying the Integration
Do not verify by looking at the page. Verify at the boundaries where things actually break:
Notification listeners. Attach a
DECISIONlistener during development and log every decision. It shows you theruleKey, thevariationKeyand the attributes the SDK actually saw — which is usually where the surprise is.The network tab. Filter for
logx.optimizely.com. Impressions and conversions are batched, so give it ten seconds or force a flush; an empty request list means events are being created but never dispatched, which is almost always a client that was garbage-collected on navigation.Two identities. Load the page with two different user ids and confirm you can reach both variations. A flag that always returns the same answer usually means the same id is being sent for everyone.
Common Failure Modes
A new user id every page load. Traffic allocation is a hash of the user id, so a fresh id per load re-randomises the visitor and destroys the experiment. Persist it.
Reading the flag in a component that renders everywhere. Enrols visitors who never saw the change and dilutes the effect toward zero.
No
clientReadygate. The off state renders first. On a slow connection that is a visible flash of the control for a visitor bucketed into the treatment.Attributes assembled after the provider mounts. A user object that starts empty and fills in later means the first decision was made without audience attributes. Delay the provider, or pass the attributes you already have.
Tracking through a second client instance. Two
createInstancecalls mean two event queues and, worse, two identities. There is one client per app.

Independent experimentation specialist
David Sertillange is an independent experimentation specialist with 10 years implementing Optimizely across enterprise programs. He specializes in Feature Experimentation, analytics integrations, and helping teams build a culture of data-driven decision making.
Related articles
Subscribe
Practical Optimizely tips, monthly. No fluff.