This guide covers breaking changes and how to update your code when upgrading from React SDK v3 to v4.
- Architecture changes
- Installation
- Underlying JS SDK changes (v5 to v6)
- Client creation
- Provider
- Hooks
- Accessing the client directly
- Removed APIs
- Event tracking
- Forced decisions
- Logger
- Server-side rendering
- React Server Components
- TypeScript changes
v4 is a ground-up rewrite with a fundamentally different architecture:
| Aspect | v3 | v4 |
|---|---|---|
| Underlying JS SDK | v5 (@optimizely/optimizely-sdk) |
v6 (@optimizely/optimizely-sdk) |
| Client model | Stateful ReactSDKClient wrapper (user bound to client) |
Thin wrapper over the JS SDK Client (user managed by Provider) |
| Readiness model | [value, clientReady, didTimeout] tuples |
{ decision, isLoading, error } discriminated unions |
| Datafile updates | autoUpdate option per hook |
Automatic via SDK polling; hooks re-evaluate on config changes |
| User overrides | Per-hook overrideUserId / overrideAttributes |
Removed; use separate <OptimizelyProvider> instances |
| Components | OptimizelyExperiment, OptimizelyFeature, OptimizelyVariation |
Removed; use hooks |
| HOC | withOptimizely |
Removed; use useOptimizelyClient hook |
npm install @optimizely/react-sdk@4| v3 | v4 | |
|---|---|---|
| Module format | ESM + CommonJS | ESM only (import / default — no require entry point) |
| Node.js | >=14.0.0 | >=18.0.0 |
| React peer dependency | >=16.8.0 | >=16.8.0 (unchanged) |
If your project uses CommonJS (require()), you will need to switch to ESM imports or configure your bundler to handle ESM dependencies.
React SDK v4 upgrades the underlying @optimizely/optimizely-sdk from v5 to v6. This brings several behavioral changes that affect React SDK usage. For full details, see the JavaScript SDK Migration Guide.
The monolithic createInstance config is now split into dedicated factory functions. Options like sdkKey, datafile, event batching, ODP, and logging are no longer top-level — each has its own factory. See Client creation for details.
Several features that were enabled by default in v5 are now opt-in in v6:
| Component | v5 (v3 React SDK) | v6 (v4 React SDK) |
|---|---|---|
| Event processing | Enabled by default (batch processor) | Opt-in — pass eventProcessor to createInstance, otherwise no events are dispatched |
| ODP | Enabled by default (configured via odpOptions) |
Opt-in — pass odpManager to createInstance, otherwise ODP is disabled |
| VUID tracking | Enabled by default | Opt-in — pass vuidManager to createInstance |
| Logging | Enabled by default | Opt-in — pass logger to createInstance, otherwise logging is disabled |
In v5, onReady() always fulfilled with { success: boolean, reason?: string }. In v6, onReady() fulfills when the client is ready and rejects on failure:
// v3
optimizely.onReady().then(({ success, reason }) => {
if (success) { /* ready */ }
else { console.log(reason); }
});
// v4
optimizely.onReady()
.then(() => { /* ready */ })
.catch((err) => { console.error(err); });Note: When using hooks (
useDecide, etc.), you don't callonReadydirectly — the Provider and hooks handle readiness internally. This change primarily affects direct client usage in server components or outside the Provider.
In v3, createInstance returned null on invalid config. In v4, it throws an error. Wrap the call in a try/catch if you need to handle invalid configurations.
import { createInstance } from '@optimizely/react-sdk';
const optimizely = createInstance({
sdkKey: 'your-sdk-key',
datafile: window.optimizelyDatafile,
// v3-specific options
eventBatchSize: 10,
eventFlushInterval: 2000,
});createInstance returned a ReactSDKClient — a custom wrapper around the JS SDK with user management, readiness tracking, and React-specific methods.
In v4, the Config type is modular. Options like sdkKey, datafile, and event batching are no longer top-level — they are configured through dedicated factory functions. The only required field is projectConfigManager.
import {
createInstance,
createPollingProjectConfigManager,
} from '@optimizely/react-sdk';
const optimizely = createInstance({
projectConfigManager: createPollingProjectConfigManager({
sdkKey: 'your-sdk-key',
datafile: window.optimizelyDatafile, // optional: use as initial datafile while polling for updates
autoUpdate: true,
}),
});createInstance from @optimizely/react-sdk returns a JS SDK v6 Client augmented with React-specific metadata. The client no longer holds user state — that responsibility moves to <OptimizelyProvider>.
Important: You must use
createInstancefrom@optimizely/react-sdk, not from@optimizely/optimizely-sdk. A client created directly from the JS SDK will not work correctly with<OptimizelyProvider>and hooks.
Key differences:
sdkKeyanddatafileare passed to a config manager factory, not tocreateInstancedirectly.- You can no longer call
optimizely.setUser()or other v3-specific wrapper methods on the returned client. Use hooks or the JS SDK client API instead.
| Factory | Use case |
|---|---|
createPollingProjectConfigManager() |
Fetches and polls for datafile updates. sdkKey is required. |
createStaticProjectConfigManager() |
Uses a fixed datafile with no polling. |
| Factory | Use case |
|---|---|
createBatchEventProcessor() |
Batches events before dispatching. |
createForwardingEventProcessor() |
Forwards each event immediately. |
| Factory | Use case |
|---|---|
createOdpManager() |
Enables ODP integration (audience segments, events). |
createVuidManager() |
Enables visitor UID tracking. |
createErrorNotifier() |
Configures error notification. |
createLogger({ logLevel }) |
Creates a logger instance (see Logger). |
import {
createInstance,
createPollingProjectConfigManager,
createBatchEventProcessor,
createOdpManager,
createLogger,
DEBUG,
} from '@optimizely/react-sdk';
const optimizely = createInstance({
projectConfigManager: createPollingProjectConfigManager({
sdkKey: 'your-sdk-key',
}),
eventProcessor: createBatchEventProcessor({
batchSize: 10,
flushInterval: 2000,
}),
odpManager: createOdpManager(),
logger: createLogger({ logLevel: DEBUG }),
});import { OptimizelyProvider, createInstance } from '@optimizely/react-sdk';
const optimizely = createInstance({ sdkKey: 'your-sdk-key' });
// v3 Provider accepted ReactSDKClient as `optimizely` prop
<OptimizelyProvider
optimizely={optimizely}
user={{ id: 'user-123', attributes: { plan: 'gold' } }}
timeout={500}
isServerSide={false}
>
<App />
</OptimizelyProvider>v3 also supported deprecated userId and userAttributes props, and a Promise<UserInfo> for async user resolution.
import { OptimizelyProvider, createInstance, createPollingProjectConfigManager } from '@optimizely/react-sdk';
const optimizely = createInstance({
projectConfigManager: createPollingProjectConfigManager({ sdkKey: 'your-sdk-key' }),
});
// v4 Provider uses `client` prop (not `optimizely`)
<OptimizelyProvider
client={optimizely}
user={{ id: 'user-123', attributes: { plan: 'gold' } }}
timeout={500}
>
<App />
</OptimizelyProvider>| v3 Prop | v4 Prop | Notes |
|---|---|---|
optimizely |
client |
Renamed. Accepts the client returned by React SDK's createInstance. |
user |
user |
Same shape { id, attributes }. No longer accepts a Promise. |
timeout |
timeout |
Default changed from 5000 ms to 30000 ms. |
isServerSide |
(removed) | No longer needed. v4 hooks return decisions synchronously whenever both user context and config are available, regardless of environment. |
userId |
(removed) | Deprecated in v3, removed in v4. Use user instead. |
userAttributes |
(removed) | Deprecated in v3, removed in v4. Use user instead. |
| (new) | skipSegments |
Skips ODP segment fetching. Default false. |
qualifiedSegments |
qualifiedSegments |
Pre-fetched ODP segments for the user. Same behavior in both versions. |
v3 allowed passing a Promise<UserInfo> to the user prop. In v4, resolve the user before rendering the Provider:
// v3
const userPromise = fetchUser();
<OptimizelyProvider optimizely={optimizely} user={userPromise}>
// v4 — resolve the user first
function AppWrapper() {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser().then(setUser);
}, []);
if (!user) return <LoadingSpinner />;
return (
<OptimizelyProvider client={optimizely} user={user}>
<App />
</OptimizelyProvider>
);
}The primary decision hook has been renamed and its signature changed.
import { useDecision } from '@optimizely/react-sdk';
const [decision, clientReady, didTimeout] = useDecision(
'flag-key',
{ autoUpdate: true, timeout: 500, decideOptions: [OptimizelyDecideOption.INCLUDE_REASONS] },
{ overrideUserId: 'other-user', overrideAttributes: { plan: 'gold' } }
);
if (!clientReady) return <Loading />;
if (decision.enabled) return <NewFeature />;import { useDecide } from '@optimizely/react-sdk';
const { decision, isLoading, error } = useDecide(
'flag-key',
{ decideOptions: [OptimizelyDecideOption.INCLUDE_REASONS] }
);
if (isLoading) return <Loading />;
if (error) return <Error error={error} />;
if (decision.enabled) return <NewFeature />;What changed:
| Aspect | v3 useDecision |
v4 useDecide |
|---|---|---|
| Import name | useDecision |
useDecide |
| Return type | [decision, clientReady, didTimeout] tuple |
{ decision, isLoading, error } object |
autoUpdate option |
Per-hook opt-in | Removed; updates are automatic via SDK polling |
timeout option |
Per-hook override | Removed; set on <OptimizelyProvider> only |
overrideUserId |
Third argument | Removed |
overrideAttributes |
Third argument | Removed |
| Error handling | Check decision for failed state |
Explicit error property |
| Loading state | !clientReady |
isLoading: true |
| Decision type when loading | Failed OptimizelyDecision object |
null |
useExperiment is removed in v4 with no hook replacement. For programmatic access, client.activate() is still available on the client. If hook-level reactivity for experiments is needed, consider staying on v3 for those components.
useFeature is removed in v4 with no hook replacement. For programmatic access, client.isFeatureEnabled() is still available on the client. If hook-level reactivity for feature flags is needed, consider staying on v3 for those components.
useTrackEvent is removed in v4. Use useOptimizelyUserContext to track events:
const { userContext } = useOptimizelyUserContext();
const handleClick = () => {
if (userContext) {
userContext.trackEvent('my-event', { revenue: 100 });
}
};v4 introduces several new hooks:
| Hook | Description |
|---|---|
useDecide(flagKey, config?) |
Single flag decision (replaces useDecision) |
useDecideForKeys(flagKeys[], config?) |
Batch decisions for multiple flag keys |
useDecideAll(config?) |
Decisions for all active flags |
useDecideAsync(flagKey, config?) |
Async variant of useDecide |
useDecideForKeysAsync(flagKeys[], config?) |
Async variant of useDecideForKeys |
useDecideAllAsync(config?) |
Async variant of useDecideAll |
useOptimizelyClient() |
Returns the Optimizely Client instance from context |
useOptimizelyUserContext() |
Returns { userContext, isLoading, error } |
import { useDecideForKeys } from '@optimizely/react-sdk';
const { decisions, isLoading, error } = useDecideForKeys(['flag-a', 'flag-b']);
if (!isLoading) {
const flagA = decisions['flag-a'];
const flagB = decisions['flag-b'];
}import { useDecideAsync } from '@optimizely/react-sdk';
const { decision, isLoading, error } = useDecideAsync('flag-key');Async hooks call the underlying async SDK methods (decideAsync, decideForKeysAsync, decideAllAsync). Use these when your setup involves asynchronous operations such as CMAB (Contextual Multi-Armed Bandit) decisions or async User Profile Service lookups.
import { withOptimizely } from '@optimizely/react-sdk';
class MyComponent extends React.Component {
render() {
const { optimizely } = this.props;
const decision = optimizely.decide('flag-key');
return <div>{decision.enabled ? 'On' : 'Off'}</div>;
}
}
export default withOptimizely(MyComponent);import { useOptimizelyClient } from '@optimizely/react-sdk';
function MyComponent() {
const client = useOptimizelyClient();
}The following v3 exports are removed in v4:
OptimizelyExperiment— Removed along with its underlyinguseExperimenthook.OptimizelyFeature— Removed along with its underlyinguseFeaturehook.OptimizelyVariation— Removed as it was only used as a child ofOptimizelyExperiment.
withOptimizely— UseuseOptimizelyClienthook instead.
useExperiment— Removed with no hook replacement. Useclient.activate()for programmatic access.useFeature— Removed with no hook replacement. Useclient.isFeatureEnabled()for programmatic access.useDecision— Renamed touseDecidewith a new return type.useTrackEvent— UseuseOptimizelyUserContextinstead.
Methods like activate(), getVariation(), isFeatureEnabled(), getFeatureVariables(), getEnabledFeatures(), setForcedVariation(), getForcedVariation(), and track() are still available on the client. As user is now decoupled from the client in v4, userId is a required parameter on all these methods.
The following v3-specific wrapper methods are removed:
setUser()/onUserUpdate()— User is managed by<OptimizelyProvider>props.
OptimizelyContext— UseuseOptimizelyClientoruseOptimizelyUserContexthooks.OptimizelyContextConsumer— Use hooks instead of the context consumer.OptimizelyContextProvider— Internal; use<OptimizelyProvider>directly.
logOnlyEventDispatcher— To disable event dispatching, simply don't pass aneventProcessortocreateInstance(event processing is opt-in in v4).setLogger/setLogLevel— Replaced bycreateLogger()factory (see Logger).logging— No longer needed; usecreateLogger().errorHandler— No longer needed; usecreateErrorNotifier().enums— Removed.
// Via useTrackEvent
const [track] = useTrackEvent();
track('purchase', undefined, undefined, { revenue: 4200 });
// Via withOptimizely
const { optimizely } = this.props;
optimizely.track('purchase');const { userContext } = useOptimizelyUserContext();
userContext?.trackEvent('purchase', { revenue: 4200 });const { optimizely } = this.props; // via withOptimizely
optimizely.setForcedDecision(
{ flagKey: 'flag-1', ruleKey: 'rule-1' },
{ variationKey: 'variation-a' }
);Forced decisions are set on the userContext object:
const { userContext } = useOptimizelyUserContext();
// Set a forced decision
userContext?.setForcedDecision(
{ flagKey: 'flag-1', ruleKey: 'rule-1' },
{ variationKey: 'variation-a' }
);
// Remove a forced decision
userContext?.removeForcedDecision({ flagKey: 'flag-1', ruleKey: 'rule-1' });
// Remove all forced decisions
userContext?.removeAllForcedDecisions();Hooks that use the affected flag key automatically re-render when forced decisions change.
import { createInstance, setLogLevel } from '@optimizely/react-sdk';
const optimizely = createInstance({
sdkKey: 'your-sdk-key',
logLevel: 'debug',
});import {
createInstance,
createPollingProjectConfigManager,
createLogger,
DEBUG,
} from '@optimizely/react-sdk';
const optimizely = createInstance({
projectConfigManager: createPollingProjectConfigManager({ sdkKey: 'your-sdk-key' }),
logger: createLogger({ logLevel: DEBUG }),
});Logging is disabled by default in v4. You must pass a logger to createInstance to enable it. The createLogger function accepts a logLevel option, and the log level constants (DEBUG, INFO, WARN, ERROR) are exported for convenience.
<OptimizelyProvider
optimizely={optimizely}
user={{ id: 'user-123' }}
isServerSide={true}
>
<App />
</OptimizelyProvider>The isServerSide prop is removed. Instead, configure the client for SSR use:
'use client';
import { useState } from 'react';
import {
createInstance,
createStaticProjectConfigManager,
createPollingProjectConfigManager,
createBatchEventProcessor,
OptimizelyProvider,
OptimizelyDecideOption,
} from '@optimizely/react-sdk';
function ExampleProvider({ children, datafile }) {
const isServerSide = typeof window === 'undefined';
const [optimizely] = useState(() =>
createInstance({
projectConfigManager: isServerSide
? createStaticProjectConfigManager({ datafile })
: createPollingProjectConfigManager({
sdkKey: process.env.NEXT_PUBLIC_OPTIMIZELY_SDK_KEY,
datafile,
}),
eventProcessor: isServerSide ? undefined : createBatchEventProcessor(),
defaultDecideOptions: isServerSide ? [OptimizelyDecideOption.DISABLE_DECISION_EVENT] : [],
disposable: isServerSide,
})
);
return (
<OptimizelyProvider client={optimizely} user={{ id: 'user-123' }}>
{children}
</OptimizelyProvider>
);
}ODP audience segments require async I/O, which is not available during synchronous server rendering. If your audience conditions depend on ODP segments, you can pre-fetch them server-side using getQualifiedSegments and pass them to the Provider.
getQualifiedSegments is available in both v3 and v4, but the return type has changed:
// v3
import { getQualifiedSegments } from '@optimizely/react-sdk';
const segments = await getQualifiedSegments(userId, datafile);
// segments: string[] | null
// v4
import { getQualifiedSegments } from '@optimizely/react-sdk';
const { segments, error } = await getQualifiedSegments(userId, datafile);
// returns QualifiedSegmentsResult { segments: string[], error: Error | null }// v3
<OptimizelyProvider
optimizely={optimizely}
user={{ id: 'user-123' }}
qualifiedSegments={segments}
>
<App />
</OptimizelyProvider>
// v4
<OptimizelyProvider
client={optimizely}
user={{ id: 'user-123' }}
qualifiedSegments={segments}
skipSegments={isServerSide}
>
<App />
</OptimizelyProvider>qualifiedSegments— Pass pre-fetched segments so the Provider can create the user context synchronously with segments already set. Available in both v3 and v4.skipSegments— (New in v4) Whentrue, skips the Provider's background ODP segment fetch. Use this on the server to avoid unnecessary async work.
v4 provides a server-safe entry point via the react-server export condition in package.json. Frameworks that support this condition (e.g., Next.js App Router) automatically resolve @optimizely/react-sdk to the server entry point when importing from a Server Component. This entry point excludes hooks and Provider (which use client-only React APIs), so it is safe to import in server contexts.
import { createInstance, createStaticProjectConfigManager } from '@optimizely/react-sdk';
export default async function ServerComponent() {
const client = createInstance({
projectConfigManager: createStaticProjectConfigManager({ datafile }),
});
await client.onReady();
const userContext = client.createUserContext('user-123');
const decision = userContext.decide('flag-key');
client.close();
return decision.enabled ? <NewFeature /> : <Default />;
}| v3 | v4 |
|---|---|
ReactSDKClient |
Client |
| Type | Description |
|---|---|
UseDecideConfig |
Config object for useDecide — { decideOptions?: OptimizelyDecideOption[] } |
UseDecideResult |
Return type of useDecide — discriminated union of loading/error/success |
UseDecideMultiResult |
Return type of useDecideForKeys / useDecideAll |
OptimizelyProviderProps |
Props for <OptimizelyProvider> |
UserInfo |
{ id?: string; attributes?: UserAttributes } |
QualifiedSegmentsResult |
Return type of getQualifiedSegments — { segments: string[], error: Error | null } (replaces string[] | null from v3) |
v3 hooks returned positional tuples. v4 hooks return discriminated union objects:
// v3
type UseDecisionReturn = [OptimizelyDecision, boolean, boolean];
// v4
type UseDecideResult =
| { isLoading: true; error: null; decision: null }
| { isLoading: false; error: Error; decision: null }
| { isLoading: false; error: null; decision: OptimizelyDecision };This pattern enables exhaustive narrowing:
const result = useDecide('flag');
if (result.isLoading) {
// result.decision is null, result.error is null
}
if (result.error) {
// result.decision is null, result.isLoading is false
}
// result.decision is OptimizelyDecision