How to Use React with TypeScript
You're debugging a component that crashes because props.user.name is undefined. The props looked fine when you wrote the code, but somewhere between the API call and your component, the data shape changed. Now you're stuck hunting through production logs trying to figure out where things went wrong.
This is exactly why TypeScript with React matters. With properly typed props and state, your IDE would've caught that undefined access before you even saved the file. This guide walks you through building React applications with TypeScript, from project setup to advanced patterns that prevent these headaches.
Starting a React Project with TypeScript
If you're starting a new project today, Vite is your best option. It's faster than Create React App (which is now deprecated), has better TypeScript support out of the box, and requires zero configuration:
npm create vite@latest my-app -- --template react-ts
cd my-app
npm install
npm run dev
This creates a new React project with TypeScript already configured. Vite uses esbuild for lightning-fast hot module replacement and includes a sensible tsconfig.json that works for most projects.
For full-stack applications, consider Next.js with TypeScript or Remix. Both have excellent TypeScript support and handle routing, data fetching, and server-side rendering.
Using TypeScript Types in a React Application
Defining and using types for props and state is vital for keeping type safety in your React components. TypeScript interfaces let you define the structure of objects, including properties and their types. Here's how you can define an interface for a User:
interface User {
id: number;
name: string;
email: string;
}
You can then use this type to check your components and make sure they get the right props and handle user data correctly:
function UserProfile({ user }: { user: User }) {
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}
Typing React Components with TypeScript
Typing React components with TypeScript means defining types for props, state, and context. Modern React code typically uses function components with inline prop types or separate type definitions:
type GreetingProps = {
name: string;
isLoggedIn?: boolean;
}
function Greeting({ name, isLoggedIn = false }: GreetingProps) {
return (
<h1>
{isLoggedIn ? `Welcome back, ${name}!` : `Hello, ${name}`}
</h1>
);
}
Note that we're not using React.FC here. While you'll see it in older code, it's no longer recommended because it has confusing behavior around children props and doesn't provide significant benefits over regular function typing.
For class components (which you might encounter in legacy codebases), you'll use React.Component with generic type parameters:
interface CounterState {
count: number;
}
interface CounterProps {
startFrom: number;
}
class Counter extends React.Component<CounterProps, CounterState> {
state = {
count: this.props.startFrom
};
increment = () => {
this.setState(state => ({
count: state.count + 1
}));
};
render() {
return (
<div>
<p>Count: {this.state.count}</p>
<button onClick={this.increment}>Add</button>
</div>
);
}
}
When working with event handlers, TypeScript helps prevent common mistakes:
function Form() {
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
// Form handling logic
};
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.value;
// Input handling logic
};
return (
<form onSubmit={handleSubmit}>
<input type="text" onChange={handleChange} />
<button type="submit">Submit</button>
</form>
);
}
Typing Children Props
Since React 18, the children prop is no longer automatically included in component types. You need to explicitly type it, and there are a few options depending on what you want to accept:
// ReactNode accepts anything renderable: JSX, strings, numbers, fragments, portals
interface ContainerProps {
children: React.ReactNode;
}
function Container({ children }: ContainerProps) {
return <div className="container">{children}</div>;
}
// PropsWithChildren is a utility type that adds children: ReactNode
function Wrapper({ children }: React.PropsWithChildren) {
return <section>{children}</section>;
}
// ReactElement only accepts JSX elements, not strings or numbers
interface StrictWrapperProps {
children: React.ReactElement;
}
function StrictWrapper({ children }: StrictWrapperProps) {
// This enforces that children must be a single React element
return <div>{children}</div>;
}
For most cases, use React.ReactNode. It's the most flexible and matches what users expect. Only use ReactElement when you specifically need to enforce that the child is a JSX element.
Creating Reusable Components with Generics
Generics let you create components that work with different data types while maintaining type safety. Here's a generic List component:
interface ListProps<T> {
items: T[];
renderItem: (item: T) => JSX.Element;
}
function List<T>(props: ListProps<T>) {
return (
<ul>
{props.items.map(props.renderItem)}
</ul>
);
}
You can use this component with various data types:
const numbers = [1, 2, 3];
const strings = ['a', 'b', 'c'];
return (
<div>
<List items={numbers} renderItem={(item) => <li>{item}</li>} />
<List items={strings} renderItem={(item) => <li>{item}</li>} />
</div>
);
You can also use utility types like Partial<T>, Pick<T>, and Omit<T> to create variations of your props:
interface ButtonProps {
text: string;
onClick: () => void;
disabled?: boolean;
variant: 'primary' | 'secondary' | 'danger';
className?: string;
}
// Make all props optional for a loading state
function LoadingButton(props: Partial<ButtonProps>) {
return <button {...props} disabled>Loading...</button>;
}
// Create a simpler button that only needs text and onClick
function SimpleButton(props: Pick<ButtonProps, 'text' | 'onClick'>) {
return <button onClick={props.onClick}>{props.text}</button>;
}
// Create a link-styled button that omits onClick
function LinkButton(props: Omit<ButtonProps, 'onClick'> & { href: string }) {
return <a href={props.href} className={props.className}>{props.text}</a>;
}
Discriminated Unions for Type-Safe Conditional Rendering
Ever had a component crash because you accessed data.results while still in a loading state? Discriminated unions solve this by making impossible states impossible to represent:
// Instead of separate booleans (error-prone)
interface BadState {
isLoading: boolean;
error?: Error;
data?: User[];
}
// Use a discriminated union (type-safe)
type DataState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'error'; error: Error }
| { status: 'success'; data: T };
interface UserListProps {
state: DataState<User[]>;
}
function UserList({ state }: UserListProps) {
// TypeScript narrows the type based on status
switch (state.status) {
case 'idle':
return <div>Click to load users</div>;
case 'loading':
return <div>Loading...</div>;
case 'error':
// TypeScript knows error exists here
return <div>Error: {state.error.message}</div>;
case 'success':
// TypeScript knows data exists here
return (
<ul>
{state.data.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
}
This pattern is especially useful for components that handle async data. You can't accidentally render state.data while still loading because TypeScript won't let you access a property that doesn't exist on that variant.
Advanced Ref Patterns with forwardRef
Sometimes you need to expose imperative methods from a child component to its parent. The useImperativeHandle hook with forwardRef lets you do this in a type-safe way:
// Define what the parent can access
interface VideoPlayerHandle {
play: () => void;
pause: () => void;
seek: (time: number) => void;
}
interface VideoPlayerProps {
src: string;
}
// forwardRef with generic types: <RefType, PropsType>
const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
({ src }, ref) => {
const videoRef = useRef<HTMLVideoElement>(null);
// Expose only specific methods to parent
useImperativeHandle(ref, () => ({
play() {
videoRef.current?.play();
},
pause() {
videoRef.current?.pause();
},
seek(time: number) {
if (videoRef.current) {
videoRef.current.currentTime = time;
}
}
}));
return <video ref={videoRef} src={src} />;
}
);
// Parent component usage
function MediaPlayer() {
const playerRef = useRef<VideoPlayerHandle>(null);
return (
<div>
<VideoPlayer ref={playerRef} src="/video.mp4" />
<button onClick={() => playerRef.current?.play()}>Play</button>
<button onClick={() => playerRef.current?.pause()}>Pause</button>
<button onClick={() => playerRef.current?.seek(30)}>Skip to 30s</button>
</div>
);
}
Note the ordering: forwardRef<RefType, PropsType> but the function parameters are (props, ref). It's backwards, which is confusing, but that's the API.
Using TypeScript with React State Management Libraries
TypeScript can help manage global state in your application when used with built-in React state management or with libraries like Redux Toolkit and Zustand.
For local state management with hooks:
function useCounter(initialValue: number = 0) {
const [count, setCount] = useState(initialValue);
const increment = () => setCount(prev => prev + 1);
const decrement = () => setCount(prev => prev - 1);
return {
count,
increment,
decrement
} as const;
}
Using React's Context API with TypeScript:
interface ThemeContext {
isDark: boolean;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContext | undefined>(undefined);
function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within ThemeProvider');
}
return context;
}
For Redux, use Redux Toolkit which has excellent TypeScript support built in:
import { createSlice, configureStore, PayloadAction } from '@reduxjs/toolkit';
interface CounterState {
value: number;
}
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 } as CounterState,
reducers: {
incremented(state) {
state.value++;
},
decremented(state) {
state.value--;
},
incrementedByAmount(state, action: PayloadAction<number>) {
state.value += action.payload;
}
}
});
export const { incremented, decremented, incrementedByAmount } = counterSlice.actions;
const store = configureStore({
reducer: {
counter: counterSlice.reducer
}
});
// Infer types from the store itself
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
Handling Props and State in React Components with TypeScript
TypeScript helps manage props and state in React components by catching type errors before runtime. Here's a todo list component demonstrating typed record state management and map transformations:
interface TodoItem {
id: number;
text: string;
completed: boolean;
}
interface TodoListProps {
initialTodos?: TodoItem[];
onTodoToggle: (id: number) => void;
}
function TodoList({ initialTodos = [], onTodoToggle }: TodoListProps) {
const [todos, setTodos] = useState<TodoItem[]>(initialTodos);
const [newTodoText, setNewTodoText] = useState('');
const addTodo = () => {
if (newTodoText.trim()) {
setTodos(prev => [...prev, {
id: Date.now(),
text: newTodoText,
completed: false
}]);
setNewTodoText('');
}
};
return (
<div>
<input
type="text"
value={newTodoText}
onChange={e => setNewTodoText(e.target.value)}
placeholder="Add new todo"
/>
<button onClick={addTodo}>Add</button>
<ul>
{todos.map(todo => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => onTodoToggle(todo.id)}
/>
<span>{todo.text}</span>
</li>
))}
</ul>
</div>
);
}
Using type assertions when working with refs:
function ImageUpload() {
const fileInputRef = useRef<HTMLInputElement>(null);
const handleUpload = () => {
const file = fileInputRef.current?.files?.[0];
if (file) {
// Handle file upload
}
};
return (
<div>
<input type="file" ref={fileInputRef} />
<button onClick={handleUpload}>Upload</button>
</div>
);
}
For Convex applications, Convex's React client library provides type-safe data fetching and state management.
Error Boundaries with TypeScript
Production React apps need error boundaries to catch rendering errors gracefully. Since error boundaries must be class components, here's how to type them correctly:
interface ErrorBoundaryProps {
children: React.ReactNode;
fallback?: React.ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
error?: Error;
}
class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
state: ErrorBoundaryState = {
hasError: false
};
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return {
hasError: true,
error
};
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
// Log to error reporting service
console.error('Error caught by boundary:', error, errorInfo);
}
render() {
if (this.state.hasError) {
return this.props.fallback || (
<div>
<h2>Something went wrong</h2>
<details>
<summary>Error details</summary>
<pre>{this.state.error?.message}</pre>
</details>
</div>
);
}
return this.props.children;
}
}
// Usage
function App() {
return (
<ErrorBoundary fallback={<ErrorFallback />}>
<UserProfile userId={123} />
</ErrorBoundary>
);
}
For a simpler approach, you can use the react-error-boundary library which provides a functional component wrapper with TypeScript support:
import { ErrorBoundary } from 'react-error-boundary';
function ErrorFallback({ error }: { error: Error }) {
return (
<div role="alert">
<h2>Something went wrong</h2>
<pre>{error.message}</pre>
</div>
);
}
function App() {
return (
<ErrorBoundary FallbackComponent={ErrorFallback}>
<UserProfile userId={123} />
</ErrorBoundary>
);
}
Adding TypeScript to an Existing React Project
To add TypeScript to an existing React project, start by installing the necessary dependencies:
npm install typescript @types/react @types/react-dom
Create a tsconfig.json file:
npx tsc --init
Update the tsconfig.json with React-specific settings:
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true
},
"include": ["src"]
}
Rename your .js and .jsx files to .ts and .tsx gradually. Start with leaf components (components that don't import other components) and work your way up. TypeScript will highlight type errors, which you can fix incrementally.
Update your imports to use ES modules syntax if necessary:
// Old
const React = require('react');
// New
import React from 'react';
Building Type-Safe React Applications
React and TypeScript work together to help you catch errors early and write more maintainable code. Here are the key patterns to remember:
- Define clear interfaces for your props and state
- Use discriminated unions for complex state machines
- Leverage utility types like
Partial,Pick, andOmitfor prop variations - Type children explicitly with
ReactNodeorPropsWithChildren - Use
forwardRefwithuseImperativeHandlefor exposing imperative APIs - Implement error boundaries to catch rendering errors gracefully
- Let type inference work for you in event handlers and hooks
For more complex applications, explore these advanced patterns:
- Custom hooks with proper generic types
- Higher-order components with type preservation
- Render props with flexible type parameters
- Component composition with discriminated unions
Need a type-safe backend for your React and TypeScript project? Get started with Convex to see how it integrates with your frontend.