Good TypeScript in a React codebase is less about clever types and more about a handful of patterns used consistently. These TypeScript React tips cover the ones I reach for on every project: typing props and children, discriminated unions for variant props, generic components, satisfies and as const, hooks, refs and events, utility types, and practical ways to keep any out of your code.
Typing props and children
Declare props with a type (or interface; both work, just be consistent) and annotate the parameter rather than using React.FC. Plain functions make generics easier and do not add implicit behaviour.
import type { ReactNode } from 'react';
type CardProps = {
title: string;
subtitle?: string;
children: ReactNode;
};
export function Card({ title, subtitle, children }: CardProps) {
return (
<section>
<h2>{title}</h2>
{subtitle && <p>{subtitle}</p>}
{children}
</section>
);
}
ReactNode is the right type for anything renderable: elements, strings, numbers, arrays, null. Use ReactElement only when you genuinely require a single element, for example because you clone it.
Extending native element props
For wrappers around HTML elements, inherit the element’s props instead of re-declaring onClick, disabled and friends:
import type { ComponentProps } from 'react';
type ButtonProps = ComponentProps<'button'> & {
variant?: 'primary' | 'secondary';
};
export function Button({ variant = 'primary', className, ...rest }: ButtonProps) {
return <button className={`btn btn-${variant} ${className ?? ''}`} {...rest} />;
}
In React 19, ref is a regular prop for function components, so ComponentProps<'button'> already includes it and forwardRef is no longer required for new code. On React 18, use ComponentPropsWithoutRef plus forwardRef.
Discriminated unions for variant props
Optional props that only make sense together are a common source of bugs. A link-style button needs href; an action button needs onClick. Model that with a union discriminated by a literal field, and TypeScript will reject invalid combinations and narrow types inside the component.
type LinkAction = { kind: 'link'; href: string; external?: boolean };
type ButtonAction = { kind: 'button'; onClick: () => void };
type ActionProps = (LinkAction | ButtonAction) & { label: string };
export function Action(props: ActionProps) {
if (props.kind === 'link') {
// props.href is available and typed here
return (
<a href={props.href} target={props.external ? '_blank' : undefined}>
{props.label}
</a>
);
}
// TypeScript knows this is ButtonAction
return <button onClick={props.onClick}>{props.label}</button>;
}
// <Action kind="link" onClick={...} label="x" /> is a compile error
Avoid destructuring the union in the parameter list; narrowing works on the props object, and destructured variables lose the link to the discriminant in many cases. The same pattern is excellent for async state: { status: 'loading' } | { status: 'error'; error: Error } | { status: 'success'; data: T } makes “data while loading” impossible to represent.
Generic components
When a component works with any item type, such as a list, select or table, make it generic so callbacks receive the real type instead of unknown or any:
import type { ReactNode } from 'react';
type ListProps<T> = {
items: T[];
getKey: (item: T) => string | number;
renderItem: (item: T) => ReactNode;
};
export function List<T>({ items, getKey, renderItem }: ListProps<T>) {
return (
<ul>
{items.map((item) => (
<li key={getKey(item)}>{renderItem(item)}</li>
))}
</ul>
);
}
// T is inferred as User; user.email autocompletes
// <List items={users} getKey={(u) => u.id} renderItem={(u) => u.email} />
If you write a generic arrow function in a .tsx file, use <T,> so the parser does not read <T> as a JSX tag. Add a constraint such as T extends { id: string } when the component relies on a property.
satisfies and as const for configuration objects
as const makes a value deeply readonly and keeps literal types. satisfies checks a value against a type without widening it. Together they give you validated config that still has precise types:
type Route = { path: string; label: string; auth?: boolean };
export const routes = {
home: { path: '/', label: 'Home' },
dashboard: { path: '/dashboard', label: 'Dashboard', auth: true },
} as const satisfies Record<string, Route>;
type RouteName = keyof typeof routes; // 'home' | 'dashboard'
routes.dashboard.path; // type is '/dashboard', not string
export const SIZES = ['sm', 'md', 'lg'] as const;
export type Size = (typeof SIZES)[number]; // 'sm' | 'md' | 'lg'
A type annotation (const routes: Record<string, Route>) would have lost both the key names and literal values. The array-to-union trick is handy when you need the values at runtime, for a select or validation, and as a type.
Typing hooks, refs and events
useState
Inference handles primitives. Supply a type when the initial value does not describe every future value:
const [user, setUser] = useState<User | null>(null);
const [tags, setTags] = useState<string[]>([]);
useRef
For DOM refs, pass the element type and null. With React 19 types, useRef requires an argument and returns a mutable RefObject typed as HTMLInputElement | null:
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
// For mutable values that are not DOM nodes:
const timerRef = useRef<number | undefined>(undefined);
Events
Inline handlers infer their event type. When you extract a handler, use React’s event types with the element as the generic:
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
setQuery(e.target.value);
}
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const data = new FormData(e.currentTarget);
}
function handleKey(e: React.KeyboardEvent<HTMLDivElement>) {
if (e.key === 'Escape') close();
}
A quick way to discover the right type is to write the handler inline, hover the parameter in your editor, and copy what it shows.
Custom hooks returning tuples
Return tuples with as const, otherwise TypeScript infers an array of a union type and destructuring loses precision:
export function useToggle(initial = false) {
const [on, setOn] = useState(initial);
const toggle = useCallback(() => setOn((v) => !v), []);
return [on, toggle] as const; // readonly [boolean, () => void]
}
Utility types worth knowing
A small set of built-in utility types covers most React needs:
Partial<T>andRequired<T>for patch objects and normalised defaults.Pick<T, K>andOmit<T, K>to derive props from a model or another component, for exampleOmit<ComponentProps<'input'>, 'size'>when you want your ownsizeprop.ComponentProps<typeof MyComponent>to reuse a component’s props without exporting its type.ReturnType<typeof fn>andAwaited<T>to type data from a fetcher without duplicating the shape.Record<K, V>for lookup maps keyed by a union.
async function getProduct(id: string) {
const res = await fetch(`/api/products/${id}`);
return (await res.json()) as { id: string; name: string; price: number };
}
type Product = Awaited<ReturnType<typeof getProduct>>;
type ProductPreview = Pick<Product, 'id' | 'name'>;
Avoiding any without fighting the compiler
any switches type checking off and spreads silently through everything it touches. Practical alternatives:
- Use
unknownfor untrusted data and narrow it. API responses,JSON.parseandcatch (err)values are allunknownin spirit. - Validate at the boundary. A runtime schema with Zod or Valibot gives you a parsed value and its inferred type in one step, so the type is actually true.
- Turn on strict mode, including
strictNullChecks; considernoUncheckedIndexedAccesssoarr[i]is typed as possibly undefined. - Lint it.
@typescript-eslint/no-explicit-anyand theno-unsafe-*rules flag newanyusage.
import { z } from 'zod';
const ProductSchema = z.object({ id: z.string(), name: z.string(), price: z.number() });
type Product = z.infer<typeof ProductSchema>;
export async function loadProduct(id: string): Promise<Product> {
const res = await fetch(`/api/products/${id}`);
const json: unknown = await res.json();
return ProductSchema.parse(json); // throws if the API shape changed
}
Strict null checks catch an entire category of runtime crashes; my guide to fixing “Cannot read properties of undefined” shows how many of those errors TypeScript can prevent outright.
Key takeaways
- Type props with plain functions and
ReactNodefor children; extend native props withComponentProps<'tag'>. - Use discriminated unions so invalid prop combinations fail to compile.
- Make reusable list and table components generic so callbacks get real types.
- Combine
as constandsatisfiesfor config that is both checked and precise. - Replace
anywithunknownplus runtime validation, and keep strict mode on.
Frequently asked questions
Should I use React.FC to type components?
It is not required and most teams now prefer typing the props parameter directly. Plain functions work better with generics and make the props type explicit.
What type should children have in React with TypeScript?
Use ReactNode, which accepts elements, strings, numbers, fragments, arrays and null. Only use ReactElement if the component must receive exactly one element.
What is the difference between satisfies and a type annotation?
A type annotation widens the value to the declared type, while satisfies checks compatibility but keeps the narrower inferred type, including literal values and exact keys.
Do I still need forwardRef in React 19?
Not for new function components, because React 19 passes ref as a normal prop. forwardRef still works and is needed if you support React 18.
Need help tightening the types in a growing React or Next.js codebase? Contact me to talk about a review or a refactor.