Typing React components
Props, children, events, state, refs and context in TypeScript, with the patterns that keep React types accurate and readable.
React and TypeScript fit well together, as long as you type components the way they're actually used. This guide covers the patterns you'll reach for most when converting .jsx files to .tsx.
Props
Describe props with a type or interface, and annotate the parameter:
type ButtonProps = {
label: string;
variant?: "primary" | "secondary";
disabled?: boolean;
onClick: () => void;
};
export function Button({ label, variant = "primary", disabled = false, onClick }: ButtonProps) {
return (
<button className={variant} disabled={disabled} onClick={onClick}>
{label}
</button>
);
}Prefer string literal unions like "primary" | "secondary" over plain string for props with a known set of values. Callers get autocomplete and typos become errors.
Children
Use React.ReactNode for anything renderable:
type CardProps = {
title: string;
children: React.ReactNode;
};If a component should accept exactly one element, use React.ReactElement instead.
Extending native elements
A component that wraps an HTML element should accept that element's props:
type InputProps = React.ComponentProps<"input"> & {
label: string;
};
export function Input({ label, ...rest }: InputProps) {
return (
<label>
{label}
<input {...rest} />
</label>
);
}Now type, placeholder, onChange and every other input attribute are typed for free.
Events
Type handlers with React's event types:
function SearchBox() {
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
console.log(e.target.value);
};
const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
};
return (
<form onSubmit={onSubmit}>
<input onChange={onChange} />
</form>
);
}When you pass a handler inline, TypeScript infers the event type and you don't need to annotate it.
State
useState infers from the initial value. Add a type argument when the initial value doesn't tell the whole story:
const [count, setCount] = useState(0); // number, inferred
const [user, setUser] = useState<User | null>(null); // starts empty
const [status, setStatus] = useState<"idle" | "loading" | "done">("idle");Refs
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
}, []);In React 19, function components receive ref as a regular prop, so you can type it in your props:
type FieldProps = React.ComponentProps<"input"> & { ref?: React.Ref<HTMLInputElement> };On earlier versions, use forwardRef<HTMLInputElement, FieldProps>.
Context
Give context a type and handle the "no provider" case explicitly:
type Theme = "light" | "dark";
const ThemeContext = createContext<Theme | undefined>(undefined);
export function useTheme(): Theme {
const theme = useContext(ThemeContext);
if (!theme) throw new Error("useTheme must be used inside ThemeProvider");
return theme;
}Custom hooks
Let return types be inferred for simple hooks, and annotate them for hooks other people depend on:
export function useToggle(initial = false): [boolean, () => void] {
const [on, setOn] = useState(initial);
return [on, () => setOn((v) => !v)];
}Converting an existing app
Migrate leaf components first: buttons, inputs, icons and small presentational pieces. Then the components that render them, then pages, then your app shell. Each component's props are then inferred from how it's really rendered by components that are already typed. That's the order CodeMorph plans from your imports.
Common pitfalls
React.FC: optional. Annotating props directly, as above, is simpler and avoids surprises withchildren.anyin event handlers: use the specific event type, or let TypeScript infer it inline.- Optional props used as required: if a prop can be missing, give it a default or handle
undefined. - Spreading unknown props onto DOM elements: extend
React.ComponentProps<"element">so only valid attributes get through.