react-component-builder
Most React components render fine in the demo and fall apart the moment a second team uses them: they can't be controlled, they're inaccessible to a keyboard or screen reader, they forget the loading/empty/error states, and their props are typed as any. This skill builds the other kind — a component a design system can stand on.
Use when
- Adding a component to a shared library / design system.
- Promoting a one-off into something two or more places will use.
- Refactoring a component that "works" but is unusable by keyboard or screen reader.
The five things that separate reusable from throwaway
- The controlled/uncontrolled contract. A form-ish component must support both: controlled (
value+onChange, parent owns state) and uncontrolled (defaultValue, component owns state via a ref). Pick based on whethervalueis provided; never silently ignore one. Warn (dev-only) if a component flips between the two across renders. - Accessibility is not optional. Real semantic elements first (
<button>,<label>,<nav>); ARIA only to fill gaps. Every interactive element is keyboard-operable and focus-visible. Custom widgets follow the WAI-ARIA authoring pattern for their role (a "combobox" has the roles, states, and key bindings a combobox is specified to have). - All the states, not just the happy one. Design and implement: default, loading, empty, error, disabled, and (where relevant) read-only. A list component that only handles "has items" is half-built.
- Forward the ref, spread the rest.
forwardRefso parents can focus/measure the node; spread remaining DOM props (...rest) onto the root soaria-*,data-*, and event handlers pass through. Don't trap consumers behind a prop allowlist you'll forever be extending. - Typed honestly. Props extend the underlying element's props (
ComponentPropsWithoutRef<'button'>) so you inheritonClick,disabled, etc. for free. Noany. Discriminated unions for mutually-exclusive prop shapes (e.g.icon-only vslabelbuttons).
Accessibility checklist (run every time)
- Reachable and operable by keyboard alone; visible focus ring (never
outline: nonewithout a replacement). - Correct role/name/value — verify the accessible name in the a11y tree, not just the visual label.
- Interactive controls have an accessible label (visible
<label>,aria-label, oraria-labelledby). - State communicated non-visually:
aria-expanded,aria-selected,aria-invalid,aria-disabled,aria-busyas appropriate. - Focus management for overlays: move focus in on open, trap it while open, restore it to the trigger on close.
- Respects
prefers-reduced-motionfor animation. - Color is never the only signal (pair with icon/text); contrast meets WCAG AA.
Anti-patterns to reject
- A
<div onClick>where a<button>belongs — loses keyboard, focus, role, and Enter/Space for free. outline: nonewith nothing replacing the focus indicator.useEffectto sync a prop into state and back — derive during render or lift state instead; the effect round-trip causes flicker and stale values.- An
indexaskeyin a reorderable list — breaks identity, corrupts state on reorder. - Business logic and data fetching baked into a presentational component — keep the component dumb; pass data and callbacks in.
- Prop explosion (
isPrimary,isSecondary,isDangerbooleans) — use onevariantunion.
Procedure
- Decide: presentational (dumb, props in/callbacks out) or a container? Shared-library components should be presentational.
- Type the props off the underlying element; add the component's own props; use a discriminated union for exclusive shapes.
- Implement the controlled/uncontrolled contract if the component holds a value.
- Build every state (default/loading/empty/error/disabled), not just the happy path.
forwardRef+ spread...restonto the root.- Run the a11y checklist; fix with semantic HTML first, ARIA second.
- Write tests with Testing Library that query by role and accessible name (
getByRole('button', { name: 'Save' })) — this both tests behavior and enforces accessibility. Add a keyboard-interaction test for custom widgets. - Document props and show the controlled + uncontrolled usage (Storybook story or MDX).
Testing stance
- Query by role/label/text — how a user (and assistive tech) finds things — never by test id or class unless nothing else is stable.
- Test behavior and states, not implementation details; don't assert on internal state or specific
useStatecalls. - Include an axe/a11y assertion in the component's test where tooling allows.
Definition of done
- Keyboard-operable, screen-reader-correct, focus-visible; a11y checklist passes.
- Controlled and uncontrolled both work (if it holds a value).
- All states implemented; ref forwarded; extra props pass through.
- Props fully typed off the underlying element, no
any. - Tests query by role/name and cover states + keyboard; a controlled and an uncontrolled example documented.