skill · sumi-react · v0.6.1
React components (React 19)
React 19 component design - composition over configuration, hooks rules, when NOT to use useEffect, useActionState/useOptimistic/useFormStatus/use(), ref as a prop, React Compiler implications (no manual memo spam), state colocation, controlled vs uncontrolled forms, accessible headless primitives (Radix, React Aria) vs hand-rolled widgets, typed props. Use when writing or reviewing *.tsx/*.jsx components, custom hooks (use*.ts), forms, context providers, component APIs or design-system primitives in components/**, or when refactoring large components or effect-heavy code.
- Reference
- sumi-react
- skill
- react-components
- source
- plugins/sumi-react/skills/react-components/SKILL.md
Target: React 19.2, TypeScript strict, React Compiler enabled where possible. Styling via Tailwind with tokens from DESIGN.md (sumi:css-architecture). Effect decision table and refactors: references/effects.md.
Principles
- A component does one job. If you need "and" to describe it, split it. ~150 lines is a review trigger, not a target.
- Compose, don't configure. Prefer
childrenand slots over boolean/prop explosions. - Derive, don't sync. Anything computable from props/state is computed during render.
- State lives as low as possible, as high as necessary. Lift only to the nearest common owner; prefer URL state for shareable UI state (filters, tabs, pagination).
- Semantics first. Native elements before ARIA; proven headless primitives before hand-rolled widgets (
sumi:a11y). - Let the compiler optimize. Write plain code; profile before memoizing.
Composition over configuration
DON'T:
<Card title="Plan" subtitle="Pro" showIcon iconPosition="left" footerButtons={[...]} variant="highlight" />
DO:
<Card tone="highlight">
<Card.Header>
<Card.Title>Plan</Card.Title>
<Card.Description>Pro</Card.Description>
</Card.Header>
<Card.Footer><Button>Upgrade</Button></Card.Footer>
</Card>
- Variants via a typed map (
cvaor a plainRecord<Variant, string>), not ad-hoc ternaries inclassName. Merge withcn()(clsx + tailwind-merge). - Accept
classNameand spread remaining native props on the root element so consumers can extend. - Type props from the element:
ComponentProps<'button'> & { tone?: Tone }. Noany, noReact.FC(sumi:js-ts). - Polymorphism: prefer an
asChildslot (RadixSlot) over genericasprops with complex typing.
Refs (React 19)
ref is a regular prop for function components. Do not use forwardRef in new code.
export function Input({ ref, className, ...props }: ComponentProps<'input'>) {
return <input ref={ref} className={cn('input', className)} {...props} />;
}
Ref callbacks may return a cleanup function. Use refs for DOM access and imperative escape hatches, not for state that renders.
Effects: you probably don't need one
useEffect is for synchronizing with an external system (DOM APIs, subscriptions, non-React widgets, network connections). Not for:
| Instead of an effect to... | Do this |
|---|---|
| Derive state from props/state | Compute in render (const total = items.reduce(...)) |
| React to a user action | Put the logic in the event handler |
| Reset state when a prop changes | key={id} on the component |
| Fetch initial data | Server Component, or use(promise); TanStack Query for client-driven data |
| Notify parent of a change | Call the parent callback in the same handler |
| Subscribe to an external store | useSyncExternalStore |
| Read latest props inside an effect without re-subscribing | useEffectEvent (19.2) |
DON'T:
const [fullName, setFullName] = useState('');
useEffect(() => setFullName(`${first} ${last}`), [first, last]);
DO:
const fullName = `${first} ${last}`;
Every effect that remains: correct dependency list (no lint suppression), cleanup for subscriptions/timers/listeners, idempotent under Strict Mode double-invoke.
Hooks rules
- Call hooks at the top level, unconditionally. Exception:
use()may be called conditionally. - Custom hooks encapsulate a reusable behavior (
useMediaQuery,useDebouncedValue), not "move code out of the component". A hook that only wraps oneuseStateis noise. - Name hooks
useX; return tuples for 2 values, objects beyond that.
Forms and actions
- Default to uncontrolled inputs +
<form action={fn}>+FormData. Works before hydration, no state per keystroke. - Controlled inputs only when the UI reacts to every keystroke (live formatting, dependent fields, instant search).
useActionState(action, initial)->[state, formAction, isPending]for results and errors.useFormStatus()inside a child of the form for pending UI.useOptimisticfor instant feedback; the server result reconciles it.- Large client-side forms with complex validation: React Hook Form + zod resolver is acceptable; still submit through a Server Action.
- Every input has a visible
<label>; errors linked viaaria-describedby;aria-invalidon failure; status messages in a polite live region. Full example: thenext-data-cachingskill,references/server-actions.md(in this plugin:${CLAUDE_PLUGIN_ROOT}/skills/next-data-caching/references/server-actions.md).
use() and Suspense
use(promise)unwraps a promise created on the server (passed as a prop) or a cached one. Never create the promise during client render - it will be new every render.use(Context)replacesuseContextand can be conditional.- Pair with
<Suspense>and an error boundary at a meaningful level.
React Compiler
- With the compiler on (
reactCompiler: trueinnext.config, verify flag location for your version), components and hooks are memoized automatically. - Do not add
useMemo,useCallbackormemoby reflex. Keep them only where the profiler proves a cost, or where referential identity is a contract (e.g. effect deps of a third-party hook) - comment why. - The compiler requires the Rules of React: no mutating props/state, no reading refs during render, pure render. Run
eslint-plugin-react-hooks(recommended config includes compiler rules) and fix, do not suppress. 'use no memo'opts a component out temporarily; leave a TODO with the reason.
State and context
- Server state is not client state: data from the server stays in Server Components or a query cache, not copied into
useState. - Context is for low-frequency, widely needed values (theme, session, locale). Split contexts by update frequency. Do not use one global context as a store.
- Prop drilling 2 levels is fine. Beyond that: composition (pass the rendered element as
children) before context; a store (Zustand) only for genuinely shared, high-frequency client state. useReducerwhen state transitions are related and named; a discriminated union for status (idle | loading | success | error) instead of multiple booleans.
Accessible primitives
- Use Radix UI, React Aria Components or Base UI for dialog, popover, menu, combobox, tabs, tooltip, select, slider. Style them with tokens.
- Hand-roll only trivial widgets (disclosure via
<details>/button +aria-expanded). Never adivwithonClick: use<button>; links navigate, buttons act. - Verify: focus trap and return in dialogs, Escape closes, roving focus in menus/tabs, visible focus ring (2.4.7/2.4.11), target size >= 24px (2.5.8). See
sumi:a11y.
Lists and keys
- Keys are stable IDs from data, never array index for reorderable/filterable lists, never
Math.random(). - Empty, loading and error states are part of the component contract, not afterthoughts.
AI slop tells
useEffect+setStateto derive values or "sync" props into state.useMemo/useCallbackaround every value and handler;memo()on every component.forwardRefin new React 19 code;React.FC<Props>;props: any.- 400-line components mixing fetching, formatting, layout and modals.
- Ten boolean props (
isPrimary,isLarge,hasIcon...) instead of variants/composition. <div onClick>buttons, placeholder-as-label, icon buttons without accessible names.isLoading,isError,isSuccessbooleans that can contradict each other.- Hard-coded colors/spacing instead of
DESIGN.mdtokens. // eslint-disable-next-line react-hooks/exhaustive-deps.
Done checklist
- No effect that derives state, handles an event, or fetches initial data.
- No unjustified
useMemo/useCallback/memo; lint clean with hooks/compiler rules. - Props typed from native elements; no
any;classNameand rest props forwarded. - Interactive widgets built on native elements or a headless primitive; keyboard and screen-reader checked.
- Forms: labels, linked errors, pending state, live status; work without JS where feasible.
- State colocated; URL state for shareable UI; no copied server state.
- Styles use tokens; no magic values.
Effects: decision guide and refactors
Ask in order. Stop at the first yes.
- Can it be computed from current props/state? -> compute during render.
- Is it caused by a specific user interaction? -> event handler.
- Should state reset when an identity changes? ->
key. - Is it data for rendering? -> Server Component /
use()/ query library. - Is it subscribing to something outside React that changes over time? ->
useSyncExternalStore. - Is it synchronizing with an external system for as long as the component is mounted? ->
useEffectwith cleanup.
Refactor 1: derived filtering
// Before
const [visible, setVisible] = useState<Todo[]>([]);
useEffect(() => { setVisible(todos.filter(t => t.status === filter)); }, [todos, filter]);
// After (compiler memoizes if needed)
const visible = todos.filter(t => t.status === filter);
Refactor 2: logic triggered by an action
// Before: effect watches state set by a click
useEffect(() => { if (submitted) { toast('Saved'); track('save'); } }, [submitted]);
// After
async function handleSave() {
await save();
toast('Saved');
track('save');
}
Refactor 3: reset on identity change
// Before
useEffect(() => { setDraft(''); }, [threadId]);
// After: parent remounts the editor per thread
<CommentEditor key={threadId} threadId={threadId} />
Refactor 4: adjusting state during render (rare)
When part of state must respond to a prop change and key is too coarse, store the previous prop and adjust during render. Prefer restructuring first.
const [prevItems, setPrevItems] = useState(items);
const [selection, setSelection] = useState<string | null>(null);
if (items !== prevItems) {
setPrevItems(items);
setSelection(null);
}
Refactor 5: external store
function useOnlineStatus() {
return useSyncExternalStore(
(cb) => {
window.addEventListener('online', cb);
window.addEventListener('offline', cb);
return () => {
window.removeEventListener('online', cb);
window.removeEventListener('offline', cb);
};
},
() => navigator.onLine,
() => true, // server snapshot
);
}
Refactor 6: latest value in an effect without re-subscribing (React 19.2)
const onMessage = useEffectEvent((msg: Message) => {
if (!muted) notify(msg); // always reads current `muted`
});
useEffect(() => {
const conn = connect(roomId);
conn.on('message', (msg) => onMessage(msg));
return () => conn.disconnect();
}, [roomId]); // muted not a dependency
Verify useEffectEvent is exported as stable in the installed React version; do not call it outside effects.
Legitimate effects (keep, with cleanup)
IntersectionObserver,ResizeObserver,matchMedialisteners (oruseSyncExternalStore).- Integrating non-React widgets (maps, charts, video players): create in effect, destroy in cleanup.
- Firestore
onSnapshotsubscriptions in client leaves: return the unsubscribe. - Document-level keyboard shortcuts.
- Focus management after a state change that is not tied to a single handler (prefer handler or ref callback first).
Strict Mode
Dev double-invokes effects (mount -> cleanup -> mount). If that breaks behavior, the cleanup is wrong; do not disable Strict Mode. For one-time app init, use a module-level guard, not an effect.