REACT (https://www.kirupa.com/react/index.htm)
BOOK (https://www.amazon.com/exec/obidos/ASIN/0134546318/kirupacom)
Using React with TypeScript
by kirupa (https://www.kirupa.com/me/index.htm) | filed under Learning React (https://www.kirupa.com/react/index.htm)
All of the React code you have written so far has been JavaScript. JavaScript is wonderfully relaxed about what you do with it. You can pass a number where a string was expected, forget a property, or misspell the name of a function, and it will let you do all of that. It only complains when your app is running and somebody clicks the wrong thing.
TypeScript (https://www.typescriptlang.org) is JavaScript with a safety net. You describe the shape of your data (what props a component takes, what your state holds), and TypeScript checks your code against those descriptions before it runs. If you pass the wrong thing, your editor underlines it right away. Almost every React job posting and open source project you will run into uses TypeScript, so it is worth getting comfortable with.
In this tutorial, we'll set up a React project with TypeScript and then walk through how to type each of the things you have been doing: props, children, state, events, refs, reducers, and context. Every example you'll see here was type checked in a real project, and the error messages are the real ones.
Creating a React and TypeScript Project
The good news is that you don't need to install or configure anything special. The Vite setup you saw in setting up your React environment (https://www.kirupa.com/react/setting_up_react_environment.htm) has a TypeScript template. Instead of choosing the react template, you choose react-ts:
npm create vite@latest my-app -- --template react-ts
cd my-app
npm install
npm run dev
If you run npm create vite@latest without the template flag, it asks you a series of questions, and you can pick React and TypeScript from the list instead. The result is the same.
Open the new project and you'll see something familiar, with a few differences:
- Your files end in .tsx instead of .jsx. A .tsx file is a TypeScript file that is allowed to contain JSX. (If a file has no JSX, .ts works too.)
- Instead of a single config file, there are a few tsconfig files: tsconfig.json, tsconfig.app.json (for your React code), and tsconfig.node.json (for the Vite config). You rarely need to touch them.
- Your package.json has typescript and the @types/react and @types/react-dom packages. Those @types packages are what tell TypeScript what React's functions and components look like.
There's a detail here that surprises people. Look at the scripts in package.json:
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview"
}
Notice that npm run dev just runs Vite. Vite does not type check your code. It strips the types out and moves on, which is part of why it is so fast. Your editor is what underlines errors as you type. The actual full check happens when you run npm run build, because it runs tsc -b (the TypeScript compiler) before Vite builds anything. If you want to check your types without building, you can run npx tsc -b on its own at any time.
The template also turns on strict checking, which is what you want. A few of its other settings affect how you write code, so keep these two in mind:
- verbatimModuleSyntax means that when you import something that is only a type, you should write import type { ReactNode } from "react" (or import { type ReactNode }), so it is clear that it disappears at build time.
- erasableSyntaxOnly means you can't use TypeScript features that generate real code, like enum. A union of strings does the same job and is what we will use.
Typing Props
Props are where TypeScript pays for itself the fastest. You describe what a component accepts with an interface (a type works too), and then you tell the function that its props look like that:
interface GreetingProps {
name: string;
excited?: boolean;
}
function Greeting({ name, excited = false }: GreetingProps) {
return (
Hello, {name}
{excited ? "!" : "."}
);
}
export default Greeting;
Here is what is going on. name is required and has to be a string. The ? after excited makes it optional, and we give it a default of false in the destructuring, the same way you would in plain JavaScript. Using the component is exactly what you are used to:
Now the fun part. If I forget the required prop:
TypeScript stops me with this:
Property 'name' is missing in type '{}' but required in type 'GreetingProps'.
You would normally see that as a red squiggle in your editor long before you ever run tsc. If you want to read more on how props work in the first place, check out creating complex components (https://www.kirupa.com/react/creating_complex_components.htm).
You will often want a prop to be one of a small set of values, such as a color. A union of string literals is perfect for that:
interface BadgeProps {
label: string;
color: "blue" | "pink" | "green";
}
function Badge({ label, color }: BadgeProps) {
return {label};
}
export default Badge;
Ask for a color that isn't in the list, like , and you get:
Type '"red"' is not assignable to type '"blue" | "pink" | "green"'.
Your editor will also autocomplete the three allowed values as you type, which is a nice bonus. A small typo in a string like this is exactly the kind of thing that wastes ten minutes in plain JavaScript.
One thing you may have seen in older code is React.FC, as in const Greeting: React.FC = .... You don't need it. Typing the props parameter directly, like we did above, is simpler and is what most people do now.
Typing Children
When a component wraps other content, its children need a type too. The type for "anything React can render" (JSX elements, strings, numbers, arrays of them, and so on) is ReactNode:
import type { ReactNode } from "react";
interface CardProps {
title: string;
children: ReactNode;
}
function Card({ title, children }: CardProps) {
return (
{title}
{children}
);
}
export default Card;
Because children is listed in the interface without a ?, it is required. If I write with nothing inside, TypeScript lets me know:
Property 'children' is missing in type '{ title: string; }' but required in type 'CardProps'.
If your component is fine with having no children, write children?: ReactNode instead. It is a small thing, but it is a nice example of the type documenting how the component is meant to be used. We covered the JavaScript side of children in components in React (https://www.kirupa.com/react/components.htm).
Typing State
For useState, you often don't need to do anything at all. TypeScript looks at the initial value and works out the type:
const [clicks, setClicks] = useState(0);
Here, clicks is a number, and setClicks only accepts numbers. If you try setClicks("lots"), you get:
Argument of type 'string' is not assignable to parameter of type 'SetStateAction'.
That SetStateAction is just React's name for "a value, or a function that returns a value." You don't have to remember it.
The inference breaks down when the starting value doesn't tell the whole story. The most common case is state that starts as null and later holds an object. For that, you give useState the type yourself, between angle brackets:
import { useState } from "react";
interface User {
name: string;
age: number;
}
function Profile() {
const [clicks, setClicks] = useState(0);
const [user, setUser] = useState(null);
return (
{user ? `${user.name} is ${user.age}` : "Nobody is logged in"}
);
}
export default Profile;
The User | null says that this state is either a User or nothing. Notice how the last paragraph checks user ? first, and only then reads user.name. That check is not optional. If you try to read user.name without it, TypeScript refuses:
'user' is possibly 'null'.
This is the single most useful thing TypeScript does in React apps. It forces you to deal with the "data hasn't loaded yet" case, which is a classic source of the "cannot read properties of null" crash.
Two gotchas are worth knowing about. First, an empty array is a trap:
const [items, setItems] = useState([]);
setItems(["a"]);
Type 'string' is not assignable to type 'never'.
TypeScript sees [] and decides it is an array that can never hold anything. The fix is to say what goes in it: useState([]). Second, if you leave out the initial value, as in useState(), the type becomes number | undefined, so using it as a plain number gives you:
Type 'number | undefined' is not assignable to type 'number'.
Give state a real starting value whenever you can. If you need a refresher on how state works, check out dealing with state (https://www.kirupa.com/react/dealing_with_state.htm).
Typing Events
Event handlers are where people tend to get stuck, mostly because the event types have long names. The pattern is simple, though. React has an event type for each kind of event, and you tell it which element the event is coming from with angle brackets. Here are the three you'll use the most, all in one form:
import { useState } from "react";
import type { ChangeEvent, MouseEvent, SubmitEvent } from "react";
function SearchBox() {
const [query, setQuery] = useState("");
const [submitted, setSubmitted] = useState("");
function handleChange(e: ChangeEvent) {
setQuery(e.target.value);
}
function handleSubmit(e: SubmitEvent) {
e.preventDefault();
setSubmitted(query);
}
function handleClear(e: MouseEvent) {
console.log(`Clear button at ${e.clientX}, ${e.clientY}`);
setQuery("");
}
return (
);
}
export default SearchBox;
Each type has the same two parts: ChangeEvent is the kind of event, and is the element it came from. That second part is why e.target.value works in handleChange. TypeScript knows the target is an input, and inputs have a value.
There's a recent change you might trip over. Older tutorials (and a lot of AI-generated code) type a form submit handler with FormEvent. In the current React types, FormEvent is marked deprecated, and SubmitEvent is the replacement, which is why the code above uses it. Your editor will show FormEvent with a strikethrough if you use it.
And a shortcut: if you write the handler inline, you don't need to type anything. In onClick={() => setClicks(clicks + 1)}, TypeScript already knows what the event is, because it knows what onClick expects. If you leave the type off a parameter of a standalone function, though, you'll see this:
Parameter 'v' implicitly has an 'any' type.
That is TypeScript telling you it can't guess here, so you need to say what it is. For more on the events themselves, see events in React (https://www.kirupa.com/react/events_in_react.htm).
Typing Refs
Refs (from accessing DOM elements (https://www.kirupa.com/react/accessing_dom_elements.htm)) take the type of the element they will point at. Because the ref starts out as null and only gets filled in after the component renders, you pass null as the initial value:
const inputRef = useRef(null);
In React 19's types, you must give useRef an argument. Calling useRef() with nothing gives you Expected 1 arguments, but got 0., so you'll want to remember the null. And since inputRef.current is either the input or null, you read it with ?. to be safe:
import { useRef } from "react";
import type { ComponentProps } from "react";
type TextFieldProps = ComponentProps<"input"> & {
label: string;
};
function TextField({ label, id, ...rest }: TextFieldProps) {
return (
);
}
function FocusInput() {
const inputRef = useRef(null);
function focusIt() {
inputRef.current?.focus();
}
return (
);
}
export default FocusInput;
There are two things to see here. The first is ComponentProps<"input">, which is a handy built-in type that means "all of the props a regular accepts." Combining it with our own label using & gives us a TextField that supports placeholder, disabled, onChange, and everything else an input does, without us listing any of them. The second is ref. In React 19, ref is a regular prop that function components can receive, so it is included in ComponentProps and comes through in ...rest. You no longer need forwardRef for this. I loaded this component in a browser, clicked the button, and the input got focus, so it is not just a type-level trick.
Typing useReducer
useReducer (https://www.kirupa.com/react/managing_state_with_usereducer.htm) is a place where TypeScript really shines, because the actions you dispatch are a perfect fit for a discriminated union. That is a fancy name for a union of types that each have a type property with a unique value:
import { useReducer } from "react";
interface CounterState {
count: number;
}
type CounterAction =
| { type: "increment" }
| { type: "decrement" }
| { type: "reset" }
| { type: "add"; amount: number };
function reducer(state: CounterState, action: CounterAction): CounterState {
switch (action.type) {
case "increment":
return { count: state.count + 1 };
case "decrement":
return { count: state.count - 1 };
case "add":
return { count: state.count + action.amount };
case "reset":
return { count: 0 };
}
}
function ReducerCounter() {
const [state, dispatch] = useReducer(reducer, { count: 0 });
return (
Count: {state.count}
);
}
export default ReducerCounter;
I only typed the two arguments of the reducer function, and everything else got figured out. Inside the case "add" branch, TypeScript knows that action has an amount, because that is the only action with type: "add". In the other branches, action.amount would be an error. And dispatch is now strict about what you give it. A typo is caught right away:
Type '"incremnt"' is not assignable to type '"add" | "decrement" | "increment" | "reset"'. Did you mean '"increment"'?
As is forgetting a piece of an action:
Property 'amount' is missing in type '{ type: "add"; }' but required in type '{ type: "add"; amount: number; }'.
This is the main reason I like reducers with TypeScript. The list of everything that can happen in your component is written down in one place, and you can't dispatch something that isn't on the list.
Typing Context
Context (from sharing data with context (https://www.kirupa.com/react/sharing_data_with_context.htm)) has one awkward part with TypeScript. When you call createContext, you have to provide a default value, but in a real app, the actual value comes from a provider much higher up. What do you put as the default? A fake value would be misleading, so the pattern I use is to make the default null and then check for it in a small custom hook:
import { createContext, use, useState } from "react";
import type { ReactNode } from "react";
type Theme = "light" | "dark";
interface ThemeContextValue {
theme: Theme;
toggleTheme: () => void;
}
const ThemeContext = createContext(null);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState("light");
function toggleTheme() {
setTheme(theme === "light" ? "dark" : "light");
}
return {children};
}
export function useTheme() {
const context = use(ThemeContext);
if (context === null) {
throw new Error("useTheme must be used inside a ThemeProvider");
}
return context;
}
There are a few things worth pointing out in this code. The context's type is ThemeContextValue | null, so it is honest about the fact that there might be no provider. The provider is written as , which is how React 19 does it. You no longer write .Provider. And the useTheme hook uses use to read the context, and throws a helpful error if the provider is missing. The nice side effect is that when useTheme returns, TypeScript knows the value is not null, so the components using it don't have to check:
import { useTheme } from "./ThemeContext";
function ThemeToggle() {
const { theme, toggleTheme } = useTheme();
return ;
}
export default ThemeToggle;
The components that use the context only see a clean theme and toggleTheme, and they are not aware of the null at all. If you haven't used use before, you can learn about it in what's new in React 19 (https://www.kirupa.com/react/react_19_whats_new.htm), and the hook version of this pattern is expanded on in building custom hooks (https://www.kirupa.com/react/custom_hooks.htm).
Generic Components
Sometimes a component needs to work with any kind of data while still being type safe. A list that takes an array of heroes today and an array of products tomorrow is a good example. This is what generics are for. A generic component has a type parameter (conventionally called T) that gets filled in when you use it:
import type { ReactNode } from "react";
interface ListProps {
items: T[];
getKey: (item: T) => string | number;
renderItem: (item: T) => ReactNode;
}
function List({ items, getKey, renderItem }: ListProps) {
return (
{items.map((item) => (
{renderItem(item)}
))}
);
}
export default List;
The T is a placeholder for "whatever type of item you give me." Using it is where the payoff is, because you almost never have to write the type out:
interface Hero {
id: number;
name: string;
}
const heroes: Hero[] = [
{ id: 1, name: "Batman" },
{ id: 2, name: "Sherlock Holmes" },
];
hero.id}
renderItem={(hero) => {hero.name}}
/>
TypeScript looks at items={heroes}, decides that T must be Hero, and then knows that hero in both of the other functions is a Hero. Type hero. and your editor suggests id and name. Generics can feel abstract, but you'll write far fewer of them than you will use. They show up most in reusable components like lists, tables, and dropdowns.
Putting It All Together
To check that all of this works in one place, I put every component from this tutorial into a single App and ran npm run build. Because the build script starts with tsc -b, it only gets to the Vite build if the types are clean. It was, and then I loaded the built app in a browser with no errors:
import Badge from "./Badge";
import Card from "./Card";
import FocusInput from "./FocusInput";
import Greeting from "./Greeting";
import List from "./List";
import Profile from "./Profile";
import ReducerCounter from "./ReducerCounter";
import SearchBox from "./SearchBox";
import { ThemeProvider } from "./ThemeContext";
import ThemeToggle from "./ThemeToggle";
interface Hero {
id: number;
name: string;
}
const heroes: Hero[] = [
{ id: 1, name: "Batman" },
{ id: 2, name: "Sherlock Holmes" },
];
function App() {
return (
hero.id}
renderItem={(hero) => {hero.name}}
/>
);
}
export default App;
A Few Tips Before You Go
- Let inference do the work. Annotate props, state that starts empty, and function parameters. Leave the rest alone. If TypeScript already knows the type, adding it yourself only creates more to maintain.
- Avoid any. It turns checking off for whatever it touches. If you don't know a type yet, unknown is safer because it makes you check before you use the value.
- Don't fight the error message. TypeScript's messages are long and look intimidating, but the last line is usually the useful one. Read it slowly and it is nearly always telling you something real.
- Don't use ! and as to silence errors. Both exist for the rare times you know more than the compiler does. The template's document.getElementById('root')! is a fine example. Using them to make an annoying error go away just moves the problem to runtime.
- You can adopt it gradually. A project can have both .jsx and .tsx files, so there's no need to convert everything on day one.
Conclusion
TypeScript adds a layer on top of everything you already know about React, and it doesn't change how React itself works. Props get an interface, children are a ReactNode, state is usually inferred (except when it starts as null or an empty array), events are typed with the element they came from, refs start as null, reducers use a discriminated union for their actions, and context gets a null default with a hook that checks it. In return, a whole category of bugs shows up in your editor instead of in front of your users.
It will feel like extra work for the first few days. Then you will refactor a component, watch the editor point out every place that needs updating, and it will start to feel like a superpower.
Next tutorial: Testing React Apps (https://www.kirupa.com/react/testing_react_apps.htm)
Onwards!