d
React with types
Before we start delving into how you can use TypeScript with React, we should first have a look at what we want to achieve. When everything works as it should, TypeScript will help us catch the following errors:
- Trying to pass an extra/unwanted prop to a component
- Forgetting to pass a required prop to a component
- Passing a prop with the wrong type to a component
If we make any of these errors, TypeScript will help us notice them immediately via the IDE. If we didn't use TypeScript, we would have to catch these errors later during testing. We might be forced to do some tedious debugging to find the cause of the errors.
Avoiding tedious debugging is always appreciated.
Like in the previous parts, we'll start with a new empty repo. http://go.djosv.com/227labtsreact. Go ahead and import that into WebStorm just like with all the previous iterations.
Once you have that done, let's get into it!
Vite with TypeScript
We can use Vite to create a TypeScript app specifying the template react-ts
in the initialization script.
So to create a TypeScript app, run the following command:
npm create vite@latest my-app-name -- --template react-ts
After running the command, you should have a complete basic React app that uses TypeScript.
You can start the app by running npm run dev
in the application's root.
You'll need to cd into the new folder you just created to start it
If you take a look at the files and folders, you'll notice that the app is not that different from one using pure JavaScript. The only differences are that the .jsx files are now .tsx files, they contain some type annotations, and the root directory contains a tsconfig.json file.
Now, let's take a look at the tsconfig.json file that has been created for us:
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
Notice compilerOptions
now has the key lib
that includes:
type definitions for things found in browser environments (like
document
).
Everything else should be more or less fine.
In our previous project, we used ESlint to help us enforce a coding style, and we'll do the same with this app. We do not need to install any dependencies, since Vite has taken care of that already.
In our previous project, we used ESlint to help us enforce a coding style, and we'll do the same with this app. We do not need to install any dependencies, since Vite has taken care of that already.
When we look at the main.tsx file that Vite has generated, it looks familiar but there is a small but remarkable difference.
There is an exclamation mark after the statement document.getElementById('root')
:
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
The reason for this is that the statement might return value null but the ReactDOM.createRoot
does not accept null
as a parameter.
With the !
operator,
it is possible to assert to the TypeScript compiler that the value is not null.
Earlier in this part we warned about the dangers of type assertions,
but in our case the assertion is OK since we are sure that the file index.html indeed has this particular id and the function is always returning a HTMLElement
.
React components with TypeScript
Let us consider the following JavaScript React example:
import ReactDOM from 'react-dom/client';
import PropTypes from "prop-types";
const Welcome = props => {
return <h1>Hello, {props.name}</h1>;
};
Welcome.propTypes = {
name: PropTypes.string
};
ReactDOM.createRoot(document.getElementById('root')).render(
<Welcome name="Powercat" />
)
In this example, we have a component called Welcome
to which we pass a name
as a prop.
It then renders the name to the screen.
We know that the name
should be a string,
and we use the prop-types package introduced in
part 5 to receive hints about the desired types of a component's props and warnings about invalid prop types.
With TypeScript, *we don't need the prop-types package anymore.
We can define the types with the help of TypeScript, just like we define types for a regular function as *React components are nothing but mere functions.
We will use an interface for the parameter types (i.e., props
) and JSX.Element
as the return type for any React component:
import ReactDOM from 'react-dom/client';
interface WelcomeProps {
name: string;
}
const Welcome = (props: WelcomeProps): JSX.Element => {
return <h1>Hello, {props.name}</h1>;
};
ReactDOM.createRoot(document.getElementById('root')!).render(
<Welcome name="Powercat" />
)
We defined a new type, WelcomeProps
, and passed it to the function's parameter types.
const Welcome = (props: WelcomeProps): JSX.Element => {
You could write the same thing using a more verbose syntax with the help of destructuring:
const Welcome = ({ name }: { name: string }): JSX.Element => (
Now our editor knows that the name
prop is a string.
There is actually no need to define the return type of a React component since the TypeScript compiler infers the type automatically, and we can just write:
interface WelcomeProps {
name: string;
}
const Welcome = (props: WelcomeProps) => { return <h1>Hello, {props.name}</h1>;
};
ReactDOM.createRoot(document.getElementById('root')!).render(
<Welcome name="Powercat" />
);
Adding types that don't exactly fit
In the previous exercise, we had three handhelds, and all handhelds had the same attributes name
and gameCount
.
But what if we needed additional attributes for the systems and each handheld needs different attributes?
How would this look, code-wise?
Let's consider the following example:
const companyHandhelds = [
{
name: "Game Boy",
gameCount: 1046,
description: "AA Battery monster, here we come!"
},
{
name: "DS",
gameCount: 1791,
numberOfScreens: 2
},
{
name: "Game Boy Advance",
gameCount: 1538,
description: "The SP version was OP",
},
{
name: "Virtual Boy",
gameCount: 22,
description: "All Hail The Greatest system everrrrr",
agreement: "http://sebastianmihai.com/virtual-boy-warnings.html"
},
];
In the above example, we have added some additional attributes to each handheld.
Each handheld has the name
and gameCount
attributes,
but the first, third and fourth also have an attribute called description
,
and the second and fourth handhelds also have some distinct additional attributes.
Let's imagine that our application just keeps on growing, and we need to pass the different handheld systems around in our code. On top of that, there are also additional attributes and handheld systems added to the mix. How can we know that our code is capable of handling all the different types of data correctly, and we are not for example forgetting to render a new handheld system on some page? This is where TypeScript comes in handy!
Let's start by defining types for our different handhelds. We notice that the first and third have the same set of attributes. The second and fourth are a bit different so we have three different categories of handhelds.
So let us define a type for each handheld category:
interface HandheldBasic {
name: string;
gameCount: number;
description: string;
category: "basic";
}
interface HandheldDual {
name: string;
gameCount: number;
numberOfScreens: number;
category: "dual";
}
interface HandheldVirtual {
name: string;
gameCount: number;
description: string;
agreement: string;
category: "vr";
}
Besides the attributes that are found in the various handhelds,
we have now introduced a additional attribute called category
that has a literal type,
it is a "hard coded" string, distinct for each handheld.
We shall soon see where the attribute category
is used!
We will now create a type union of all these types. We can then use this union as the type for our array, which should accept any of these handheld types:
type Handheld = HandheldBasic | HandheldDual | HandheldVirtual;
Now we can set the type for our companyHandhelds
variable.
const App = () => {
const companyName = "Nintendo";
const companyHandhelds: Handheld[] = [
{
name: "Game Boy",
gameCount: 1046,
description: "AA Battery monster, here we come!",
category: "basic" },
{
name: "DS",
gameCount: 1791,
numberOfScreens: 2,
category: "dual" },
{
name: "Game Boy Advance",
gameCount: 1538,
description: "The SP version was OP",
category: "basic" },
{
name: "Virtual Boy",
gameCount: 22,
description: "All Hail The Greatest system everrrrr",
agreement: "http://sebastianmihai.com/virtual-boy-warnings.html",
category: "vr" },
]
// ...
}
Notice that we have now added the attribute category
with a proper value to each element of the array.
Our editor will automatically warn us if we use the wrong type for an attribute, use an extra attribute, or forget to set an expected attribute. If we eg. try to add the following to the array
{
name: "3DS",
gameCount: 1407,
category: "dual",
},
We will immediately see an error in the editor:

Since our new entry has the attribute category
with value dual
, TypeScript knows that the new entry is not just a Handheld
but more specifically a HandheldDual
.
So here the attribute category
narrows the type of the entry from a more general to a more specific type that has a certain set of attributes.
We shall soon see this style of type narrowing in action in the code!
But we're not satisfied yet! There is still a lot of type duplication we want to avoid. We start by identifying the attributes all handhelds have in common, and defining a base type that contains them. Then we will extend that base type to create our category-specific types:
interface HandheldBase {
name: string;
gameCount: number;
}
interface HandheldBasic extends HandheldBase {
description: string;
category: "basic";
}
interface HandheldDual extends HandheldBase {
numberOfScreens: number;
category: "dual";
}
interface HandheldVirtual extends HandheldBase {
description: string;
agreement: string;
category: "vr";
}
type Handheld = HandheldBasic | HandheldDual | HandheldVirtual;
More type narrowing
How should we now use these types in our components?
If we try to access the objects in the array handhelds: Handheld[]
we notice that it is possible to only access the attributes that are common to all the types in the union:

And indeed, the TypeScript documentation says this:
TypeScript will only allow an operation (or attribute access) if it is valid for every member of the union.
The documentation also mentions the following:
The solution is to narrow the union with code... Narrowing occurs when TypeScript can deduce a more specific type for a value based on the structure of the code.
So once again the type narrowing is the rescue!
One way to narrow these structures in TypeScript is to use switch case expressions.
Once TypeScript has deduced that a variable is of union type and that each type in the union contains a particular literal attribute (in our case category
),
we can use that as a type identifier.
We can then build a switch case around that attribute and TypeScript will know which attributes are available within each case block:

In the above example, TypeScript knows that a handheld
has the type Handheld
and it can then infer that handheld
is of either type HandheldBasic, HandheldDual or HandheldVirtual based on the value of the attribute category
.
The specific technique of type narrowing where a union type is narrowed based on literal attribute value is called discriminated union.
Notice that the narrowing can naturally be also done via an
if
statement. We could eg. do the following:companyHandhelds.forEach(handheld => { if (handheld.category === 'virtual') { console.log('see the following:', handheld.agreement); } // can not refer to handheld.agreement here! });
Adding new types
What about adding new types?
If we were to add a new handheld, wouldn't it be nice to know if we had already implemented handling that type in our code?
In the example above, a new type would go to the default
block and nothing would get printed for a new type.
Sometimes this is wholly acceptable.
For instance, if you wanted to handle only specific (but not all) cases of a type union, having a default is fine.
Nonetheless, you should handle all variations separately in most cases.
With TypeScript, we can use a method called exhaustive type checking.
Its basic principle is that if we encounter an unexpected value,
we call a function that accepts a value with the type
never and also has the return type never
.
A straightforward version of the function could look like this:
/**
* Helper function for exhaustive type checking
*/
const assertNever = (value: never): never => {
throw new Error(
`Unhandled discriminated union member: ${JSON.stringify(value)}`
);
};
If we now were to replace the contents of our default
block to:
default:
return assertNever(handheld);
and remove the case that handles the type HandheldVirtual
, we would see the following error:

The error message says that
'HandheldVirtual' is not assignable to parameter of type 'never'.
which tells us that we are using a variable somewhere where it should never be used. This tells us that something needs to be fixed.
React app with state
So far, we have only looked at an application that keeps all the data in a typed variable but does not have any state. Let us once more go back to the task app, and build a typed version of it.
We start with the following code:
import { useState } from 'react';
const App = () => {
const [newTask, setNewTask] = useState('');
const [tasks, setTasks] = useState([]);
return null;
}
When we hover over the useState
calls in the editor, we notice couple of interesting things.
The type of the first call useState('')
looks like the following:
useState<string>(initialState: string | (() => string)):
[string, React.Dispatch<React.SetStateAction<string>>]
The type is somewhat challenging to decipher. It has the following "form":
functionName(parameters): return_value
So we notice that TypeScript compiler has inferred that the initial state is either a string
or a function that returns a string
:
initialState: string | (() => string)
The type of the returned array is the following:
[string, React.Dispatch<React.SetStateAction<string>>]
So with the line const [newTask, setNewTask] = useState('');
,
we can deduce that newTask
is a string
since that is what useState
returns.
The second element that we assigned setNewTask
has a slightly more complex type: React.Dispatch<React.SetStateAction<string>>
.
We notice that there is a string
mentioned there, so we know that it must be the type of a function that sets a valued data.
See here if you want to learn more about useState
's types.
From this all we see that TypeScript has indeed
inferred
the type of the first useState
quite right, it is creating a state with type string
.
When we look at the second line, const [tasks, setTasks] = useState([]);
, the type looks quite different
useState<never[]>(initialState: never[] | (() => never[])):
[never[], React.Dispatch<React.SetStateAction<never[]>>]
TypeScript can just infer that the state has type never[]
, it is an array but it has no clue what are the elements stored to array,
so we clearly need to help the compiler and provide the type explicitly.
One of the best sources for information about typing React is the React TypeScript Cheatsheet.
The chapter about useState hook instructs to use a type parameter in situations where the compiler can not infer the type.
Let us now define a type for tasks
:
interface Task {
id: number,
content: string
}
To fix the typing issue, we write:
const [tasks, setTasks] = useState<Task[]>([]);
Now when hovering the type is correct:
useState<Task[]>(initialState: Task[] | (() => Task[])):
[Task[], React.Dispatch<React.SetStateAction<Task[]>>]
So in technical terms useState is a generic function, where the type has to be specified as a type parameter in those cases when the compiler can not infer the type.
Rendering the tasks is now easy. Let us just add some data to the state so that we can see that the code works:
import { useState } from "react";
interface Task {
id: number,
content: string
}
const App = () => {
const [newTask, setNewTask] = useState('');
const [tasks, setTasks] = useState<Task[]>([
{ id: 1, content: 'testing' } ]);
return (
<div> <ul> {tasks.map(task => <li key={task.id}>{task.content}</li> )} </ul> </div> )
}
The next task is to add a form that makes it possible to create new tasks:
const App = () => {
const [tasks, setTasks] = useState<Task[]>([
{ id: 1, content: 'testing' }
]);
const [newTask, setNewTask] = useState('');
return (
<div>
<form> <input value={newTask} onChange={(event) => setNewTask(event.target.value)} /> <button type='submit'>add</button> </form> <ul>
{tasks.map(task =>
<li key={task.id}>{task.content}</li>
)}
</ul>
</div>
)
}
It just works!
When we hover over the event.target.value
, we see that it is a string
, which is what setNewTask
expects as a parameter:

So we still need the event handler for adding the new task. Let us try the following:
const App = () => {
// ...
const taskCreation = (event) => { event.preventDefault(); // ... };
return (
<div>
<form onSubmit={taskCreation}> <input
value={newTask}
onChange={(event) => setNewTask(event.target.value)}
/>
<button type='submit'>add</button>
</form>
// ...
</div>
)
}
It does not quite work, there is an Eslint error complaining about implicit any:

TypeScript compiler has now no clue what is the type of the parameter,
so that is why the type is the infamous implicit any that we want to avoid at all costs.
The React TypeScript cheatsheet comes again to rescue, the chapter about
forms and events reveals that the right type of event handler is React.SyntheticEvent
.
The code becomes
interface Task {
id: number,
content: string
}
const App = () => {
const [tasks, setTasks] = useState<Task[]>([]);
const [newTask, setNewTask] = useState('');
const taskCreation = (event: React.SyntheticEvent) => { event.preventDefault(); const taskToAdd = { content: newTask, id: tasks.length + 1 } setTasks(tasks.concat(taskToAdd)); setNewTask(''); };
return (
<div>
<form onSubmit={taskCreation}>
<input value={newTask} onChange={(event) => setNewTask(event.target.value)} />
<button type='submit'>add</button>
</form>
<ul>
{tasks.map(task =>
<li key={task.id}>{task.content}</li>
)}
</ul>
</div>
)
}
And that's it, our app is ready and perfectly typed!
Communicating with the server
Let's modify the app so that the tasks are saved in a JSON server backend in URL http://localhost:3001/tasks
As usual, we shall use Axios and the useEffect
hook to fetch the initial state from the server.
Let us try the following:
const App = () => {
// ...
useEffect(() => {
axios.get('http://localhost:3001/tasks').then(response => {
console.log(response.data);
})
}, []);
// ...
}
When we hover over the response.data
we see that is has the type any

To set the data using setTasks
we must type it properly.
With a little help from the internet, we find a clever trick:
useEffect(() => {
axios.get<Task[]>('http://localhost:3001/tasks').then(response => { console.log(response.data);
})
}, []);
When we hover over response.data
we see that it has the correct type:

With the correct type, we can call setTasks
to get the code working:
useEffect(() => {
axios.get<Task[]>('http://localhost:3001/tasks').then(response => {
setTasks(response.data); })
}, []);
So just like with useState
, we gave a type parameter to axios.get
to instruct it how the typing should be done.
Like useState
, axios.get
is a generic function.
Unlike some generic functions, the type parameter of axios.get
has a default value any
.
If the function is used without defining the type parameter, the type of the response data would be any
.
The code works, and we see no large errors from eslint or the compiler.
However, giving a type parameter to axios.get
is potentially dangerous.
The *request body can be anything*, and when giving a type parameter we are essentially just telling to TypeScript compiler to trust us that the data has type Task[]
.
So our code is essentially as safe as it would be if a type assertion were used:
useEffect(() => {
axios.get('http://localhost:3001/tasks').then(response => {
// response.body is of type any
setTasks(response.data as Task[]); })
}, []);
Since the TypeScript types do not even exist in runtime, our code does not safeguard against malformed data from the request body.
Type casting axios.get
might be ok if we are absolutely sure that the backend behaves correctly and always sends the right data.
If we want to build a robust system, we should prepare for surprises and parse the response data in the frontend
similarly to what we did in the previous section for the requests to the backend.
Let's finish our app's functionality by integrating axios into our task creation:
const taskCreation = (event: React.SyntheticEvent) => {
event.preventDefault();
axios.post<Task>('http://localhost:3001/tasks', { content: newTask }) .then(response => { setTasks(tasks.concat(response.data)); });
setNewTask('');
};
We are again giving axios.post
a type parameter.
We know that the server response is the added task, so the proper type parameter is Task
.
Let's refactor a bit of the code. Let's move some type definitions into a new file named types.ts:
export interface Task {
id: number,
content: string
}
export type NewTask = Omit<Task, 'id'>
We have added a type for a new task, one that does not yet have the id
field assigned.
The code that communicates with the backend is also refactored to the file services/taskService.tsx
import axios from 'axios';
import { Task, NewTask } from "../types";
const baseUrl = 'http://localhost:3001/tasks';
export const getAllTasks = () => {
return axios
.get<Task[]>(baseUrl)
.then(response => response.data);
}
export const createTask = (object: NewTask) => {
return axios
.post<Task>(baseUrl, object)
.then(response => response.data);
}
The component App
is now much cleaner:
import { useState, useEffect } from "react";
import { Task } from "./types";import { getAllTasks, createTask } from './services/taskService';
const App = () => {
const [newTask, setNewTask] = useState('');
const [tasks, setTasks] = useState<Task[]>([]);
useEffect(() => {
getAllTasks().then(data => { setTasks(data); }) }, []);
const taskCreation = (event: React.SyntheticEvent) => {
event.preventDefault()
createTask({content: newTask}) .then(data => { setTasks(tasks.concat(data)); });
setNewTask('');
};
return (
// ...
);
}
The app is now nicely typed and ready for further development!
The code of the typed tasks can be found here.
About defining object types
We have used interfaces to define object types, e.g. diary entries, in the previous section
interface DiaryEntry {
id: number;
date: string;
weather: Weather;
visibility: Visibility;
comment?: string;
}
and in the handheld of this section
interface HandheldBase {
name: string;
gameCount: number;
}
We actually could have had the same effect by using a type alias
type DiaryEntry = {
id: number;
date: string;
weather: Weather;
visibility: Visibility;
comment?: string;
}
type
and interface
are mostly interchangeable.
However, subtle differences exist, particularly when you try to define types or interfaces with a non-unique name.
If you define a second interface
with the same name,
Typescript will merge them.
Trying to define a second type
, however,
will result in Typescript raising an error because a type with the same name has already declared.
TypeScript documentation recommends using interfaces in most cases.