JavaScript powers the web, but it comes with a big tradeoff: it’s dynamically typed. That means type errors like passing a string where a number was expected only show up at runtime. For small scripts, this isn’t a big deal. But in large React applications, these issues can cause frustrating bugs that are hard to trace.
That’s where TypeScript comes in. TypeScript adds static typing to JavaScript, which means you catch errors earlier, improve editor autocomplete, and make your codebase easier to maintain. When combined with React, TypeScript can significantly improve how you work with components, state, and props.
In this article, we’ll explore common patterns for using React with TypeScript, including props, state, events, children, and custom hooks. Along the way, we’ll look at best practices and mistakes to avoid.
Why TypeScript with React?
Before diving into patterns, let’s understand the main benefits of using TypeScript in React projects:
Type safety: Catch errors at compile time rather than runtime.
Better autocomplete: Your editor knows exactly what props or state fields exist.
Self-documenting code: Types act as living documentation for your components.
Refactor with confidence: Changing a type forces updates in all dependent code.
Pattern 1. Typing Props
Props are the foundation of React components, and TypeScript makes them safer.
Example
type ButtonProps = {
label: string;
onClick: () => void;
disabled?: boolean;
};
function Button({ label, onClick, disabled = false }: ButtonProps) {
return (
<button onClick={onClick} disabled={disabled}>
{label}
</button>
);
}
Here
label
It is required and must be a string.
onClick
It is required and must be a function.
disabled
is optional, with a default of false
.
If you forget label
or onClick.
TypeScript raises an error.
Pattern 2. Typing State with useState
React’s useState
hook is strongly typed, but TypeScript sometimes needs a hint.
const [count, setCount] = React.useState<number>(0);
Here, the state must always be a number.
For a more complex state:
type User = { name: string; age: number };
const [user, setUser] = React.useState<User | null>(null);
This state can either be an User
object or null
. This prevents you from accidentally calling user.name
when no user exists.
Pattern 3. Typing Events
Event handlers are a common source of confusion in React + TypeScript. Luckily, React provides built-in types.
Example: typing an input change event:
function TextInput() {
const [value, setValue] = React.useState("");
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value);
};
return <input type="text" value={value} onChange={handleChange} />;
}
Other common event types:
Pattern 4. Typing Components with Children
Many components wrap others using children
. In TypeScript, you can type this with React.ReactNode
.
type CardProps = {
title: string;
children: React.ReactNode;
};
function Card({ title, children }: CardProps) {
return (
<div className="card">
<h3>{title}</h3>
<div>{children}</div>
</div>
);
}
This ensures Card
can accept text, JSX, or other components.
Pattern 5. Typing Custom Hooks
Custom hooks are where TypeScript shines because you can define both input and output types.
Example: localStorage hook:
function useLocalStorage<T>(key: string, initialValue: T) {
const [value, setValue] = React.useState<T>(() => {
const saved = localStorage.getItem(key);
return saved ? JSON.parse(saved) : initialValue;
});
React.useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue] as const;
}
// usage
const [theme, setTheme] = useLocalStorage<"light" | "dark">("theme", "light");
Notice the <T>,
This makes the hook generic so it can work with any type.
Pattern 6. Typing Context
React Context works seamlessly with TypeScript. Example:
type AuthContextType = {
user: { name: string } | null;
login: (name: string) => void;
logout: () => void;
};
const AuthContext = React.createContext<AuthContextType | undefined>(undefined);
function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = React.useState<{ name: string } | null>(null);
const login = (name: string) => setUser({ name });
const logout = () => setUser(null);
return (
<AuthContext.Provider value={{ user, login, logout }}>
{children}
</AuthContext.Provider>
);
}
function useAuth() {
const context = React.useContext(AuthContext);
if (!context) throw new Error("useAuth must be used within AuthProvider");
return context;
}
This ensures any component using it useAuth
knows exactly what methods and data are available.
Pattern 7. Typing Refs
Refs can be typed too. Example:
function InputFocus() {
const inputRef = React.useRef<HTMLInputElement>(null);
const focusInput = () => {
inputRef.current?.focus();
};
return (
<>
<input ref={inputRef} />
<button onClick={focusInput}>Focus</button>
</>
);
}
Here, inputRef
is explicitly typed as an HTMLInputElement
.
Best Practices
Use type aliases (type
) for props and state. Use interfaces when extending.
Use union types for limited sets of values.
Write generic hooks for reusable typed logic.
Keep context types small to avoid re-renders.
Let TypeScript infer when it can.
Conclusion
TypeScript transforms the way you write React applications. By typing props, state, events, children, context, and hooks, you make your components safer and more predictable. The key is to use typing wisely, avoid any
, don’t overcomplicate types, and let TypeScript infer where possible.
The result is code that’s not only less error-prone but also easier to read and maintain. As your React projects scale, TypeScript becomes less of an optional extra and more of an essential tool.
If you’re building production-grade React apps, embracing TypeScript is one of the smartest moves you can make.