How would you build a reusable UI component focused on modularity, performance, and design trade offs?
Design API first (props that compose well), keep the component unopinionated about styling (className passthrough, slots, asChild pattern), ensure a11y baked in (keyboard, focus, ARIA), memoize where it matters, support controlled + uncontrolled modes, expose imperative ref where needed, document with Storybook + a11y addon, test with both unit (logic) and visual (Chromatic/Percy). Look at Radix/React Aria/shadcn for proven patterns.
A truly reusable component is a small product, not just a piece of JSX. It needs API design, accessibility, theming, performance, and documentation.
1. API design
Composition over configuration:
// Bad — configuration props balloon as features grow
<Card title="X" subtitle="Y" footer={<Btn />} headerActions={[…]} />
// Good — slot composition
<Card>
<Card.Header>
<Card.Title>X</Card.Title>
<Card.Subtitle>Y</Card.Subtitle>
</Card.Header>
<Card.Body>…</Card.Body>
<Card.Footer><Btn /></Card.Footer>
</Card>Controlled + uncontrolled:
// Uncontrolled — component owns state
<Dialog defaultOpen={false}>
// Controlled — parent owns state
<Dialog open={open} onOpenChange={setOpen}>Support both. Default to uncontrolled (simpler API) and let users escalate to controlled when needed.
asChild pattern (Radix):
<Tooltip.Trigger asChild>
<button>Click me</button>
</Tooltip.Trigger>Forwards props onto the child instead of rendering a wrapper. Avoids unnecessary DOM nodes.
ClassName + style passthrough:
<Button className="my-extra-styles" style={{ marginTop: 8 }}>Don't fight users who want to customize. Accept className, style, and spread the rest of the props through.
2. Accessibility
A reusable component must be accessible — users will trust it to handle a11y for them.
Baseline:
- Semantic HTML where it fits (
<button>, not<div onClick>). - Keyboard support: Tab, Enter, Space, Escape, Arrows.
- Focus management: trap focus in modals, return focus on close.
- ARIA where the semantics aren't expressible in HTML (
aria-expanded,aria-controls,role="dialog"). aria-labelon icon-only buttons.- Contrast: text passes WCAG AA in all theme variants.
prefers-reduced-motionrespected for animations.
Look at Radix UI, React Aria (Adobe), or shadcn/ui for proven baseline implementations. Rolling a11y from scratch means re-discovering decades of edge cases.
3. Theming
- CSS variables for colors / spacing / radius / shadows.
- Variants exposed as props (
<Button variant="primary" size="md">). - Don't bake brand colors into the component; consume from tokens.
- Support light/dark via
data-themeattribute or class on root.
4. Performance
- Avoid expensive work in render — memoize derived data.
- Wrap with
React.memoif the component is rendered many times with stable props. - For lists, expose stable keys.
- Don't recreate inline object/array literals in JSX — hoist or memoize.
- Avoid sync layout reads (
getBoundingClientRect) during render; use refs + effects. - Lazy-load heavy children behind
<Suspense>if part of a bigger component.
5. Imperative ref
Some components legitimately need imperative APIs (textarea.focus(), form.reset(), modal.close()):
const Modal = forwardRef<{ open: () => void; close: () => void }, Props>(
function Modal(props, ref) {
const [open, setOpen] = useState(false);
useImperativeHandle(ref, () => ({
open: () => setOpen(true),
close: () => setOpen(false),
}));
// …
}
);Use sparingly — declarative props are usually better.
6. Documentation
- Storybook with every variant + state.
- a11y addon to catch contrast/aria issues.
- Interaction tests (Storybook + Vitest) for behavior.
- JSDoc on the public API.
- Migration notes when breaking changes ship.
7. Testing
- Unit: behavior in isolation (onClick fires, controlled value reflects).
- Visual regression: Chromatic / Percy on Storybook to catch unintended visual changes.
- A11y: axe-core in tests.
- Integration: in real consumer flows.
8. Versioning
- Semver carefully — breaking prop changes = major bump.
- Deprecate before removing (warn in dev, keep working for one major).
- Changelog with migration notes.
9. Example: a real reusable Modal
type Props = {
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
children: React.ReactNode;
ariaLabel: string;
};
export function Modal({ open, defaultOpen = false, onOpenChange, children, ariaLabel }: Props) {
const [internal, setInternal] = useState(defaultOpen);
const isControlled = open !== undefined;
const isOpen = isControlled ? open : internal;
const setOpen = (v: boolean) => {
if (!isControlled) setInternal(v);
onOpenChange?.(v);
};
// focus trap, Escape to close, return focus on close
// (using a battle-tested lib like @radix-ui/react-dialog is the right call)
return (
<Dialog.Root open={isOpen} onOpenChange={setOpen}>
<Dialog.Portal>
<Dialog.Overlay className="…" />
<Dialog.Content aria-label={ariaLabel} className="…">
{children}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}Pitfalls
- API balloons into 30 props — composition is the cure.
- Hard-coded styles → can't theme.
- Missing a11y → users add their own (badly).
- Always controlled — forces consumers to wire state for trivial usage.
- Never controlled — can't escalate when needed.
- Re-rendering on parent updates because of new prop references.
Mental model
A reusable component is a contract. Design the API first, bake in a11y and theming, make the common case easy and the complex case possible. Use a primitives lib (Radix, React Aria) under the hood unless you have a strong reason not to.
Follow-up questions
- •How do you decide between controlled and uncontrolled mode?
- •What's the asChild pattern and when is it useful?
- •How do you handle theming for a design-system component?
- •How do you test accessibility?
Common mistakes
- •Massive prop API — composition is the fix.
- •Hard-coded colors/sizes — can't theme.
- •Missing keyboard support — fails for non-mouse users.
- •Forcing controlled mode — adds boilerplate for trivial use.
- •No memoization on a heavily-rendered component.
- •Building from scratch when Radix/React Aria already solved it.
Performance considerations
- •A reusable component renders many times across an app. Inefficiencies compound. Memoize where it matters, avoid inline objects, expose stable APIs. But the bigger perf win is usually elsewhere (bundle size, network) — don't over-optimize the component layer.
Edge cases
- •SSR: components that depend on window need careful gating.
- •RTL languages: layout direction must be respected.
- •Reduced motion: skip animations.
- •Nested portals (modal in modal) need careful focus management.
- •Form integration: components that wrap inputs need to forward refs and event handlers correctly.
Real-world examples
- •Radix UI — unstyled accessible primitives, the gold standard.
- •React Aria (Adobe) — hooks for a11y behavior, framework-agnostic.
- •shadcn/ui — copy-paste Radix + Tailwind components.
- •Material UI, Chakra UI, Mantine — fully styled component libraries.