TypeScript in React projects divides developers. Some find it invaluable. Others find it a constant obstacle. The difference is usually whether you are using the patterns that actually help or fighting the type system unnecessarily. This guide covers the former.
Typing Component Props
The first decision is interface vs type for props. In practice, both work for component props. The conventional choice is interface for object shapes that might be extended, and type for unions, intersections, and complex compositions.
interface ButtonProps {
label: string;
onClick: () => void;
variant?: "primary" | "secondary";
disabled?: boolean;
}
function Button({ label, onClick, variant = "primary", disabled = false }: ButtonProps) {
return (
<button onClick={onClick} disabled={disabled} className={`btn-${variant}`}>
{label}
</button>
);
}
Optional props with ? are fine. Provide default values in destructuring, not with null checks.
Typing useState
The generic parameter on useState is often optional because TypeScript infers it from the initial value. When the initial value is not representative of all possible values, provide the type explicitly:
// TypeScript infers string -- no annotation needed
const [name, setName] = useState("John");
// Infer would give null, but the state will later hold a User
const [user, setUser] = useState<User | null>(null);
// Union type needed -- inference would give string[]
const [status, setStatus] = useState<"idle" | "loading" | "error">("idle");
Typing Event Handlers
Event handlers have specific types that avoid any:
// Input change event
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
setValue(e.target.value);
}
// Button click event
function handleClick(e: React.MouseEvent<HTMLButtonElement>) {
e.preventDefault();
}
// Form submit event
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
// handle submit
}
The pattern is React.ChangeEvent<HTMLElement>, React.MouseEvent<HTMLElement>, React.FormEvent<HTMLElement>. If you do not need the event object, just use () => void for the handler type.
Typing Refs
useRef needs the element type when used for DOM access:
const inputRef = useRef<HTMLInputElement>(null);
// Later:
inputRef.current?.focus();
The null initial value is correct for DOM refs. TypeScript knows current may be null before the element mounts, which is why the optional chaining (?.) is needed.
For mutable refs (storing values without triggering re-render), use a non-null initial value and omit the generic:
const countRef = useRef(0); // Inferred as MutableRefObject<number>
Typing Context
Context is where many developers reach for any. The correct pattern:
interface ThemeContextValue {
theme: "light" | "dark";
toggleTheme: () => void;
}
const ThemeContext = React.createContext<ThemeContextValue | null>(null);
function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useTheme must be used within ThemeProvider");
return ctx; // Now typed as ThemeContextValue, not ThemeContextValue | null
}
The null default with a throwing hook is the safest pattern. It catches missing provider usage at runtime with a clear error.
Typing Custom Hooks
Always provide an explicit return type for custom hooks. It serves as documentation and catches implementation mistakes:
interface UseCounterReturn {
count: number;
increment: () => void;
decrement: () => void;
reset: () => void;
}
function useCounter(initial = 0): UseCounterReturn {
const [count, setCount] = useState(initial);
return {
count,
increment: () => setCount(c => c + 1),
decrement: () => setCount(c => c - 1),
reset: () => setCount(initial),
};
}
Typing Children
ReactNode is the correct type for children in almost all cases. It accepts strings, numbers, elements, arrays, fragments, null, and undefined.
interface CardProps {
children: React.ReactNode;
title: string;
}
ReactElement is more restrictive (only actual JSX elements, not strings or null). Use it when you specifically need to call React.cloneElement on children or inspect their type.
Common Struggles: Polymorphic Components
Typing a component that renders as different HTML elements is genuinely tricky:
type TextProps<T extends React.ElementType = "p"> = {
as?: T;
children: React.ReactNode;
} & React.ComponentPropsWithoutRef<T>;
function Text<T extends React.ElementType = "p">({
as,
children,
...props
}: TextProps<T>) {
const Component = as ?? "p";
return <Component {...props}>{children}</Component>;
}
This is one of the harder TypeScript patterns in React. If you do not need full polymorphism, a union of specific elements is simpler.
Typing Forwarded Refs
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
function Input({ className, ...props }, ref) {
return <input ref={ref} className={className} {...props} />;
}
);
The two generics are: the element type the ref attaches to, and the props the component accepts.
When TypeScript Gets in the Way
TypeScript is a tool. Like any tool, using it for the wrong job creates friction.
For small scripts, utility files, and one-off data transformations, strict TypeScript can slow you down without meaningful benefit. Use as unknown as T sparingly when you know more than the type system does. Use @ts-ignore for genuine edge cases with third-party libraries that have wrong types. Use @ts-expect-error when you expect TypeScript to complain and want to be notified if it stops.
These are escape hatches, not normal usage. If you find yourself using them frequently, the types are wrong, not TypeScript.
The Strict Mode Argument
strict: true in tsconfig.json enables a collection of stricter checks: strictNullChecks, noImplicitAny, strictFunctionTypes, and others.
Enabling strict mode on an existing large codebase is painful. Enabling it at project start costs almost nothing. A component written in strict mode is not meaningfully harder to write than one in loose mode, once you know the patterns. But retrofitting strict mode onto 50,000 lines of code takes days.
Enable strict: true when you create the project. Do not defer it.
Keep Reading
- React State Management in 2026: Which Approach Actually Makes Sense -- typed state management patterns
- Next.js API Routes Best Practices: Patterns for Production APIs -- TypeScript in API route handlers
- React Server Components: What They Are and When to Use Them -- typing Server Components
Pristren builds AI-powered software for teams. Zlyqor is our all-in-one workspace -- chat, projects, time tracking, AI meeting summaries, and invoicing -- in one tool. Try it free.