setting-feature-flags-in-storybook
About setting-feature-flags-in-storybook
A guide for setting feature flags in Storybook stories. It explains the parameter forms for boolean and variant flags and why imperative updates can produce incorrect visual-regression snapshots.
Key features
- 01Set boolean flags with a story parameter
- 02Pin variant values with record syntax
- 03Mix booleans and variants in one record
- 04Account for shallow parameter replacement
- 05Stage other runtime state when needed
Use cases
- 01Render a story with a boolean flag enabled
- 02Compare stories using different experiment variants
- 03Avoid flag-off visual-regression snapshots
- 04Set up stories that depend on additional runtime state
Install
Copy the command and run it in a terminal.
npx skills add https:/ / github.com/ posthog/ posthog --skill setting-feature-flags-in-storybookAfter installing, type/setting-feature-flags-in-storybookin Claude Code to confirm it loaded.
Details
From the project READMESetting feature flags in Storybook
Warning
Never call
featureFlagLogic.actions.setFeatureFlags(...)from a story. It silently no-ops in the visual-regression runtime (rendering the flag-OFF branch) while passing in jest — so unit tests stay green and the snapshot is wrong. Use thefeatureFlagsstory parameter instead.
Mental model: boolean flags always work (they ride the always-merged baseline);
variant values need a gate that only the featureFlags parameter opens correctly.
How to use it
This is the stable contract — prefer it over anything in "Under the hood" below.
Boolean flags (flag is simply on)
const meta: Meta = {
title: 'Scenes/MyScene',
parameters: {
featureFlags: [FEATURE_FLAGS.MY_FLAG, FEATURE_FLAGS.OTHER_FLAG],
},
}
Array entries are flags that evaluate to true. This is the common case.
Multivariate flags (pin a specific variant)
For a flag whose value is a variant string (experiment arms, multivariate rollouts), use
the record form — the array form can only express true:
// meta-level: applies to every story in the file
parameters: { featureFlags: { [FEATURE_FLAGS.THEME_OVERRIDE]: 'intent_plus' } }
// per-story: a different arm per story
export const ControlArm: Story = {
parameters: { featureFlags: { [FEATURE_FLAGS.THEME_OVERRIDE]: 'control' } },
}
export const TreatmentArm: Story = {
parameters: { featureFlags: { [FEATURE_FLAGS.THEME_OVERRIDE]: 'intent_plus' } },
}
You can mix booleans and variants in one record: { 'some-bool-flag': true, 'my-experiment': 'test_b' }.
Important
A story's
parametersreplace the meta's (shallow merge), so a per-storyfeatureFlagsdrops every flag set at the meta level. Re-list any meta flags you still need — silently losing one renders the flag-off branch and is easy to miss.
Why the obvious approach fails
Setting flags imperatively (featureFlagLogic.actions.setFeatureFlags) works in jest
(NODE_ENV==='test') but not in the built visual-regression Storybook. posthog-js loads
flags and fires onFeatureFlags with an empty set, which dispatches setFeatureFlags
and wipes whatever you set imperatively — so the unit test passes while the snapshot
renders the flag-OFF branch. The featureFlags parameter avoids this by writing flags to
the always-merged baseline instead (below), so the empty callback can't clobber them.
Do not try to fix this by disabling posthog-js flags (e.g.
advanced_disable_feature_flags). The app shell (appLogic.showApp) normally waits untilreceivedFeatureFlagsis true, which is set by that sameonFeatureFlagscallback — suppress it and everyScenes-App/*story stalls behind a 3s timeout and flakes. The login page is an intentional exception: with no user on/login,showApprenders without waiting for flags. Login stories still need thefeatureFlagsparameter for deterministic flag state.
Under the hood (implementation detail — may drift)
This explains why the parameter works, for trust and for anyone extending the harness. Treat "How to use it" above as the contract, not this.
withFeatureFlags → setFeatureFlags (frontend/src/mocks/browser.tsx) writes the
flags (array or record) straight to window.POSTHOG_APP_CONTEXT.persisted_feature_flags.
getPersistedFeatureFlags (frontend/src/lib/logic/featureFlagLogic.ts) reads that as
featureFlagLogic's initial value and spyOnFeatureFlags always merges it as the
baseline — so both booleans and pinned variants survive the empty onFeatureFlags
callback. The only production-side support this needs is getPersistedFeatureFlags
accepting the record form (variant values) in addition to the server's array form.
You should not need to touch any of this. If you're extending the harness, that's the seam.
See also
The featureFlags parameter only controls the flag. If a component's render also depends
on other runtime state (localStorage, an APP_CONTEXT field, a kea logic that resolves an
entry), stage that state too — typically in a small wrapper the story renders that sets it
and mounts the logic before rendering the component. Keep that wrapper in the story file;
don't add test-only props to the production component to make it renderable.