Controlled & Uncontrolled
The value/defaultValue/onChange contract, dual-mode APIs, and mirroring native HTML form patterns.
interface ToggleProps {
toggled: boolean;
setToggled: (value: boolean) => void;
}interface ToggleProps {
checked: boolean;
onChange: (checked: boolean) => void;
}Custom toggle components should mirror the native <input type="checkbox"> API: checked + onChange. Consumers familiar with HTML form elements instantly understand the contract. setToggled implies the child owns the state; in React, data flows down and events flow up.
interface DatePickerProps {
/** The initially selected date. */
initialDate?: Date;
/** Called when the user picks a date. */
onDateChange: (date: Date) => void;
}interface DatePickerProps {
/** The initial date for uncontrolled mode. */
defaultValue?: Date;
/** Called when the user picks a date. */
onChange: (date: Date) => void;
}React's convention for uncontrolled initial values is defaultValue (or defaultChecked for checkboxes). initialDate invents a custom name. Using defaultValue + onChange makes any form library and React developer immediately understand this is an uncontrolled component.
interface ProfileEditorProps {
/** Used to detect user changes. */
userId: string;
user: User;
onSave: (updates: Partial<User>) => void;
}
// Resets draft state when user changes:
// useEffect(() => { setDraft(user); }, [userId]);interface ProfileEditorProps {
user: User;
onSave: (updates: Partial<User>) => void;
}
// Key change remounts with fresh state:
// <ProfileEditor
// key={user.id} user={user} onSave={...}
// />The bad version passes userId separately from user just to use it as a useEffect dependency for resetting internal draft state. But user.id already carries that information.
Using key={user.id} at the call site eliminates the redundant prop and the useEffect entirely. When the key changes, React remounts the component with clean state. This is the pattern the React docs recommend over syncing state in effects.
interface AccordionProps {
children: React.ReactNode;
/** Which panel is expanded. */
expanded?: number;
/** Called when a panel is toggled. */
onToggle?: (index: number) => void;
/** Initially expanded panel. */
startExpanded?: number;
}interface AccordionProps {
children: React.ReactNode;
/** Controlled: which panel is expanded. */
value?: number;
/** Called when the expanded panel changes. */
onChange?: (value: number) => void;
/** Uncontrolled: initially expanded panel. */
defaultValue?: number;
}The value / defaultValue / onChange trio is React's universal contract for dual-mode (controlled + uncontrolled) components. expanded + startExpanded + onToggle invents custom names for the same concept. Consumers immediately know: pass value + onChange for controlled, or defaultValue for uncontrolled.
interface TagInputProps {
/** Current tags. */
selectedTags: string[];
/** Called to add a tag. */
onAddTag: (tag: string) => void;
/** Called to remove a tag. */
onRemoveTag: (tag: string) => void;
placeholder?: string;
}interface TagInputProps {
/** Controlled: the current list of tags. */
value: string[];
/** Called with the full updated tag list. */
onChange: (tags: string[]) => void;
placeholder?: string;
}Following the value/onChange convention, the component reports the entire new state, not individual operations. The parent decides how to update: onChange receives the full array after any add or remove. This matches how <select multiple> works natively.
Tradeoff: if the parent needs to know the operation type (e.g., show "Tag added" vs "Tag removed" toasts), supplementary callbacks like onAdd/onRemove alongside value/onChange can be useful.
interface ReplyFormProps {
/** Current conversation. */
conversationId: string;
/** Called when the reply is sent. */
onSend: (message: string) => void;
/** Increment to clear the draft. */
resetTrigger?: number;
}
// Internally resets via useEffect:
// useEffect(() => { clearDraft(); }, [resetTrigger]);interface ReplyFormProps {
/** Called when the reply is sent. */
onSend: (message: string) => void;
}
// Parent resets by changing the key:
// <ReplyForm
// key={conversationId} onSend={handleSend}
// />When the user switches conversations, the reply draft should reset. React's key prop handles this: when key changes, React unmounts and remounts the component with fresh state. No extra props needed.
The bad version threads conversationId through as a prop the component doesn't need for rendering, plus a resetTrigger counter. key eliminates both from the API.
interface ColorPickerProps {
/** The current color. */
color?: string;
/** Called when a color is selected. */
onColorChange?: (color: string) => void;
/** Initial color before interaction. */
initialColor?: string;
/** Whether the picker popup is visible. */
isVisible?: boolean;
/** Called when visibility changes. */
onVisibilityChange?: (visible: boolean) => void;
/** Whether popup starts visible. */
initiallyVisible?: boolean;
}interface ColorPickerProps {
/** Controlled: the selected color. */
value?: string;
/** Called when the selected color changes. */
onChange?: (color: string) => void;
/** Uncontrolled: initial selected color. */
defaultValue?: string;
/** Controlled: whether the popup is open. */
open?: boolean;
/** Called when popup open state changes. */
onOpenChange?: (open: boolean) => void;
/** Uncontrolled: initial popup open state. */
defaultOpen?: boolean;
}Each controlled dimension follows the same convention: value/defaultValue/onChange for the color, open/defaultOpen/onOpenChange for the popup. Once a developer learns the pattern, every dimension is predictable.
The alternative uses natural-sounding names (color, initialColor, isVisible), but each dimension uses its own convention. initialColor could mean a default, a fallback, or a reset target. isVisible reads as a status report rather than a controllable prop.