Projects (What you’ll build)
The project in this course will have you revisit the Github Battle project from the React Hooks course. Your goal will be converting the app from JavaScript to TypeScript.
Like the project, the curriculum will be a refactor of the Hacker News curriculum from the React Hooks course.
You have a few options for how you can complete the project and curriculum for this course. Since both of them are based on existing code bases, you can choose to convert the projects directly to TypeScript. Alternatively, you can choose to build the projects from scratch, using TypeScript instead of JavaScript.
Doing the project from scratch will take more time and effort, but you’ll be able to see firsthand the benefits of using TypeScript as you are creating web apps.
Configuring TypeScript for React
There are a few settings in our tsconfig.json we need to configure to use React TypeScript.
React Type Declarations
React doesn’t ship with TypeScript declarations; we have to install them from DefinitelyTyped. All it takes is one little npm install.
npm install --save-dev @types/react @types/react-dom
JSX Transform
If we are using React 16.14 or React 17, we’ll want to make sure we have TypeScript 4.1 or later installed. These versions of React and TypeScript introduced a new JSX transform which adds a few benefits:
- It lets you use JSX in your code without importing React
- It can decrease the bundle size in certain circumstances
- It future-proofs your app for new features that are coming in future React versions.
We can configure TypeScript to use these new JSX transforms by setting the jsx
option in tsconfig.json. There are two options we can use:
-
react-jsx
, for compiling to production code -
react-jsxdev
, for development mode
Since there are two different options depending on our environment, it might make sense to create two tsconfig.json files. We can have our development tsconfig.json file extend our production configuration, so we don’t need to duplicate our settings.
// tsconfig.dev.json
{
"extends": "./tsconfig.json",
"compilerOptions": {
"jsx": "react-jsxdev"
}
}
Then we put all of our other configuration in tsconfig.json, including using the production JSX transform.
Legacy JSX Transform
If you are using an older version of React, you can configure the legacy JSX transform by setting the jsx
setting to react
. This will convert JSX syntax into the appropriate React.createElement
calls, but it requires that React is imported inside of every module.
// tsconfig.json
{
"compilerOptions": {
"jsx": "react"
// ...
}
}
If we are using the legacy JSX transform, we can change the JSX factory function from React.createElement
to something else. For example, if we are using Preact, we can set our JSX factory to the h
function.
// tsconfig.json
{
"compilerOptions": {
"jsx": "react",
"jsxFactory": "h"
// ...
}
}
Just like with React.createElement
, this factory function expects the h
function to be imported in every module that uses JSX.
import { h } from "preact";
const HelloWorld = () => <div>Hello</div>;
Fragments
You might remember that React.Fragment
is used to wrap adjacent elements. There is also a shorthand syntax which looks something like this:
const Hello = () => {
return (
<>
<h1>Hello</h1>
<h2>World</h2>
</>
);
};
This shorthand needs to be transformed to use the correct Fragment component, which can also be configured using jsxFragmentFactory
. Regardless of whether you are using the new or legacy transform, you’ll likely want this setting to be Fragment
.
// tsconfig.json
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxFragmentFactory": "Fragment"
}
}
.tsx Extension
The most important thing to remember when using React with TypeScript is that file extensions matter. TypeScript has some syntax which is incompatible with JSX syntax. To get around these conflicts, TypeScript only recognizes JSX inside of files that end with .tsx
; it also disables the conflicting TypeScript syntax in those files.
If you ever find strange, nonsensical errors in your JSX code, double check your extension.
Class Components
Let’s start by looking at an example of a React component that accepts an initial value for a counter, and has buttons to increase and decrease the number value.
class Counter extends React.Component {
constructor(props) {
super(props);
this.state = {
count: props.defaultCount,
};
}
render() {
return (
<div>
<h1>Count: {this.state.count}</h1>
<button
onClick={() =>
this.setState(({ count }) => ({
count: count - 1,
}))
}
>
-
</button>
<button
onClick={() =>
this.setState(({ count }) => ({
count: count + 1,
}))
}
>
+
</button>
</div>
);
}
}
Now take a look at where we call this component. Do you see anything wrong with this?
ReactDOM.render(
<Counter defaultCount="Hello" />,
rootElement
);
If we were to run this code, our initial count would be the string value “Hello”. Clicking the plus button would change it to “Hello1”, but clicking the minus button would change it to NaN
. This is not what we want.
With TypeScript, we can avoid these kinds of mistakes altogether. The React.Component class is generic, which means we can pass an interface to it which represents the props which the component accepts.
interface CounterProps {
defaultCount: number;
}
class Counter extends React.Component<CounterProps> {
constructor(props: CounterProps) {
// ...
}
// ...
}
Notice also that we also have to annotate the props
parameter of our constructor function.
This annotation allows us to constrain the properties which we can pass into this component, which makes TypeScript warn us when we accidentally use our component incorrectly.
ReactDOM.render(
<Counter defaultCount="Hello" />,
rootElement
);
// Type Error: Type 'string' is not assignable to type 'number'.
We talk much more about adding type definitions to props in a later section in the course.
Component State
We aren’t done adding types to our component yet. TypeScript is still giving us a warning everywhere that we are accessing this.state
. For example, on the line where we render our count
value:
class Counter extends React.Component<CounterProps> {
// ...
render() {
return (
<div>
<h1>Count: {this.state.count}</h1>
{/* Type Error: Property 'count' does not exist on type 'Readonly<{}>'. */}
{/* ... */}
</div>
);
}
}
By default, React.Component
uses a Readonly empty object as the type of this.state
. We can easily override that by assigning the state
property an interface as its type.
interface CounterState {
count: number;
}
class Counter extends React.Component<CounterProps> {
state: CounterState;
render() {
return (
<div>
<h1>Count: {this.state.count}</h1>
{/* ... */}
</div>
);
}
}
We still have one more problem, this time with our this.setState
calls.
class Counter extends React.Component<CounterProps> {
state:CounterState
render() {
return (
<div>
{/* ... */}
<button
onClick={() => {
this.setState(({count}) => ({ count: count - 1 }))
// Property 'count' does not
// exist on type 'Readonly<{}>'.
}}
>
-
</button>
)
}
}
Why is TypeScript warning us that the state parameter of this.setState
doesn’t have a count
property? Don’t we assign a type to this.state
in our class?
Well, yes, we do. But this.setState
uses a different mechanism for determining its parameter and return types. We also have to pass our state interface in as the second generic parameter of our generic React.Component
class, like so:
class Counter extends React.Component<
CounterProps,
CounterState
> {
// ...
}
Our generic parameters apply types to this.props
and this.setState
, but not to the parameters of class lifecycle methods, like shouldComponentUpdate
or componentDidUpdate
. You need to annotate those parameters with the interfaces you used for your component.
class Counter extends React.Component<
CounterProps,
CounterState
> {
// ...
componentDidUpdate(
prevProps: CounterProps,
prevState: CounterState
) {
// Do some kind of check against this.props and this.state
}
// ...
}
Class methods and properties work exactly the same way as they do in regular classes. For class properties, they just need a type annotation; for class methods, so long as you add annotations to method parameters, TypeScript will be able to type check how you use your class methods.
(Practice) Annotating Components
Below is a CodeSandbox link which has some TypeScript code. Your job is to add types to the class components so all of the red squiggles indicating errors go away.
Good luck!
Class Component Annotation CodeSandbox
(Solution) Annotating Components
Solution
Class Component Annotation CodeSandbox Solution
Function Components
If you know how add type annotations to a regular function, you are 99% of the way towards annotating a React function component. The only thing to remember is that React components all have to return either a React Element or null
, so it’s usually a good idea to add a return type annotation to your function so TypeScript warns us if we return the wrong thing.
We’ll use the React.ReactElement | null
type as our return type. This type represents the output of React.createElement
, which is what you get when you write any JSX. It also allows us to return null
if we don’t want our component to render anything.
import { ReactElement } from "react";
function HelloWorld(): ReactElement | null {
return <div>Hello world!</div>;
}
If you try to return something other than ReactElement
, like string or number, you might run into some trouble. We’ll talk about this when we dive into the built-in types that come with React.
Props can be annotated by giving our function parameters an annotation. Any JSX that calls our function component will be type checked against the type we assign to the props parameter.
function Message({
message,
}: {
message: string;
}): ReactElement {
return <div>A message: {message}</div>;
}
If you want to explicitly annotate a function as a React component, you can use the built-in React.FunctionComponent
type, or it’s alias React.FC
. This type includes annotations for common static properties that are added to function components, like displayName
, propTypes
, and defaultProps
. It also includes the children
prop in your prop definition, and has an explicit return type.
We can use FC
by annotating a variable that we assign an arrow function component. FC
and FunctionComponent
are generic types which accept a type representing the props.
import { FC } from "react";
const Message: FC<{ message: string }> = ({
message,
children,
}) => {
return (
<div>
A message: {message}
<br />
Children: {children}
</div>
);
};
If you are planning on doing anything unique or special with the type of children
, you likely want to annotate it directly; in that case, skip using React.FC
. We discuss all of the ways to annotate the children
prop later in the course. For now, use whatever style you prefer.
Managing state and lifecycles in function components is done with hooks. Since hooks are a topic unto themselves, we’ll cover them in a later section of the course.
Component and Element Types
Before we go much further, we need to be clear about the different kinds of things that we work with as we write JSX.
Elements
The most often used function in React is React.createElement
, but you don’t normally see it. That’s because our JSX code is transformed into React.createElement
calls. This function turns a component into an element, which can then be rendered to the DOM (or wherever else) using ReactDOM.render
. When you call React.createElement
, you pass in whatever props you want your element to have. It can then begin maintaining its own state.
Here are a few elements, and the results of calling console.log
on one of them.
const helloElement = <Hello />;
const worldElement = React.createElement(World);
// {
// '$typeof': Symbol(react.element),
// type: [Function: World],
// key: null,
// ref: null,
// props: {},
// _owner: null,
// _store: {}
// }
These React elements represent a single, unique immutable object that is returned from a component. If we were to look at the return type of React.createElement
, we would see that it is React.Element
, which makes perfect sense. This is the case whether we are using React.createElement
directly, or using JSX.
We can use ReactElement
as a prop.
function RenderElement({
element,
}: {
element: ReactElement;
}) {
return <div>{element}</div>;
}
Since our element already is an element, we don’t need to wrap it in JSX brackets - we can just plop it right into our component.
ReactElement
is an object shaped like what we saw up above, with key
, props
, ref
, etc. What about components that return something other than ReactElement
, like a string or array of numbers? If we were to try putting one of these components inside a JSX block, TypeScript would give us a warning.
function RenderString() {
return "Hello world!";
}
function App() {
return (
<div>
<RenderString />
{/* Type Error: 'RenderString' cannot be used as a JSX component.
Its return type 'string' is not a valid JSX element */}
</div>
);
}
To get this to work, we need to convince TypeScript that our string is actually a ReactElement
. To do that, we could assert that our string is unknown
, and then assert that it is a ReactElement
.
If you remember doing this from the TypeScript course, you know that double type assertions are dangerous . We are lying to the type checker, and if we’re wrong about the types, we could have a very hard time tracking down where our runtime type errors are coming from. We can give ourselves just a bit more type safety by asserting our string is a type that is common between ReactElement
and strings: ReactNode
. This is a type built into React’s type definition that represents anything that can be used as a child of a React component, including strings and arrays.
import { ReactNode, ReactElement } from "react";
function RenderString() {
return ("Hello world!" as ReactNode) as ReactElement;
}
Now, if we use our component in JSX, the type errors have gone away.
There is a slightly easier way to solve this. Just wrap our string in a React fragment to turn it into a ReactElement
.
import { Fragment } from "react";
function RenderString() {
return <Fragment>Hello world!</Fragment>;
}
How you handle this is up to you. Just know that you have a few options.
Components
A React component is a class or function which returns a React Node. Components define what props you can pass and what state can be held by the component, but the component itself doesn’t hold state. It’s just a template. Since you can call a function or instantiate a class multiple times, React components are intended to be used multiple times.
These are components:
class Hello extends React.Component {
render() {
return <div>Hello</div>;
}
}
const World = () => {
return <div>World</div>;
};
These two component definitions, a class component and a function component, can be represented with a single type built into React’s type definitions: React.ComponentType<P>
, with P
being the props of the component. Unsurprisingly, the type definition for React.ComponentType<P>
is just a union of a class component and function component definition.
type ComponentType<P = {}> =
| ComponentClass<P>
| FunctionComponent<P>;
This next example is a little contrived, but it does show one way you might use ComponentType
.
We can create a component that accepts another component as a prop, and renders that component as an element. We can use ComponentType
to annotate our Comp
prop.
function RenderComponent({ Comp }: { Comp: ComponentType }) {
return (
<div>
<Comp />
</div>
);
}
We’ll talk more about using components as props later in the course.
(Bonus) Intrinsic Elements
In React, intrinsic elements represent the basic units that React uses to render a component with a particular renderer. In ReactDOM, these are div
, span
, and all the other HTML elements.
Intrinsic elements don’t really exist in React Native; instead, you import components directly from React Native that are translated to the native elements. This lesson only focuses on React DOM.
The type declarations for React come with special types which can help us work with these intrinsic elements.
The React type declarations add a namespace called JSX
to the global. We can access this anywhere that we are using React. This is where the type declarations for intrinsic elements comes from.
For example, if you were to hover over the <button>
element in this example using VS Code, you would see that it is an instance of JSX.IntrinsicElements.button
which defines the props for that element. For example, <button>
can have a type
and disabled
prop, but not a width
prop.
const MyButton = () => {
return <button>Click Me!</button>;
};
If we dive into the definition for JSX.IntrinsicElements.button
, we would seem something that looks like this:
interface IntrinsicElements {
// ...
button: React.DetailedHTMLProps<
React.ButtonHTMLAttributes<HTMLButtonElement>,
HTMLButtonElement
>;
// ...
}
There’s a lot going on here, so lets break it down.
HTMLButtonElement
is a type that represents a button in the DOM. It’s part of the standard library that is built into TypeScript. React is using to to say “This intrinsic element renders as this DOM node.” If we were to attach a ref to this element, HTMLButtonElement
is the type that would be assigned to ref.current
.
Then, we have the React.ButtonHTMLAttributes
generic. Here’s what that looks like:
interface ButtonHTMLAttributes<T> extends HTMLAttributes<T> {
autoFocus?: boolean;
disabled?: boolean;
form?: string;
formAction?: string;
formEncType?: string;
formMethod?: string;
formNoValidate?: boolean;
formTarget?: string;
name?: string;
type?: "submit" | "reset" | "button";
value?: string | string[] | number;
}
This interface extends the HTMLAttributes
interface, and creates prop definitions for all of the attributes that are unique to buttons. HTMLAttributes
has prop definitions for all of the attributes which are common among HTML elements.
You might be wondering “So what are we doing with that generic T
there?” Recall that T
represents the HTMLButtonElement
type. If we follow the types, so to speak, we eventually end up at the DOMAttributes
type, which has definitions for all of the events that you can attach to DOM nodes. Here’s what onClick
looks lke.
interface DOMAttributes<T> {
// ...
onClick?: MouseEventHandler<T>;
// ...
}
Aha! Now we are getting somewhere. And what is the definition for MouseEventHandler
?
type MouseEventHandler<T = Element> = EventHandler<MouseEvent<T>>;
Remember, React uses a synthetic event system which is an abstraction on top of the regular DOM event system. This helps with cross-browser compatibility and makes it so events all behave consistently. These types here model that synthetic event system. The EventHandler
type is just a function that accepts an event object as a parameter, and returns void, like this:
type EventHandler<E extends SyntheticEvent> = (e: E) => void;
SyntheticEvent
is another interface with the properties common of all events, like e.preventDefault()
.
MouseEvent
extends SyntheticEvent
and adds a few more properties which are exclusive of MouseEvents
.
interface MouseEvent<T = Element, E = NativeMouseEvent>
extends SyntheticEvent<T, E> {
// ...
button: number;
buttons: number;
clientX: number;
clientY: number;
// ...
}
And that’s about as deep as we can get in our type spelunking.
To recap, the type definitions for Intrinsic Elements are built into the React type declarations, and include all of the DOM elements that you could possibly use. Some intrinsic elements, like <button>
have unique prop definitions which match the attributes you can assign to an actual HTMLButtonElement. All intrinsic elements have common properties, including event handlers. And, depending on which event handler you are using, the event parameter might have some extra properties, like how onClick
’s event parameter has properties for which mouse button did the clicking.
This lesson is just an explainer, designed to give you an in-depth look at these types. In a later section, we’ll learn about how we can use these event types as we’re working with forms and events.
useState & useReducer
There are two hooks for storing state: useState
and useReducer
.
useState
useState
is simple. The state value’s type is the type of whatever you pass in to the useState
function.
const [stringState, setStringState] = useState("Hello!");
const [numberState, setNumberState] = useState(5);
Sometimes you need to tell useState
exactly what type your state should be, such as when you don’t pass in an initial value, or you want to use a union type. useState
is a generic function, so we can pass the type in before we call the function.
// This can only be undefined
const [
uninitializedState,
setUninitializedState,
] = useState();
// This can be a string or undefined
const [stringState, setStringState] = useState<
string | undefined
>();
// Its more common to initialize with null
const [maybeNumberState, setMaybeNumberState] = useState<
string | null
>(null);
Of course, you can put whatever kind of thing you want in there - arrays, functions, classes - you name it. Just make sure you pass a type into the generic function.
When working with component composition, it’s common to pass the state and state setter function as props. If we only ever intend to pass a literal value to our state setter function, then annotating that function is easy - pass in value as a parameter; return void
;
interface UpstreamComponentProps {
stringState: string;
setStringState: (newValue: string) => void;
}
This gets a bit tricker if we need to use an update function with our state setter. In this case, it’s probably easier to use the built-in type which React uses.
In VS Code, we can hover over setStringState
to see that its type is Dispatch<SetStateAction<string>>
(replacing string
with whatever the actual type is). Both Dispatch
and SetStateAction
are built into React’s type declarations, so we can use them in our props interface.
interface UpstreamComponentProps {
stringState: string;
setStringState: Dispatch<SetStateAction<string>>;
maybeNumberState: number | null;
setMaybeNumberState: Dispatch<
SetStateAction<number | null>
>;
}
useReducer
If you need a bit more control over how your state is updated, or if you have several pieces of state that update in concert, useReducer
can really come in handy.
Quick recap on reducers. We have state, which is usually an object (although it can be any type). useReducer
gives us our state and a dispatch
function. We call dispatch
with an action, which is also usually an object. Dispatch then calls our reducer function with our current state and our action. The results of that reducer function become our new state.
That means we’ll need to create three things to use useReducer
:
- A reducer function
- A state type
- An action type, which is a union of several types representing each of the possible actions.
For this example, we’ll create a shopping list that separates our grocery items by category. Our state will be an array of objects that conform to the ShoppingListItem interface, so lets create that.
type Category =
| "Bread"
| "Fruit"
| "Vegetable"
| "Meat"
| "Milk";
interface ShoppingListItem {
id: string;
title: string;
completed: boolean;
category: Category;
}
type ShoppingListState = ShoppingListItem[];
Next, we’ll create the interfaces for our actions. Each action should include a property which identifies the action, such as ‘add’, ‘edit’, ‘delete’, or ‘complete’. Actions should also have any additional data that is needed to perform the action, such as the ID of the ShoppingListItem that we are completing.
Once we’ve created all of our action interfaces, we can create a discriminating union of them, which makes it easy to know what type of action we are working with just by checking the type property.
interface AddAction {
type: "add";
category: Category;
}
interface EditAction {
type: "edit";
id: string;
title: string;
}
interface DeleteAction {
type: "delete";
id: string;
}
interface CompleteAction {
type: "complete";
id: string;
}
export type ShoppingListAction =
| AddAction
| EditAction
| DeleteAction
| CompleteAction;
Finally, we can create our shopping list reducer function. Remember, this reducer takes our state interface and a union of actions as its parameters. We’ll use a switch
statement to determine what action was passed in, and modify our state array accordingly.
function shoppingReducer(
state: ShoppingListState,
action: ShoppingListAction
): ShoppingListState {
switch (action.type) {
case "add":
return state.concat({
// Good enough random ID generator.
id: `${Math.random()}-${Date.now()}`,
title: "",
category: action.category,
completed: false,
});
case "complete":
return state.map((item) => {
if (item.id === action.id) {
return { ...item, completed: true };
}
return item;
});
case "delete":
return state.filter((item) => item.id !== action.id);
case "edit":
return state.map((item) => {
if (item.id === action.id) {
return { ...item, title: action.title };
}
return item;
});
default:
return state;
}
}
Now here comes the cool part. The useReducer
type definition has a few function overloads, but the most common takes two parameters: our reducer function, and the initial state.
const [state, dispatch] = useReducer(shoppingReducer, []);
Wait, don’t we need to annotate useReducer at all? In this case, we don’t. TypeScript has pulled the type of our state and actions out of the parameters of shoppingReducer
and used those as the types of state
and dispatch
.
Actually, in the case of dispatch
, the type is Dispatch<ShoppingListAction>
which should look familiar. This is the same shape as the state setter function for useState
, except it uses our action instead of SetStateAction
. That makes it really easy to pass our dispatch
function to other components using props or context.
Now, if we use our dispatch
function incorrectly, TypeScript can give us a warning.
dispatch({ type: "explode" });
// Type Error: Type '"explode"' is not assignable to
// type '"add" | "edit" | "delete" | "complete"'
(Practice) useReducer
Below is a CodeSandbox link which has some TypeScript code. Your job is to add types and implement the reducer function used in the useFetch
hook so the app runs correctly. Follow the instructions in the App.tsx
file.
Good luck!
(Solution) useReducer
Solution
useReducer CodeSandbox Solution
useEffect, useMemo, useCallback
These next three hooks are relatively similar, and fairly simple, so this lesson should go quick.
useEffect
Lets take a look at the type definition for useEffect
.
function useEffect(
effect: EffectCallback,
deps?: DependencyList
): void;
type DependencyList = ReadonlyArray<any>;
type EffectCallback = () => void | (() => void | undefined);
This type definition tells us three things:
-
useEffect
takes a function, called EffectCallback, as it’s first parameter. - EffectCallback has to return either
void
, or a cleanup function. That cleanup function has to returnvoid
. -
useEffect
can optionally take a list of dependencies, which is just an array ofany
.
The only thing to keep in mind when using useEffect
in TypeScript is that the EffectCallback has to return void
, which means it returns nothing, or a cleanup function. If we do return a cleanup function, it can’t return anything either.
This only really becomes a problem if we are using an implicit return with an arrow function, like this:
useEffect(() =>
fetch("https://example.org", { method: "POST" })
);
// Type Error: Type 'Promise<Response>' is not assignable to type 'void | (() => void | undefined)'
Usually it’s best to use the non-implicitly returning form of arrow functions with useEffect
.
useEffect(() => {
fetch("https://example.org", { method: "POST" });
});
// No problem!
It’s also worth noting that the seldom-used useLayoutEffect
hook has exactly the same type definition as useEffect
.
useMemo
Since it’s short, we’ll also look at the type definition for useMemo
:
function useMemo<T>(
factory: () => T,
deps: DependencyList
): T;
useMemo
is a generic function. We pass it a function, and whatever that function returns is the return value of useMemo
. I’ve never had a situation where TypeScript didn’t properly infer the value of T
based on what I passed in, so you’ll likely never have to assign a type to T
The only thing to note here is that the dependency list for useMemo
is not optional - you are required to provide one. Otherwise, what’s the point of using useMemo
? Without a dependency list, the value returned from useMemo
will be referentially different between renders.
useCallback
useCallback
is just like useMemo
, except it only memoizes functions for us. Here’s the type definition for useCallback
:
function useCallback<T extends (...args: any[]) => any>(
callback: T,
deps: DependencyList
): T;
This is mostly the same as useMemo
except the generic type that represents the return value is constrained to be a function. That’s because useCallback
is specifically used for memoizing functions, so naturally we have to pass a function in.
Like useMemo
, we also must provide a dependency list. Otherwise, we’ll get a warning. It’s just TypeScripts way of trying to help you out.
Common Props Patterns
Props is always an object. This makes it really easy to annotate props with a type, since we can use any of the usual techniques for objects when writing our props annotation.
I’ll use interfaces for my examples, but the same principle applies to type aliases or literal object types.
Here’s a component with a bunch of different kinds of props.
<FruitBasket
fruitType="apple"
maxFruit={5}
disabled
fruit={[
'red delicious',
'granny smith'
]}
>
Now imagine we were to take those props and put them into an object. It would look something like this:
const props = {
fruitType: "apple",
maxFruit: 5,
disabled: true,
fruit: ["red delicious", "granny smith"],
};
This is the object that will be passed in to our FruitBasket component as props. If we were to annotate that object, we would need an interface that looks like this:
interface FruitBasketProps {
// This could be a string, but I'm using a union of strings
// to constrain what the possible values are.
fruitType: "apple" | "orange" | "banana";
maxFruit: number;
disabled?: boolean;
fruit: string[];
}
const FruitBasket = (props: FruitBasketProps) => {
// ...
};
There’s nothing special about it. TypeScript is smart enough to translate the props in the JSX into a props object and check that object against the interface we created. This includes the union type, the array, and the function.
Notice that disabled
is an optional property in our interface. This is important, since we often want to omit certain props from the JSX for brevity. It would be annoying if we always had to write:
<FruitBasket disabled={false} />
Since the absence of a prop is considered undefined
, we can treat it as falsy in our component code and safely mark it as optional. We could do the same thing for non-boolean types by assigning a default value in our function parameters.
interface FruitBasketProps {
// This could be a string, but I'm using a union of strings
// to constrain what the possible values are.
fruitType?: "apple" | "orange" | "banana";
maxFruit: number;
disabled?: boolean;
fruit: string[];
addFruit: (fruitName: string) => void;
}
const FruitBasket = ({ fruitType = "apple", ...props }: FruitBasketProps) => {
// ...
};
TypeScript will even recognize indexable types, allowing us to put whatever props we want on a component.
interface ListerProps {
[key: string]: string;
}
const Lister = (props) => {
return (
<ul>
{/* List all of the keys and values in the props object. */}
{Object.entries(props).map(([key, value]) => (
<li key={key}>
<strong>{key}</strong>: {value}
</li>
))}
</ul>
);
};
<Lister item1="Hello" item2="World" />;
It’s possible to use any
, unknown
, object
, or Function
in our props interfaces as well, and TypeScript will allow it. However, that runs the risk to introducing runtime type errors. As usual with TypeScript, it’s best to be as specific as possible as you are writing your type annotations for your components.
Event Handlers
Earlier in the course when we learned about intrinsic elements, we learned that event handlers are built into each intrinsic element. In fact, in React DOM, only intrinsic elements fire events; any other component that accepts an onClick
or onKeydown
prop will treat it like any other prop.
If we are passing an event handler directly to an intrinsic element, TypeScript will infer the type of the function’s parameters for us.
<button onClick={(event) => {
// (parameter) event: React.MouseEvent<HTMLButtonElement, MouseEvent>
}}>
TypeScript automatically knows that onClick
is a mouse event and that its target is an HTMLButtonElement
. This is the easiest way to use event handlers.
If you need to write out your event handler function separately, such as if we are passing the event handler as a prop, you have a few options for adding the appropriate types.
First, the only type that really matters is the event
parameter’s type; all React event handlers have a void
return type.
If we were to take advantage of our IDE, we could easily write out our event handler inline, like we did above, and then grab and use the type annotation which TypeScript infers. Here’s what that would look like for an onChange
event handler.
const App = () => {
function handleOnChange(event: React.FormEvent<HTMLInputElement>):void {
// ...
}
return <input type="text" onChange={handleOnChange}>
}
Alternatively, you can use one of the many “EventHandler” types that are built into React’s type definition, including ChangeEventHandler
, MouseEventHandler
, and PointerEventHandler
. These are generic types that accept an element type and output a function type matching whatever event handler you need.
Here’s that same onChange
handler, but typed with ChangeEventHandler
instead.
const App = () => {
const handleOnChange:ChangeEventHandler<HTMLInputElement> = (event) => {
// ...
}
return <input type="text" onChange={handleOnChange}>
}
Notice, we don’t have to annotate anything inside the function itself; our ChangeEventHandler
type takes care of that for us.
Both of these are functionally equivalent - use whichever matches your preferences.
Sometimes, the properties on the event
object aren’t complete. One glaring example of this is form submit events. Take this simple form, for example:
<form onSubmit={handleSubmit}>
<div>
<label>
Email:
<input type="email" name="email" />
</label>
</div>
<div>
<label>
Message:
<textarea name="message"></textarea>
</label>
</div>
</form>
Inside handleSubmit
, the event.target
property should represent the <form>
element, and should have separate properties for each of the named inputs in the form. However, TypeScript isn’t quite clever enough to infer that. We need to widen our target
value to include those properties. We can do that using type widening.
Type widening is a powerful tool which can help you add properties to objects type definitions. It works by creating an intersection type between the original type of the object and an interface representing the properties you want to add.
interface FormFields {
email: HTMLInputElement;
message: HTMLTextAreaElement;
}
function handleSubmit(
event: React.FormEvent<HTMLFormElement>
) {
event.preventDefault();
const target = event.target as typeof event.target &
FormFields;
const formValues = {
email: target.email.value,
message: target.message.value,
};
// Do whatever with the form values.
}
Notice that I’m able to maintain the original type of event.target
by starting my intersection with typeof event.target
. This lets me add additional fields to event.target
without changing the other fields.
In some cases, we might want to use the same event handler with two different kinds of elements, such as buttons and anchor tags. In this case, it might make more sense to assert that our target is a union of the two element types.
function handleClick(
event: React.MouseEvent<
HTMLButtonElement | HTMLAnchorElement
>
) {
const target = event.target as
| HTMLButtonElement
| HTMLAnchorElement;
// If target has an `href` property, we know it's an anchor
if ("href" in target) {
target.href;
}
}
All of the properties common to both buttons and anchors would be available without needing to type narrow; if we need to access a property unique to one of the element types, we can use the in
operator to check for the existence of the property to narrow our type.
Advanced Props Patterns
Children
Earlier in the course, we used React.ReactElement
as the return type for our function components. That type represents a React component, so naturally we would think that is the type we should use for the children
prop.
There’s only one problem: children
can be much more than just ReactElement
s. Observe:
const Layout = ({ children }: { children: ReactElement }) => {
return <div>{children}</div>;
};
const App = () => {
return <Layout>Hello there!</Layout>;
// Type Error: 'Layout' components don't accept text as child elements.
// Text in JSX has the type 'string', but the expected type of 'children' is 'ReactElement'
};
The children
prop of Layout
is definitely not a ReactElement
; it’s a string
. string
isn’t compatible with ReactElement
, so if we want to use a string
as children for this component, we’ll need to use a union type for our children
prop.
type ReactChildrenProp = ReactElement | string;
We might as well add number
to the list too, since it’s possible to put a number in as children.
const App = () => {
return <Layout>{100}</Layout>;
};
How about boolean
s, null
, and undefined
? Well, TypeScript doesn’t try to render those out as text. In fact, if you pass any of those to the children
prop, it just ignores it and renders nothing. Yes, even true
doesn’t get rendered as text.
Now we’ve got a type that looks something like this:
type ReactChildrenProp =
| ReactElement
| string
| number
| boolean
| null
| undefined;
As it turns out, there already is a type that matches what we have there: ReactNode
.
type ReactNode = ReactChild | boolean | null | undefined;
type ReactChild = ReactElement | ReactText;
type ReactText = string | number;
If you want the most flexibility for your children
prop, use ReactNode
. In fact, if you annotate your components with React.FC
, that’s the type that it uses for children
by default.
Of course, there might be times that we want to constrain the type of the children
prop, such as with these components.
const DoubleNumber = ({ children }: { children: number }) => {
return <>{children * 2}</>;
};
const UppercaseString = ({
children,
}: {
children: string;
}) => {
return <>{children.toUpperCase()}</>;
};
<>
<DoubleNumber>{100}</DoubleNumber>
<UppercaseString>Hello there!</UppercaseString>
</>;
Note that we can’t constrain everything about our children
prop. For example, since every component eventually becomes a ReactElement
before being passed in as children
, there’s no way to know specifically what React Component created that ReactElement
.
Render Props
A Render Prop is technically any prop that accepts a function that returns a ReactNode
. It’s often used for sharing code between React components. In fact, React itself uses a render prop for the <Context.Consumer>
component.
React Hooks solve the same problems that render props solve, but you might find some situations where a render prop is the right choice.
Implementing render props can be a little tricky, but writing the type annotation for them is very simple. Suppose we had a component that calculated the current mouse position and provided those in render prop form. Consuming this component might look something like this:
// Render prop style
<MousePosition render={({x,y}) => <div>{x}, {y}</div>} />
// Children prop style
<MousePosition>{({x,y}) => <div>{x}, {y}</div>}</MousePosition>
The type annotation isn’t too complicated at all. We’ll mark the render
and children
props as optional, and have them both be functions that have a mouse position interface as the parameter and return a ReactNode
.
import { ReactNode, FC } from "react";
interface MousePositions {
x: number;
y: number;
}
interface MousePositionProps {
render?: (MousePositions) => ReactNode;
children?: (MousePositions) => ReactNode;
}
const MousePosition: FC<MousePositionProps> = ({
render,
children,
}) => {
// ...
};
Style
The style
prop lets us adjust the CSS of a specific intrinsic element. If you ever need to annotate the style
prop, you can use the React.CSSProperties
type to do that.
import { CSSProperties } from "react";
const Button: React.FC<{ style: CSSProperties }> = ({
style,
children,
}) => {
return <button style={style}>{children}</button>;
};
We can use TypeScript’s utility types to make it so only certain style attributes can be assigned through props.
type AllowedStyles = "display" | "backgroundColor";
const Button: React.FC<{
style: Pick<CSSProperties, AllowedStyles>;
}> = ({ style, children }) => {
return <button style={style}>{children}</button>;
};
<Button style={{ fontSize: 24 }}>Click me!</Button>;
// Type Error: Type '{ fontSize: number; }' is not assignable to type 'Pick<CSSProperties, AllowedStyles>'
Mirroring HTML Elements
In the above example, we created a <Button>
component that renders the <button>
HTML Element. We’re only passing two props to our child button, but we likely want to pass all of the props that are available to the <button>
element.
React gives us a utility type which extracts the props from an HTML element. Often, if we are creating a component that wraps an intrinsic element, we likely want to add more props that control some special behavior. We can use type widening to combine our props with the intrinsic props by either creating an intersection type with &
or creating an interface that extends the element’s props.
import { ComponentPropsWithoutRef } from "react";
// Option 1
type ButtonProps = {
variant?: "primary" | "success" | "warning" | "danger";
} & ComponentPropsWithoutRef<"button">;
// Option 2
interface ButtonProps
extends ComponentPropsWithoutRef<"button"> {
variant?: "primary" | "success" | "warning" | "danger";
}
const Button: React.FC<ButtonProps> = ({
variant,
className = "",
...props
}) => {
return (
<button
{...props}
className={`${className} ${variant}`}
/>
);
};
Notice when we pull ButtonProps
out of the intrinsic button
element, we pass it a string. We can use a string for any of the intrinsic elements. If we want to pull the props out of a non-intrinsic component, we just have to pass the type of that component in, like so:
type ButtonComponentProps = ComponentPropsWithoutRef<
typeof Button
>;
// type ButtonComponentProps = {
// variant?: "primary" | "success" | "warning" | "danger";
// etc. etc...
// }
If we wanted to restrict which props the consumer of our component can assign to this element, we could also use TypeScripts utility types to Pick or Omit properties from the ButtonProps
interface.
We’ll talk more about forwardRef
in a later section in the course, but if we wanted to pull off all of the props that a component has including a ref assigned to it, we could use ComponentPropsWithRef
. This does the same thing, but includes ref
in the props definition if applicable.
(Bonus) PropTypes
The PropTypes package lets you assign types to the props that are passed into your components without using TypeScript. In fact, you might have spent this entire section wondering why we need to add type definitions to our component’s props when PropTypes can already give us type checking.
TypeScript is a static type checker. It looks at all of your code to make sure the types of your properties, variables, and parameters all match up. Since it checks your code without running it, it can give you hints and warnings in your IDE which can help you as you write your code.
PropTypes, on the other hand, does runtime checking of the types of your props. In development mode, PropTypes prints a warning to the console if you use the wrong type for one of your props. It can’t help you as you write your code, though; only when you run it.
Using both of these tools together can be helpful. For example, if you are writing a library, it’s possible that users of your library aren’t using TypeScript. In that case, assigning PropTypes to your components can help them catch bugs.
PropTypes are assigned as a static property to class components, or as a property directly on function components. We’ll use the FruitBasketProps
to demonstrate using TypeScript and PropTypes together.
import { Component, FC } from "react";
import PropTypes from "prop-types";
interface FruitBasketProps {
fruitType: "apple" | "orange" | "banana";
maxFruit: number;
disabled?: boolean;
fruit: string[];
addFruit: (fruitName: string) => void;
}
const FruitBasketPropTypes = {
fruitType: PropTypes.oneOf(["apple", "orange", "banana"])
.isRequired,
maxFruit: PropTypes.number.isRequired,
disabled: PropTypes.bool,
fruit: PropTypes.arrayOf(PropTypes.string),
// Notice it can't check for the presence of parameters like TypeScript can.
addFruit: PropTypes.func,
};
const FruitBasket: React.FC<FruitBasketProps> = (props) => {
// ...
};
FruitBasket.propTypes = FruitBasketPropTypes;
class FruitBasket extends Component<FruitBasketProps> {
static propTypes = FruitBasketPropTypes;
// ...
}
Using PropTypes isn’t as important when we’re working in TypeScript, since TypeScript can validate our props as we write our code using more sophisticated type checking. Still, you might find it helpful to include PropTypes, especially if JavaScript users are consuming your components.
(Practice) Props
Below is a CodeSandbox link which has some TypeScript code. Your job is to annotate the props of the various React components so the red squiggles indicating errors go away.
Good luck!
Props Annotation CodeSandbox (Solution) Props
(Solution) Props
Solution
Props Annotation CodeSandbox Solution
Context
React Context lets you pass values from a parent component to any of the children components. The way context is implemented makes it possible for the type definition of your context to be available wherever you consume it.
We create context using React.createContext
. We have to pass in a default value. This is the value the context provides if it is accessed outside of a <Context.Provider>
tree. The type of the entire context is inferred from the type of the default value.
Often, the default value is null
or undefined
. If we need to set an explicit type for our context, we can pass it in as a generic type.
import {
createContext,
Dispatch,
SetStateAction,
} from "react";
interface ThemeModeInterface {
mode: "dark" | "light";
setMode: Dispatch<SetStateAction<"dark" | "light">>;
}
const ThemeModeContext = createContext<ThemeModeInterface | null>(
null
);
This has one substantial downside - we have to check if the value of our context is null
every time we access it, regardless of whether we are in a Provider tree or not. However, we can get around these limitations with a bit of TypeScript slight of hand.
One option is to indicate that our default value is non-null with the non-null assertion operator.
const ThemeModeContext = createContext<ThemeModeInterface>(
undefined!
);
Another option is to assert that an empty object is actually our interface.
const ThemeModeContext = createContext(
{} as ThemeModeInterface
);
In both of these cases, we are sacrificing type safety. If we try to access our context outside of the Provider tree, we’ll likely run into runtime type errors.
One way to approach this is by creating a prescribed method of accessing that particular context that checks if the context value is actually set. Here’s an example that uses the useContext
hook.
export const useThemeMode = () => {
const themeMode = useContext(ThemeModeContext);
if (!themeMode?.mode)
throw new Error(
"The theme mode context was accessed outside of the provider tree"
);
return themeMode;
};
This is typically paired with a component that manages the context value and returns the context provider.
export const ThemeModeProvider: FC = ({ children }) => {
const [mode, setMode] = useState<"dark" | "light">("light");
// Memoize the context value to avoid unnecessary re-renders
const contextValue = useMemo(() => ({ mode, setMode }), [
mode,
setMode,
]);
return (
<ThemeModeContext.Provider value={contextValue}>
{children}
</ThemeModeContext.Provider>
);
};
By only exporting the provider and the useThemeMode
hook, we can ensure that an error will be thrown if the context is accessed outside of the provider tree.
If we access our context using the useContext
hook or the ThemeModeContext.Consumer
render prop, TypeScript will give us the type which we initialized our context with; this isn’t the case with the static contextType
property on class components, though - this.context
will always need to be annotated directly on the class.
Refs
In class components, refs are only used to store references to the underlying HTML elements which our intrinsic elements render to the page. We create the ref using React.createRef
and assign it to an instance property on our component. When we assign the ref to an intrinsic element, React puts the DOM node into our ref.
class MyComponent extends React.Component {
private myRef = React.createRef();
render() {
return <div ref={this.myRef} />;
// Type Error: Type 'RefObject<unknown>' is not assignable to type 'RefObject<HTMLDivElement>'
}
}
TypeScript isn’t able to infer the type of our ref from how we use it. It has no way of knowing that we are assigning our ref to an HTMLDivElement
. Because of that, whenever we are using the ref to get a reference to a DOM element, we have to pass an HTML element type in as the generic argument for createRef
. Then the warning will go away.
class MyComponent extends React.Component {
private myRef = React.createRef<HTMLDivElement>();
render() {
return <div ref={this.myRef} />;
}
}
Now, if we access this.myRef.current
, it will be an HTMLDivElement
and we’ll have type safety as we work with it.
Refs with hooks is a little more nuanced. Function components use refs both for holding references to DOM elements and storing bits of data that don’t affect rendering, like class component instance properties. The way we use the useRef
hook is slightly different in each case.
If we’re collecting a reference to a DOM element, we likely want to initialize our ref with null
; this mimics the behavior of createRef
. Also like createRef
, we need to provide a type to the generic that represents the type of the ref’s contents.
With strictNullChecks
turned on, we’ll also need to verify that our ref is set before we try to access it.
const Form = () => {
const inputRef = useRef<HTMLInputElement>(null)
const onClick = () => {
if (inputRef.current) {
inputRef.current.focus()
}
}
return <input type="text" ref={inputRef} onClick={onClick}>
}
Using refs for any other value, such as arbitrary strings or objects, works exactly the same as useState
- whatever we pass as the initial value of the ref determines the type of the ref. If we need to override that, we can provide a type to the generic.
const App = () => {
const stringRef = useRef("Hello there!");
const maybeNumberRef = useRef<number | null>(null);
// ...
};
Forwarding Refs
Ref forwarding lets you pass a ref through a component to one of its children. It’s not very common, but at very least you can enjoy type safety while you use it!
When we wrap our component in React.forwardRef
, we have to provide generic types for the ref itself and for the wrapped component’s props. The ref type is defined first , followed by the props, even though the function parameters put the ref after the props.
We have to provide the props type even if we’ve already defined those types in the function declaration. Typically, you’ll have the easiest time if you wrap your component as you define it, like this:
import { forwardRef } from "react";
const Input = forwardRef<HTMLInputElement, { disabled?: boolean }>(
({ disabled }, ref) => {
return <input ref={ref} disabled={disabled} />;
}
);
(Project) Introduction
This is the introduction for the project. You can find the completed project here: https://github-battle.ui.dev. This project takes the project from the React Hooks course and converts it to TypeScript. You have three options:
- Follow the project videos and code along as I make the conversions.
- Try converting the project yourself, only referencing the videos after you’ve completed that part of the conversion yourself.
- Rewrite the entire project from scratch using TypeScript instead of JavaScript. This option will be the most difficult, but will give you the greatest experience and appreciation for how TypeScript can help you as you write your code.
Good Luck!
(Project) TypeScript Configuration
You can find the starting place for this video here: Configuring TypeScript Start
The Github commit for the completed code can be found here: Github Commit
(Project) API
You can find the starting place for this video here: API Start
The Github commit for the completed code can be found here: Github Commit
(Project) Hooks, Context, Tooltip
You can find the starting place for this video here: useHover, Tooltip, Context Start
The Github commit for the completed code can be found here: Github Commit
(Project) Battle
You can find the starting place for this video here: Battle Start
The Github commit for the completed code can be found here: Github Commit
(Project) Card, Nav, Loading
You can find the starting place for this video here: Card, Loading, Nav Start
The Github commit for the completed code can be found here: Github Commit
(Project) Popular
You can find the starting place for this video here: Popular Start
The Github commit for the completed code can be found here: Github Commit
(Project) Results
You can find the starting place for this video here: Results Start
The Github commit for the completed code here: Github Commit
The Github branch for the completed project can be found here: Completed Project
What’s Next?
If you made it this far, I’m incredibly humbled that you’d take the time to go through this course. As you can imagine, it was no small task to create, but I’m hopeful it’s been worth your time. The question now is, where do you go from here? As always, the answer is “Go build stuff!”. Specifically, head over to Github and start working on the curriculum. Don’t skip this part! It’s critical that you put what we’ve talked about into practice.