storybook-controlled-components
Use when a Storybook story for a controlled component is frozen — picking an option, deleting a chip, typing, or toggling fires onChange but nothing on screen changes. Applies to any story whose compo
SKILL.mdplugins/react-frontend-sdlc/skills/storybook-controlled-components/SKILL.md
GitHubStorybook stories for controlled components
Profile keys consumed
capabilities.storybookmake.storybook_buildcapabilities.visual_testingmake.test_visualarchitecture.source_rootarchitecture.component_prefix
Storybook work is gated by capabilities.storybook: skip the whole skill with a recorded
capability-absent note when it is false. Verify a story through the target mapped by
make.storybook_build (skip with a recorded note when it maps to null), and through the target
mapped by make.test_visual where the stories are the screenshot surface.
Overview
A controlled component renders whatever value it is handed. A story that passes a static value
and only logs onChange can never update, so the story looks broken even though the component is
correct. The story itself has to own the state.
When to use
- A story's control does not respond to clicks, selection, deletion, or typing.
- A story passes
valuefromargsand wiresonChangeto an action logger only. - A new story is being written for any component that exposes a
value/onChangepair. - Not for: uncontrolled components, or components whose only props are presentational.
Applicability by repository shape
- React SPA shape (feature modules under the source root, a bootable app, an aggregate CI
target): yes — Storybook 9 with stories under
<source root>/**/*.stories.@(js|jsx|ts|tsx); the repository's Storybook dev-server target runs the panel. - Next.js app shape (routed pages, no aggregate duplication gate): yes — Storybook 10 with the
same story glob plus
mjs, driven by the same Storybook dev-server target. - Component-library shape (Storybook-first, no bootable app, published package): yes —
Storybook 10, stories co-located in each prefixed component folder under the source root
(
architecture.component_prefix, e.g.src/components/ui-*); the select and multi-select stories are the shipped examples.
# Where a shape maps a Storybook dev-server target, it is typically:
make storybook-start
Core pattern
Use a CSF3 render that is a named function — hooks are only legal inside a component, and the
capitalised name is what makes lint and React treat it as one. Seed the state from args so the
Controls panel still supplies the initial selection, then turn off the panel control for value so
it cannot fight the local state.
const meta: Meta<typeof UiMultiSelect> = {
title: 'UiComponents/UiMultiSelect',
component: UiMultiSelect,
argTypes: {
options: { control: 'object' }, // editable in the panel, unlike `value`
// Selection is driven by the story's own state; its panel control is off.
value: { control: false },
},
};
export const MultiSelect: StoryObj<typeof UiMultiSelect> = {
args: { options, value: [options[0], options[2]], label: 'Role' },
render: function Render(args): React.ReactElement {
const [value, setValue] = React.useState<UiMultiSelectOption[]>(args.value ?? []);
return (
<UiMultiSelect
options={args.options}
label={args.label}
value={value}
onChange={setValue}
/>
);
},
};
// Sibling variants reuse the same stateful renderer rather than copying it.
export const Loading: StoryObj<typeof UiMultiSelect> = {
args: { options, value: [options[0]], label: 'Role', loading: true },
render: MultiSelect.render,
};
Everything that is not selection state — options, label, placeholder, disabled, loading
— keeps coming from args, so the Controls panel stays useful for exercising the component with
different data.
Why it matters beyond the panel
In the component-library shape the stories are the render surface for the Playwright visual suite
mapped by make.test_visual, which screenshots them directly: a frozen story pins a baseline of a
state the component can never actually reach, so the regression the baseline was meant to catch goes
unnoticed. In the React SPA and Next.js shapes the Playwright suites drive the app rather than
Storybook, but the stories are still the review surface and the Storybook build gate mapped by
make.storybook_build, so a frozen story hides the same defect from the reviewer.
Common mistakes
- An arrow function for
render— the hook then sits outside a component and the hooks lint fails. - Leaving
valueas an editable control while local state also owns it; the two overwrite each other and the story flickers back to the arg. - Copying the whole stateful renderer into every variant instead of reusing the first story's
render, which duplicates code and drifts. - Initialising state from a module-level constant instead of
args, so the Controls panel's initial value is silently ignored.