I want to take a look at how to use React's Suspense component with Optimizely's A/B testing Javascript SDK.
Suspense was introduced in React 18 to simplify how loading states are expressed in a React component tree. It allows you to structure you app's pending states around promises instead of maintaining explicit markers like isLoading or similar.
More than anything else, I'm hoping to show how to be granular with A/B test loading. It's a good practice to not block rendering parts of your frontend that aren't involved in an active test, both for a responsive UX and for overall optimal page performance.
A Product Page You've Seen Before
Let's imagine you're running an e-commerce site for clothing. You'll have a product page that shows listings for any selected category, let's say Men's Outerwear. The React component for that page might look like this:
export default function Products({ productCategoryId }) {
return (
<>
<PopularProducts categoryId={productCategoryId} />
<ProductSearchBar />
<SearchResults />
</>
);
}
Now let's run an A/B test on the popular products listing. For a select group we'll show a list of promoted products for the current category instead of what's popular. Our hypothesis will be that the total number of completed checkouts will increase for all sessions that saw promotions while browsing. And we'll let Optimizely handle the analysis to determine whether our A/B test group actually represents an improvement.
So to make this happen I need a few bits of boilerplate. First, I'll sketch out the control flow:
export default function Products({
productCategoryId,
+ evaluateABTest
}) {
+ const {
+ enabled, // In Optimizely, every A/B test is wrapped by a feature flag, which (separately from audience targeting) can be turned on or off
+ flagKey, // Here, "promoted_products". Useful if the same component renders multiple active tests
+ variationKey, // By convention "control" or "treatment", but is customizable in a test's configuration
+ variables, // A generic object that contains treatment-specific variables
+ } = evaluateABTest('promoted_products');
return (
<>
- <PopularProducts categoryId={productCategoryId} />
+ <>
+ {!enabled || !variationKey || variationKey === 'control' || && (
+ <PopularProducts categoryId={productCategoryId} />
+ )}
+ {enabled && variationKey === 'treatment' && (
+ <PromotedProducts
+ campaignId={variables['campaign_id']}
+ categoryId={productCategoryId}
+ />
+ )}
+ </>
<ProductSearchBar />
<SearchResults />
</>
);
};
The evaluateABTest prop is a function that accepts an experiment key and returns an Optimizely Decision object.
Providing evaluateABTest as a prop sets us up to integrate with React Router's client loading architecture, which in turn allows us to use React.Suspense in our render.
Now let's write the client loader for this route to actually provide the new prop.
By convention client loaders are specified as a named export, clientLoader(), from the same file as the route itself.
+ import { OptimizelyHelper } from 'lib/helpers';
+ export async function clientLoader({ params }) {
+ const optimizely = OptimizelyHelper.getMemoizedSDKInstance();
+
+ const { success: clientIsReady, reason } = await optimizely.onReady();
+ const { userId, attributes } = await getCurrentUserAsync();
+
+ const user = optimizely.createUserContext(userId, attributes);
+
+ if (!user) {
+ console.warn(`Could not identify user ${userId}`);
+ return;
+ }
+
+ const evaluateABTest = (flagKey) => {
+ if (!clientIsReady) {
+ console.warn(`Could not evaluate "${flagKey}":`);
+ console.warn(reason);
+ return;
+ }
+
+ const decisions = user.decideForKeys([flagKey])
+
+ // Other errors are reported in the .reasons field of the returned Decision object
+ return decisions[flagKey];
+ };
+
+ return { evaluateABTest };
+ }
export default function Products({
productCategoryId,
loaderData,
}) {
- const {
- enabled,
- flagKey,
- variationKey,
- variables,
- } = evaluateABTest('promoted_products');
+ const { evaluateABTest } = loaderData;
+ const {
+ enabled,
+ flagKey,
+ variationKey,
+ variables,
+ } = React.useMemo(() => evaluateABTest('promoted_products'), []);
return (
<>
<>
{!enabled || !variationKey || variationKey === 'control' || && (
<PopularProducts categoryId={productCategoryId} />
)}
{enabled && variationKey === 'treatment' && (
<PromotedProducts
campaignId={variables['campaign_id']}
categoryId={productCategoryId}
/>
)}
</>
</React.Suspense>
<ProductSearchBar />
<SearchResults />
</>
);
};
Much of the boilerplate in the client loader is dedicated to handling the loading state of the SDK.
evaluateABTest() serves as a thin wrapper around the Optimizely SDK's decideForKeys() method.
Most importantly, the awaits inside clientLoader() block the entire route render while the SDK instance downloads a datafile and the current user details are fetched.
If instead we return a promise from the loader instead of awaiting each async call within, the loaderData prop can then be used to suspend only part of the render without blocking the entire page.
import * as React from "react";
import { OptimizelyHelper } from 'lib/helpers';
export async function clientLoader({ params }) {
const optimizely = OptimizelyHelper.getMemoizedSDKInstance();
- const { success: clientIsReady, reason } = await optimizely.onReady();
- const { userId, attributes } = await getCurrentUserAsync();
-
- const user = optimizely.createUserContext(userId, attributes);
-
- if (!user) {
- console.warn(`Could not identify user ${userId}`);
- return;
- }
-
- const evaluateABTest = (flagKey) => {
- if (!clientIsReady) {
- console.warn(`Could not evaluate "${flagKey}":`);
- console.warn(reason);
- return;
- }
-
- const decisions = user.decideForKeys([flagKey])
-
- // Other errors are reported in the .reasons field of the returned Decision object
- return decisions[flagKey];
- };
-
- return { evaluateABTest };
+ return Promise.all([
+ optimizely.onReady(),
+ getCurrentUserAsync()
+ ])
+ .then(([
+ { success: clientIsReady, reason },
+ { userId, attributes }
+ ]) =>
+ optimizely.createUserContext(userId, attributes)
+ )
+ .then((user) => {
+ const evaluateABTest = (flagKey) => {
+ if (!user) {
+ console.warn(`Could not identify user ${userId}`);
+ return;
+ }
+
+ if (!clientIsReady) {
+ console.warn(`Could not evaluate "${flagKey}":`);
+ console.warn(reason);
+ return;
+ }
+
+ const decisions = user.decideForKeys([flagKey])
+
+ // Other errors are reported in the .reasons field of the returned Decision object
+ return decisions[flagKey];
+ };
+
+ return { evaluateABTest };
+ });
};
export default function Products({
productCategoryId,
loaderData,
}) {
const optimizelyAsync = loaderData;
return (
<>
<React.Suspense fallback={<LoadingBar />}>
<Await resolve={optimizelyAsync}>
{({ evaluateABTest }) => {
const { enabled, variationKey, variables } = evaluateABTest('promoted_products');
return (
<>
{!enabled || !variationKey || variationKey === 'control' || && (
<PopularProducts categoryId={productCategoryId} />
)}
{enabled && variationKey === 'treatment' && (
<PromotedProducts
campaignId={variables['campaign_id']}
categoryId={productCategoryId}
/>
)}
</>
);
}}
</Await>
</React.Suspense>
<ProductSearchBar />
<SearchResults />
</>
);
};
Notice I needed to push down the evaluateABTest() function into a .then() chain so that the entire loader could return an un-awaited promise.
That promise is then passed into React Router's <Await> component which will render its children only once its prop has resolved.
And with those changes it's finally possible to show a loading state (tied to the Optimizely instance's lifecycle) only for the promotional area that's part of our A/B test. React.Suspense handles showing the fallback for us, in this case <LoadingBar />.
Remember: It's always best to load progressively, and with small loading states that take up minimal area instead of blocking an entire page from rendering.
Here's the final code block without diff markers:
import * as React from "react";
import { OptimizelyHelper } from 'lib/helpers';
export async function clientLoader({ params }) {
const optimizely = OptimizelyHelper.getMemoizedSDKInstance();
return Promise.all([
optimizely.onReady(),
getCurrentUserAsync()
])
.then(([
{ success: clientIsReady, reason },
{ userId, attributes }
]) =>
optimizely.createUserContext(userId, attributes)
)
.then((user) => {
const evaluateABTest = (flagKey) => {
if (!user) {
console.warn(`Could not identify user ${userId}`);
return;
}
if (!clientIsReady) {
console.warn(`Could not evaluate "${flagKey}":`);
console.warn(reason);
return;
}
const decisions = user.decideForKeys([flagKey])
// Other errors are reported in the .reasons field of the returned Decision object
return decisions[flagKey];
};
return { evaluateABTest };
});
};
export default function Products({
productCategoryId,
loaderData,
}) {
const optimizelyAsync = loaderData;
return (
<>
<React.Suspense fallback={<LoadingBar />}>
<Await resolve={optimizelyAsync}>
{({ evaluateABTest }) => {
const { enabled, variationKey, variables } = evaluateABTest('promoted_products');
return (
<>
{!enabled || !variationKey || variationKey === 'control' || && (
<PopularProducts categoryId={productCategoryId} />
)}
{enabled && variationKey === 'treatment' && (
<PromotedProducts
campaignId={variables['campaign_id']}
categoryId={productCategoryId}
/>
)}
</>
);
}}
</Await>
</React.Suspense>
<ProductSearchBar />
<SearchResults />
</>
);
};