TypeScript with React
TypeScript and React are a powerful combination. TypeScript catches prop mismatches, incorrect event handlers, and missing context values at compile time rather than at runtime. This page covers the essential patterns every React developer needs — from typing basic components to advanced patterns like generic components and forwardRef.
Setting Up
The fastest way to start a TypeScript React project is with Vite or Create React App:
# Vite (recommended) npm create vite@latest my-app -- --template react-ts cd my-app && npm install && npm run dev # Next.js (full-stack) npx create-next-app@latest my-app --typescript
For an existing project, install the type definitions:
npm install --save-dev @types/react @types/react-dom
@types/react separately if you are on an older version.Function Components
The preferred way to type a function component is to define a Props interface and type the function parameter directly — no React.FC needed:
interface ButtonProps {
label: string;
onClick: () => void;
disabled?: boolean;
variant?: 'primary' | 'secondary' | 'danger';
}
function Button({ label, onClick, disabled = false, variant = 'primary' }: ButtonProps) {
return (
<button
onClick={onClick}
disabled={disabled}
className={`btn btn--${variant}`}
>
{label}
</button>
);
}
// Usage — TypeScript validates every prop
<Button label="Save" onClick={() => console.log('saved')} />
<Button label="Delete" onClick={handleDelete} variant="danger" />
<Button label="Bad" onClick="not a function" /> // ErrorWhy avoid React.FC?
React.FC (or React.FunctionComponent) adds an implicit children prop and complicates typing. Since React 18 removed the implicit children, the direct approach is both cleaner and more precise:
// Avoid — React.FC adds historical baggage
const BadButton: React.FC<ButtonProps> = ({ label }) => <button>{label}</button>;
// Prefer — explicit, readable, no implicit children
function GoodButton({ label }: ButtonProps) {
return <button>{label}</button>;
}Typing Children
Children must be typed explicitly in React 18+. Use React.ReactNode for any renderable content:
import { ReactNode } from 'react';
interface CardProps {
title: string;
children: ReactNode;
footer?: ReactNode;
}
function Card({ title, children, footer }: CardProps) {
return (
<div className="card">
<h2>{title}</h2>
<div className="card__body">{children}</div>
{footer && <div className="card__footer">{footer}</div>}
</div>
);
}
// Accepts any renderable content
<Card title="Hello">
<p>Some text</p>
<img src="/photo.jpg" alt="Photo" />
</Card>Type | Accepts |
|---|---|
ReactNode | Anything renderable: elements, strings, numbers, null, arrays |
ReactElement | Only JSX elements (not strings or null) |
ReactChild | Elements, strings, numbers (deprecated in React 18) |
JSX.Element | A single JSX element — identical to ReactElement in most cases |
Event Handler Types
React provides typed event objects for every DOM event. The pattern is React.XxxEvent<HTMLElement>:
import { ChangeEvent, FormEvent, MouseEvent, KeyboardEvent } from 'react';
function Form() {
function handleInputChange(e: ChangeEvent<HTMLInputElement>) {
console.log(e.target.value); // string
console.log(e.target.checked); // boolean (for checkboxes)
}
function handleSelectChange(e: ChangeEvent<HTMLSelectElement>) {
console.log(e.target.value); // selected option value
}
function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
const form = e.currentTarget; // HTMLFormElement
}
function handleClick(e: MouseEvent<HTMLButtonElement>) {
console.log(e.clientX, e.clientY);
}
function handleKeyDown(e: KeyboardEvent<HTMLInputElement>) {
if (e.key === 'Enter') console.log('Enter pressed');
}
return (
<form onSubmit={handleSubmit}>
<input onChange={handleInputChange} onKeyDown={handleKeyDown} />
<select onChange={handleSelectChange}>
<option value="a">A</option>
</select>
<button type="submit" onClick={handleClick}>Submit</button>
</form>
);
}useRef for DOM Elements
import { useRef, useEffect } from 'react';
function AutoFocusInput() {
// Pass the element type to useRef; start as null
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
// inputRef.current is HTMLInputElement | null
inputRef.current?.focus();
}, []);
return <input ref={inputRef} placeholder="Auto-focused" />;
}
// For mutable values (not DOM refs), omit null and pass the initial value
function Timer() {
const intervalRef = useRef<number>(0); // no null — always a number
useEffect(() => {
intervalRef.current = window.setInterval(() => {
console.log('tick');
}, 1000);
return () => clearInterval(intervalRef.current);
}, []);
return <div>Timer running</div>;
}useRef<T>(null) for DOM refs and useRef<T>(initialValue) for mutable values. The former gives T | null; the latter gives T.Generic Components
Generic components are incredibly powerful — they let you write a single component that works with any data type while preserving full type safety:
interface ListProps<T> {
items: T[];
renderItem: (item: T, index: number) => ReactNode;
keyExtractor: (item: T) => string;
}
function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
return (
<ul>
{items.map((item, i) => (
<li key={keyExtractor(item)}>{renderItem(item, i)}</li>
))}
</ul>
);
}
// Usage — T is inferred from the items array
interface User { id: number; name: string; }
<List
items={users}
keyExtractor={(u) => String(u.id)}
renderItem={(u) => <span>{u.name}</span>}
/>forwardRef with TypeScript
forwardRef requires two type parameters: the ref type and the props type:
import { forwardRef, useImperativeHandle } from 'react';
interface InputProps {
label: string;
placeholder?: string;
}
// forwardRef<RefType, PropsType>
const LabelledInput = forwardRef<HTMLInputElement, InputProps>(
function LabelledInput({ label, placeholder }, ref) {
return (
<label>
{label}
<input ref={ref} placeholder={placeholder} />
</label>
);
}
);
// Usage
function Parent() {
const inputRef = useRef<HTMLInputElement>(null);
return (
<>
<LabelledInput ref={inputRef} label="Name" placeholder="Enter name" />
<button onClick={() => inputRef.current?.focus()}>Focus</button>
</>
);
}Exposing Imperative API with useImperativeHandle
import { forwardRef, useImperativeHandle, useRef } from 'react';
interface VideoPlayerHandle {
play: () => void;
pause: () => void;
seek: (seconds: number) => void;
}
interface VideoPlayerProps {
src: string;
}
const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
function VideoPlayer({ src }, ref) {
const videoRef = useRef<HTMLVideoElement>(null);
useImperativeHandle(ref, () => ({
play: () => videoRef.current?.play(),
pause: () => videoRef.current?.pause(),
seek: (s) => { if (videoRef.current) videoRef.current.currentTime = s; },
}));
return <video ref={videoRef} src={src} />;
}
);
function App() {
const playerRef = useRef<VideoPlayerHandle>(null);
return (
<>
<VideoPlayer ref={playerRef} src="/movie.mp4" />
<button onClick={() => playerRef.current?.play()}>Play</button>
<button onClick={() => playerRef.current?.seek(30)}>Skip 30s</button>
</>
);
}Polymorphic Components
A polymorphic component renders as different HTML elements based on an as prop — fully type-safe:
import { ElementType, ComponentPropsWithoutRef } from 'react';
type PolymorphicProps<T extends ElementType> = {
as?: T;
} & ComponentPropsWithoutRef<T>;
function Text<T extends ElementType = 'p'>({
as,
children,
...rest
}: PolymorphicProps<T>) {
const Component = as ?? 'p';
return <Component {...rest}>{children}</Component>;
}
// Renders as <p>
<Text>Hello world</Text>
// Renders as <h1> — gets heading-specific props
<Text as="h1">Page Title</Text>
// Renders as <a> — gets href and target props
<Text as="a" href="https://example.com" target="_blank">Link</Text>
// Error: divs don't have href
<Text as="div" href="/bad" /> // ErrorTyping Higher-Order Components
import { ComponentType, ReactNode } from 'react';
interface WithAuthProps {
user: { id: string; role: string };
}
function withAuth<P extends object>(
WrappedComponent: ComponentType<P & WithAuthProps>
) {
return function AuthenticatedComponent(props: Omit<P, keyof WithAuthProps>) {
const user = useCurrentUser(); // hypothetical hook
if (!user) return <div>Please log in</div>;
return <WrappedComponent {...(props as P)} user={user} />;
};
}
interface DashboardProps extends WithAuthProps {
title: string;
}
function Dashboard({ user, title }: DashboardProps) {
return <div>Welcome, {user.id} — {title}</div>;
}
const AuthDashboard = withAuth(Dashboard);
// user prop is injected — only title is required
<AuthDashboard title="My Dashboard" />Component Prop Types Reference
Utility | What it extracts |
|---|---|
ComponentProps<"button"> | All props a native button element accepts |
ComponentPropsWithoutRef<"input"> | Native input props without the ref prop |
ComponentPropsWithRef<"div"> | Native div props including the ref prop |
React.HTMLAttributes<HTMLElement> | Generic HTML element attributes |
React.AriaAttributes | All ARIA attributes |
Key Takeaways
Type props as an interface parameter directly — avoid React.FC
Use ReactNode for children; ReactElement when you need only JSX elements
Event handlers use React.ChangeEvent<T>, React.MouseEvent<T>, etc.
useRef<HTMLElement>(null) for DOM refs; useRef<T>(value) for mutable values
Generic components infer T from usage — write ListProps<T> with a generic function
forwardRef takes two type params: forwardRef<RefType, PropsType>
useImperativeHandle exposes a custom imperative API through a ref
Polymorphic components use ElementType and ComponentPropsWithoutRef for full safety