# React Component Builder

> Build a reusable React component that is accessible, controllable, and correctly typed — not just something that renders. Use when adding a shared/design-system component or refactoring a one-off into something reusable. Enforces the controlled/uncontrolled contract, a11y semantics, forwarded refs, and states beyond the happy path (loading, empty, error, disabled).

- Skill: `omonuj/react-component-builder` (Agent Skill)
- Install (CLI): `npx skillmds@latest add omonuj/react-component-builder`
- Raw SKILL.md: https://api.skillmd.com/api/skills/omonuj/react-component-builder/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: omonuj (https://skillmd.com/u/omonuj)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/omonuj/react-component-builder

---


# 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
1. **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 whether `value` is provided; never silently ignore one. Warn (dev-only) if a component flips between the two across renders.
2. **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).
4. **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.
3. **Forward the ref, spread the rest.** `forwardRef` so parents can focus/measure the node; spread remaining DOM props (`...rest`) onto the root so `aria-*`, `data-*`, and event handlers pass through. Don't trap consumers behind a prop allowlist you'll forever be extending.
5. **Typed honestly.** Props extend the underlying element's props (`ComponentPropsWithoutRef<'button'>`) so you inherit `onClick`, `disabled`, etc. for free. No `any`. Discriminated unions for mutually-exclusive prop shapes (e.g. `icon`-only vs `label` buttons).

## Accessibility checklist (run every time)
- Reachable and operable by keyboard alone; visible focus ring (never `outline: none` without 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`, or `aria-labelledby`).
- State communicated non-visually: `aria-expanded`, `aria-selected`, `aria-invalid`, `aria-disabled`, `aria-busy` as appropriate.
- Focus management for overlays: move focus in on open, trap it while open, restore it to the trigger on close.
- Respects `prefers-reduced-motion` for 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: none` with nothing replacing the focus indicator.
- `useEffect` to sync a prop into state and back — derive during render or lift state instead; the effect round-trip causes flicker and stale values.
- An `index` as `key` in 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`, `isDanger` booleans) — use one `variant` union.

## Procedure
1. Decide: presentational (dumb, props in/callbacks out) or a container? Shared-library components should be presentational.
2. Type the props off the underlying element; add the component's own props; use a discriminated union for exclusive shapes.
3. Implement the controlled/uncontrolled contract if the component holds a value.
4. Build every state (default/loading/empty/error/disabled), not just the happy path.
5. `forwardRef` + spread `...rest` onto the root.
6. Run the a11y checklist; fix with semantic HTML first, ARIA second.
7. 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.
8. 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 `useState` calls.
- 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.

