On this page · sections
Component Composition & APIs
React composition patterns let you build flexible, reusable UIs: compound components share implicit state, controlled/uncontrolled modes define source of truth, polymorphic components swap underlying elements, and children-as-props isolates heavy subtrees from hot parent state.
| Pattern | Core idea | Best for |
|---|---|---|
| Compound components | Shared state via Context between siblings | Design systems, tabs, accordions |
| Controlled / uncontrolled | React state vs DOM as source of truth | Forms, inputs, widgets |
Polymorphic as / asChild | One implementation, swappable renderer | Buttons, links, primitives |
| Children-as-props | Stable element identity skips reconcile | Dashboard shells, layout slots |
Compound Components
Definition
A family of components that share implicit state through Context — the consumer controls structure and styling while the root coordinates behavior.
'use client';
import { createContext, useContext, useId, useMemo, useState, type ReactNode } from 'react';
type TabsContextValue = { active: string; setActive: (id: string) => void; baseId: string };
const TabsContext = createContext<TabsContextValue | null>(null);
function useTabsContext() {
const ctx = useContext(TabsContext);
if (!ctx) throw new Error('Tabs.* must be used within <Tabs>');
return ctx;
}
function Tabs({
defaultValue = 'tab-1',
value,
onValueChange,
children,
}: {
defaultValue?: string;
value?: string;
onValueChange?: (value: string) => void;
children: ReactNode;
}) {
const baseId = useId();
const [internal, setInternal] = useState(defaultValue);
const active = value ?? internal;
const setActive = (id: string) => {
if (value === undefined) setInternal(id);
onValueChange?.(id);
};
const ctx = useMemo(() => ({ active, setActive, baseId }), [active, baseId]);
return <TabsContext.Provider value={ctx}>{children}</TabsContext.Provider>;
}
function TabsTrigger({ value, children }: { value: string; children: ReactNode }) {
const { active, setActive, baseId } = useTabsContext();
const selected = active === value;
return (
<button
type="button"
role="tab"
id={`${baseId}-trigger-${value}`}
aria-selected={selected}
aria-controls={`${baseId}-panel-${value}`}
tabIndex={selected ? 0 : -1}
onClick={() => setActive(value)}
>
{children}
</button>
);
}
Tabs.Trigger = TabsTrigger;Compound needs shared coordinated state between siblings. Export domain actions (toggle, select), not raw setState. Memoize context value to avoid unrelated re-renders.
Interview: Prefer Context over legacy
cloneElement— Context scales better and does not break ref forwarding.
Controlled vs Uncontrolled
Definition
Controlled components use value + onChange — React state is the source of truth every render. Uncontrolled components use defaultValue + ref — the DOM owns the value after mount.
'use client';
import { useRef, useState, type FormEvent } from 'react';
function ControlledName() {
const [name, setName] = useState('');
return (
<input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Controlled"
/>
);
}
function UncontrolledName() {
const inputRef = useRef<HTMLInputElement>(null);
function handleSubmit(event: FormEvent) {
event.preventDefault();
console.log(inputRef.current?.value);
}
return (
<form onSubmit={handleSubmit}>
<input ref={inputRef} defaultValue="" placeholder="Uncontrolled" />
<button type="submit">Save</button>
</form>
);
}Controlled → keystroke → onChange → setState → re-render → value prop → DOM
Uncontrolled → keystroke → browser updates DOM; read ref on submitChoose controlled for live validation, character counts, dependent fields, URL sync. Choose uncontrolled for simple submit-only forms or React Hook Form register().
Interview:
defaultValueapplies only on initial mount. To reset, remount with akeyor switch to controlled state.
Real-world example
React 19 Server Actions with useActionState:
'use client';
import { useActionState } from 'react';
type FormState = { error?: string; ok?: boolean };
async function createPost(_prev: FormState, formData: FormData): Promise<FormState> {
const title = String(formData.get('title') ?? '');
if (title.length < 3) return { error: 'Title too short' };
return { ok: true };
}
function CreatePostForm() {
const [state, formAction, pending] = useActionState(createPost, {});
return (
<form action={formAction}>
<input name="title" defaultValue="" disabled={pending} />
{state.error && <p role="alert">{state.error}</p>}
<button type="submit" disabled={pending}>Save</button>
</form>
);
}File inputs are always uncontrolled from React’s perspective — store File in state and remount with key to clear.
Polymorphic Components
Definition
A polymorphic component renders as different underlying elements via an as prop or Radix asChild + Slot — one implementation, swappable renderer.
'use client';
import {
forwardRef,
type ComponentPropsWithoutRef,
type ElementType,
} from 'react';
type ButtonProps<T extends ElementType = 'button'> = {
as?: T;
variant?: 'primary' | 'ghost';
} & ComponentPropsWithoutRef<T>;
function ButtonInner<T extends ElementType = 'button'>(
{ as, variant = 'primary', className, ...props }: ButtonProps<T>,
ref: React.Ref<Element>,
) {
const Comp = as ?? 'button';
const classes = `btn btn--${variant} ${className ?? ''}`;
return <Comp ref={ref} className={classes} {...props} />;
}
export const Button = forwardRef(ButtonInner) as <T extends ElementType = 'button'>(
props: ButtonProps<T> & { ref?: React.Ref<Element> },
) => React.ReactElement | null;Radix Slot and asChild
Merge className, event handlers, and ref onto the consumer’s single child — no wrapper DOM node:
'use client';
import { Slot } from '@radix-ui/react-slot';
import { forwardRef, type ButtonHTMLAttributes } from 'react';
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & { asChild?: boolean };
export const PrimitiveButton = forwardRef<HTMLButtonElement, ButtonProps>(
function PrimitiveButton({ asChild, className, ...props }, ref) {
const Comp = asChild ? Slot : 'button';
return <Comp ref={ref} className={`btn ${className ?? ''}`} {...props} />;
},
);
// <PrimitiveButton asChild><a href="/dashboard">Dashboard</a></PrimitiveButton>as prop → primitive picks the tag internally
asChild + Slot → consumer supplies the element; props merge onto itInterview: Next.js Link:
<Button as={Link} href="/dashboard">— Link must forward ref. Slot throws on multiple children.
Children-as-Props & Composition
Definition
Pre-built JSX passed as children from a stable scope lets React skip subtree reconciliation — structural isolation, not magic memoization.
'use client';
import { useState, type ReactNode } from 'react';
function ExpensiveList() {
return (
<ul>
{Array.from({ length: 5000 }, (_, i) => (
<li key={i}>Row {i}</li>
))}
</ul>
);
}
// ❌ Inline — new element every parent render
function BadShell() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount((c) => c + 1)}>{count}</button>
<ExpensiveList />
</div>
);
}
// ✅ Lifted element — stable reference
const stableList = <ExpensiveList />;
function GoodShell({ children }: { children: ReactNode }) {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount((c) => c + 1)}>{count}</button>
{children}
</div>
);
}Named slots
Use named ReactNode props for shells with multiple independent regions:
type PageShellProps = {
header: ReactNode;
sidebar?: ReactNode;
children: ReactNode;
};
function PageShell({ header, sidebar, children }: PageShellProps) {
return (
<div className="grid">
<header>{header}</header>
{sidebar && <aside>{sidebar}</aside>}
<main>{children}</main>
</div>
);
}Pass elements, not component types — rendering <Content /> inside the shell recreates the element every parent render.
Interview: Composition moves volatile state outside the subtree. Restructure first,
React.memosecond.
Senior Interview Summary
What to say
Compound components share implicit state via Context between named siblings — export guarded hooks and controlled/uncontrolled root modes. Controlled means React owns the value; uncontrolled means the DOM does. Polymorphic components swap renderers with
asorasChild. Pre-built JSX aschildrenfrom a stable scope skips subtree reconciliation.
Common pitfalls
"Compound = any component with children" → needs shared sibling state
"defaultValue updates on prop change" → only initial mount; use key
"as='div' for buttons" → needs role, tabIndex, keyboard
"Inline JSX in hot parent" → new element every render
"Children never re-render" → wrong under hot provider or inline JSXPractical rule
Public design system primitive → compound + ARIA + asChild on triggers
Live validation / URL sync → controlled
Server Action simple mutation → uncontrolled + useActionState
Dashboard shell → stable grid children from layout + counter island
Interactive primitive → polymorphic or asChild; layout → wrapper div