Last month the new React docs went live at react.dev and TypeScript 5.0 shipped on the same day. I took that as a sign and spent a few evenings going through the component library, cleaning up types that had grown in five different styles. What came out the other end is a short list of shapes I now use for everything.
Props as a type, component as a function
// src/components/Badge.tsx
type BadgeProps = {
label: string;
tone?: "neutral" | "success" | "danger";
};
export function Badge({ label, tone = "neutral" }: BadgeProps) {
return <span className={`badge badge-${tone}`}>{label}</span>;
}
No React.FC. It used to add an implicit children to every component whether you wanted it or not, which was removed in the React 18 types a year ago. It makes generic components awkward, see below. And it adds nothing a plain function with a typed argument doesn't give you, including the return type, which TypeScript infers. The React TypeScript Cheatsheet has been saying this for a while.
Defaults go into the destructuring. The type keeps tone optional for the caller and the body sees a string.
If your codebase has React.FC on every component, consistency wins over my preference. Leave it, and if you're moving to the React 18 types, types-react-codemod fixes the children fallout for you.
Children
import type { ReactNode } from "react";
type CardProps = {
title: string;
children: ReactNode;
};
ReactNode is anything React can render: elements, strings, numbers, null, arrays of those. ReactElement is a single element, which you only want when you'll read its props or clone it. PropsWithChildren<CardProps> is the shortcut, I prefer seeing children in the type.
Extending a native element
Most of the components in a library are a styled button, input or a. They should accept everything the real element accepts.
// src/components/Button.tsx
import type { ComponentPropsWithoutRef } from "react";
type ButtonProps = ComponentPropsWithoutRef<"button"> & {
variant?: "primary" | "ghost";
};
export function Button({
variant = "primary",
type = "button",
className,
...rest
}: ButtonProps) {
return (
<button
type={type}
className={`btn btn-${variant} ${className ?? ""}`}
{...rest}
/>
);
}
That gives you onClick, disabled, aria-label and the rest without listing them. The type = 'button' default is a bonus: a native button inside a form submits it by default, and a design system button should not.
When one of the native props has to change shape, take it out first:
type InputProps = Omit<ComponentPropsWithoutRef<"input">, "onChange"> & {
// the value, not the event, because that's what every caller wanted anyway
onChange: (value: string) => void;
};
forwardRef
Form libraries and focus management want a ref to the real element, so inputs get forwardRef:
// src/components/Input.tsx
import { forwardRef, type ComponentPropsWithoutRef } from "react";
type InputProps = Omit<ComponentPropsWithoutRef<"input">, "onChange"> & {
onChange: (value: string) => void;
};
// element type first, props second. Everyone gets this backwards once
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
{ onChange, ...rest },
ref,
) {
return (
<input
ref={ref}
onChange={(event) => onChange(event.target.value)}
{...rest}
/>
);
});
The named function inside forwardRef is there so the component shows up as Input in the dev tools instead of Anonymous.
Either this or that
Some props only make sense together. A notice that can be dismissed needs a handler, one that can't must not have one. An optional onDismiss doesn't say that. A union does:
// src/components/Notice.tsx
type NoticeProps = {
message: string;
} & (
| { dismissible: true; onDismiss: () => void }
| { dismissible?: false; onDismiss?: never }
);
export function Notice({ message, dismissible, onDismiss }: NoticeProps) {
return (
<div role="status">
{message}
{dismissible && <button onClick={onDismiss}>Dismiss</button>}
</div>
);
}
<Notice message="Saved" dismissible /> without onDismiss is a compile error, and so is onDismiss without dismissible. The error messages for unions are long and read like a stack trace, that's the price.
Generic components
A select that works for any option type is where the plain function pays off:
// src/components/Select.tsx
type SelectProps<T> = {
options: T[];
value: T | null;
onChange: (value: T) => void;
getKey: (option: T) => string;
getLabel: (option: T) => string;
};
export function Select<T>({
options,
value,
onChange,
getKey,
getLabel,
}: SelectProps<T>) {
return (
<select
value={value ? getKey(value) : ""}
onChange={(event) => {
const next = options.find(
(option) => getKey(option) === event.target.value,
);
if (next) onChange(next);
}}
>
{options.map((option) => (
<option key={getKey(option)} value={getKey(option)}>
{getLabel(option)}
</option>
))}
</select>
);
}
T is inferred from options at the call site, so onChange gets a User when you pass users and nobody writes <Select<User>. With React.FC the generic has to go somewhere else, and with an arrow function in a .tsx file you need the <T,> comma trick to keep it from being parsed as JSX. A function declaration has neither problem.
Variant maps
Variants as a const object, the type derived from it, so adding one is one edit:
// src/components/tones.ts
export const tones = {
neutral: "bg-gray-100 text-gray-800",
success: "bg-green-100 text-green-800",
danger: "bg-red-100 text-red-800",
} as const satisfies Record<string, string>;
export type Tone = keyof typeof tones;
satisfies came with TypeScript 4.9 in November. It checks the object against the shape without widening the keys to string, so Tone stays 'neutral' | 'success' | 'danger'.
Events
ChangeEvent<HTMLInputElement>, FormEvent<HTMLFormElement> and MouseEvent<HTMLButtonElement> from react cover nearly everything. My trick is to not name them: write the handler inline on the element, hover it, and if I need to pull it out, copy the type the editor shows. Inference did the work, I just moved it.
What I stopped doing
React.FC, see above.- Typing
useStatewhen the initial value says it all.useState('')is a string. OnlyuseState<User | null>(null)needs the argument, becausenullsays nothing. - Return types on components.
JSX.Elementon every function is noise, and it's wrong the moment a component returnsnull. anyon event handlers to make the red go away. It comes back as a runtime error, with interest.
So I write the types where inference can't see, the props, the empty useState, the generic, and let it work out everything downstream. I use type over interface for props, but that one's a coin toss. Pick one with your team and don't look back.
Read more here:
- React TypeScript Cheatsheets started by Shawn Wang
- Introducing react.dev by Dan Abramov and Rachel Nabors
- Announcing TypeScript 5.0 by Daniel Rosenwasser
- Removal of implicit children by Sebastian Silbermann
- types-react-codemod on GitHub