React and TypeScript are like peanut butter and jelly - they just work so well together. If you’re already familiar with React, adding TypeScript to your toolkit can seriously level up your development game. Let’s dive into how you can use TypeScript with React to catch bugs early and write more robust code.
First things first, you’ll need to set up a new React project with TypeScript. The easiest way to do this is using Create React App. Just run this command in your terminal:
npx create-react-app my-app --template typescript
This will create a new React project with TypeScript already configured. Easy peasy!
Once you’ve got your project set up, you’ll notice that your component files now have a .tsx extension instead of .js or .jsx. This tells TypeScript that these files contain JSX code.
Let’s start with a simple example. Here’s a basic React component written in TypeScript:
import React from 'react';
interface GreetingProps {
name: string;
}
const Greeting: React.FC<GreetingProps> = ({ name }) => {
return <h1>Hello, {name}!</h1>;
};
export default Greeting;
In this example, we’re defining an interface called GreetingProps that specifies the shape of the props our component expects. We’re then using this interface in the component definition. This tells TypeScript that our component expects a prop called name of type string.
If we try to use this component without providing a name prop, or if we provide a name prop of the wrong type, TypeScript will yell at us. This is super helpful for catching errors before they make it to runtime.
One of the coolest things about using TypeScript with React is how it can help with handling events. Here’s an example:
import React, { useState } from 'react';
const Counter: React.FC = () => {
const [count, setCount] = useState<number>(0);
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
setCount(count + 1);
};
return (
<div>
<p>You clicked {count} times</p>
<button onClick={handleClick}>Click me</button>
</div>
);
};
export default Counter;
In this example, we’re using TypeScript to specify the type of the event in our handleClick function. This gives us better autocomplete and type checking when working with event properties.
Another area where TypeScript shines is with React’s useRef hook. Here’s how you might use it:
import React, { useRef, useEffect } from 'react';
const AutoFocusInput: React.FC = () => {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
return <input ref={inputRef} />;
};
export default AutoFocusInput;
By specifying HTMLInputElement as the type for our ref, we get better type checking and autocomplete when working with the ref’s current property.
One thing that tripped me up when I first started using TypeScript with React was dealing with children props. Here’s a pattern I’ve found useful:
import React from 'react';
interface WrapperProps {
children: React.ReactNode;
}
const Wrapper: React.FC<WrapperProps> = ({ children }) => {
return <div className="wrapper">{children}</div>;
};
export default Wrapper;
Using React.ReactNode as the type for children allows for any valid JSX to be passed as children to this component.
When working with forms in React, TypeScript can be a huge help. Here’s an example of a form component with TypeScript:
import React, { useState } from 'react';
interface FormData {
name: string;
email: string;
}
const Form: React.FC = () => {
const [formData, setFormData] = useState<FormData>({ name: '', email: '' });
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = event.target;
setFormData(prevData => ({ ...prevData, [name]: value }));
};
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
console.log(formData);
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
name="name"
value={formData.name}
onChange={handleChange}
/>
<input
type="email"
name="email"
value={formData.email}
onChange={handleChange}
/>
<button type="submit">Submit</button>
</form>
);
};
export default Form;
In this example, we’re using TypeScript to define the shape of our form data and to type our event handlers. This gives us better autocomplete and error checking when working with form inputs and events.
One of the things I love about TypeScript is how it can help with prop drilling. If you’re not familiar with prop drilling, it’s when you pass props through multiple levels of components. It can get messy quickly. Here’s how TypeScript can help:
import React, { createContext, useContext } from 'react';
interface ThemeContextType {
theme: 'light' | 'dark';
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
const ThemeProvider: React.FC = ({ children }) => {
const [theme, setTheme] = React.useState<'light' | 'dark'>('light');
const toggleTheme = () => {
setTheme(prevTheme => (prevTheme === 'light' ? 'dark' : 'light'));
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
};
const useTheme = () => {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
};
const ThemedButton: React.FC = () => {
const { theme, toggleTheme } = useTheme();
return (
<button onClick={toggleTheme} style={{ background: theme === 'light' ? '#fff' : '#000', color: theme === 'light' ? '#000' : '#fff' }}>
Toggle Theme
</button>
);
};
export { ThemeProvider, useTheme, ThemedButton };
In this example, we’re using TypeScript to define the shape of our context and to ensure that we’re using our custom hook correctly. This can save you a lot of headaches when working with complex state management.
Another area where TypeScript shines is when working with APIs. Here’s an example of how you might use TypeScript with the Fetch API:
interface User {
id: number;
name: string;
email: string;
}
const fetchUser = async (id: number): Promise<User> => {
const response = await fetch(`https://api.example.com/users/${id}`);
if (!response.ok) {
throw new Error('Failed to fetch user');
}
return response.json();
};
const UserProfile: React.FC<{ userId: number }> = ({ userId }) => {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
fetchUser(userId)
.then(data => setUser(data))
.catch(err => setError(err))
.finally(() => setLoading(false));
}, [userId]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
if (!user) return null;
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
};
In this example, we’re using TypeScript to define the shape of our User data and to type our API response. This helps ensure that we’re handling our data correctly and can catch any mismatches between our expected data shape and what the API actually returns.
One of the things that can be a bit tricky when you’re first getting started with TypeScript and React is typing your custom hooks. Here’s an example of how you might type a custom hook:
import { useState, useCallback } from 'react';
interface UseCounterResult {
count: number;
increment: () => void;
decrement: () => void;
reset: () => void;
}
const useCounter = (initialValue: number = 0): UseCounterResult => {
const [count, setCount] = useState(initialValue);
const increment = useCallback(() => setCount(x => x + 1), []);
const decrement = useCallback(() => setCount(x => x - 1), []);
const reset = useCallback(() => setCount(initialValue), [initialValue]);
return { count, increment, decrement, reset };
};
export default useCounter;
In this example, we’re defining an interface for the return value of our hook. This makes it clear what values and functions our hook is providing, and allows TypeScript to check that we’re using the hook correctly.
When you’re working with libraries that don’t have built-in TypeScript support, you might need to create your own type definitions. Here’s an example of how you might do this:
declare module 'some-untyped-module' {
export function doSomething(value: string): number;
export function doSomethingElse(value: number): string;
}
You can put this in a file with a .d.ts extension, and TypeScript will pick it up automatically. This allows you to use untyped libraries in a type-safe way.
One of the most powerful features of TypeScript is its ability to infer types. This can make your code much cleaner and easier to read. Here’s an example:
const numbers = [1, 2, 3, 4, 5];
const doubledNumbers = numbers.map(n => n * 2);
In this example, TypeScript will infer that doubledNumbers is an array of numbers. You don’t need to explicitly type it.
However, sometimes you might want to be more explicit with your types. This can be especially useful when working with complex data structures. Here’s an example:
interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'user';
}
const users: User[] = [
{ id: 1, name: 'Alice', email: '[email protected]', role: 'admin' },
{ id: 2, name: 'Bob', email: '[email protected]', role: 'user' },
];
const admins = users.filter(user => user.role === 'admin');
In this example, we’re explicitly typing our users array. This ensures that all of our user objects have the correct shape.
One of the things I love about TypeScript is how it can help with refactoring. Because TypeScript knows the shape of your data and the types of your functions, it can catch a lot of errors when you’re making changes to your code. This can give you a lot more confidence when refactoring large codebases.
Another great feature of TypeScript is its support for generics. This can be super helpful when you’re writing reusable components. Here’s an example:
interface ListProps<T> {
items: T[];
renderItem: (item: T) => React.ReactNode;
}
function List<T>({ items, renderItem }: ListProps<T>) {
return (
<ul>
{items.map((item, index) => (
<li key={index}>{renderItem(item)}</li>
))}
</ul>
);
}
// Usage
const numbers = [1, 2, 3, 4, 5];
const NumberList = () => (
<List