Introduction to TypeScript in React: A Beginner's Guide
Dive into TypeScript basics for React! From typing props to custom hooks, this guide offers beginner-friendly insights to elevate your React development.
Why Typescript in React
TypeScript is a superset of JavaScript that adds static typing on top of JavaScript. TypeScript, when used with React, offers numerous benefits. These include static typing, which catches bugs during development/runtime, intelligent autocompletion, and self-documenting code with type annotations. Developers can understand the intent of a component without checking the implementation details, which increases productivity and enables smoother collaboration in the long run.
Note: When using TypeScript with React.js, you need to let TypeScript handle the types for you as much as possible. Only step in and define types when TypeScript cannot figure out types on its own.
Typing Props
Many of the components we write will accept the prop object, usually named NameOfComponentProps
.
Let's see an example of a single todo component which accepts todo data and renders a todo component.
type TodoProps = {
id: number;
title: string;
completed: boolean;
};
export const Todo = (props: TodoProps) => {
return (
<div>
<h4>{props.title}</h4>
<p>{props.completed ? "Completed" : "Not Completed"}</p>
</div>
);
};
Instead of writing props
every time, we can destructure properties from our props.
type TodoProps = {
id: number;
title: string;
completed: boolean;
};
export const Todo = ({ title, completed }: TodoProps) => {
return (
<div>
<h4>{title}</h4>
<p>{completed ? "Completed" : "Not Completed"}</p>
</div>
);
};
Now let's make some props optional.
type TodoProps = {
id: number;
title: string;
completed?: boolean;
};
export const Todo = ({ title, completed }: TodoProps) => {
return (
<div>
<h4>{title}</h4>
<p>{completed ? "Completed" : "Not Completed"}</p>
</div>
);
};
Now the completed
prop is optional; we can choose not to pass it.
Let's say we want to provide a default value in case completed
is not passed. We can set default values for our props as shown below.
type TodoProps = {
id: number;
title: string;
completed?: boolean;
};
export const Todo = ({ title, completed = false }: TodoProps) => {
return (
<div>
<h4>{title}</h4>
<p>{completed ? "Completed" : "Not Completed"}</p>
</div>
);
};
Now, if we don't pass the 'completed' prop, its value will be false.
Typing children Props
It is common in most React applications to have components with children
props. These can include layout components, grid components, or card components which layout passed components.
In such cases, if we are sure we are passing a single JSX element, we can type our children
props with JSX.Element
.
const Layout = ({ children }: { children: JSX.Element }) => {
return <main>{children}</main>;
};
export default Layout;
TypeScript will complain if you try to pass multiple JSX elements. In that case, you can make the children
prop of type JSX.Element
an array.
const Layout = ({ children }: { children: JSX.Element[] }) => {
return <main>{children}</main>;
};
export default Layout;
Now you can pass multiple JSX elements to your layout, but you can still run into an issue when rendering primitive types like strings, numbers, etc.
To solve such issues, we can type our children props with React.ReactNode
so that we can pass a single or multiple children props, including primitive types, together as children.
const Layout = ({ children }: { children: React.ReactNode }) => {
return <main>{children}</main>;
};
export default Layout;
Another good alternative to React.ReactNode
is using the built-in PropsWithChildren
type, which internally does what we just did above with other prop types extension if needed.
const Layout = ({ children }: PropsWithChildren) => {
return <main>{children}</main>;
};
export default Layout;
Now, what do we do if we want other props, for example, a title for our page? We can extend them. It looks like this.
type LayoutProps = {
title?: string
}
const Layout = ({ title = "Task Manager", children }: PropsWithChildren<LayoutProps>) => {
return <>
<Helmet>
<title>{title}</title>
</Helmet>
<main>{children}</main>;
</>
};
export default Layout;
To keep it clean, I prefer to do it like this.
type LayoutProps = PropsWithChildren<{
title?: string
}>
const Layout = ({ title = "Task Manager", children }: LayoutProps) => {
return <>
<Helmet>
<title>{title}</title>
</Helmet>
<main>{children}</main>;
</>
};
export default Layout;
Extending Props
Let's say we are building a button component. A button component can have multiple attributes like type, onClick, disabled, and many more. Typing all of them is not a great experience. We can use utility helpers here.
import { ComponentPropsWithoutRef } from "react"
type ButtonProps = ComponentPropsWithoutRef<"button">
export const Button = ({ children, ...props }: ButtonProps) => {
return <button {...props}>{children}</button>
}
We can use the ComponentPropsWithoutRef
type and pass the element type to get all the properties of the element.
Typing useState
If you have an initial type for your useState, TypeScript can infer the type of your state. For example:
const [count, setCount] = useState(0)
Here, TypeScript can infer the type of count
and setCount
you don't need to explicitly type this.
const [count, setCount] = useState()
However, here TypeScript needs your help. Currently, the type of count
is undefined
, and the type of setCount
is React.Dispatch<React.SetStateAction<undefined>>
. To fix this, let's give them a type.
const [count, setCount] = useState<number>()
TypeScript will now give count
a type of undefined | number
, allowing you to set a number to your state.
You can also assign non-primitive types to your useState. Let's see an example.
export const Todo = () => {
const [todo, setTodo] = useState<Todo | undefined>()
useEffect(() => {
fetch('https://jsonplaceholder.typicode.com/todos/1')
.then(res => res.json()).then(data => setTodo(data));
}, [])
if (!todo) return <p>Loading...</p>
return (
<div>
<h4>{todo.title}</h4>
<p>{todo?.completed ? "Completed" : "Not Completed"}</p>
</div>
);
};
Typing Reducers
First things first, when defining types for reducers, the main parts are type definitions for state and action types.
Let's start by defining the type for our todo manager state.
type InitialState = {
id: number;
title: string;
completed: boolean;
};
const initialState: InitialState[] = [];
Now let's create action types for create, and delete todo actions.
type AddTodoAction = {
type: "addTodo";
payload: {
title: string;
};
};
export type DeleteTodoAction = {
type: "deleteTodo";
payload: {
id: number;
};
};
type ActionType = AddTodoAction | DeleteTodoAction;
Now, thanks to our types, we can get IntelliSense support and switch over our actions to update our state.
Our reducer function can look like this:
export const todoReducer = (state: Todo[] = initialState, action: AddTodoAction | DeleteTodoAction): Todo[] => {
switch (action.type) {
case "addTodo":
return [
...state,
{
id: state.length,
title: action.payload.title,
completed: false,
},
];
case "deleteTodo":
return state.filter((todo) => todo.id !== action.payload.id);
default:
return state;
}
};
Let's use our reducer inside our component.
function App() {
const [todos, dispatch] = useReducer(todoReducer, initialState)
return (
<Layout>
<div style={{ display: "flex", flexDirection: "column", justifyContent: "center", justifyItems: "center", alignItems: "center" }}>
<AddTodo onSubmit={dispatch} />
{
todos.map(todo => <Todo key={todo.id} id={todo.id} title={todo.title} completed={todo.completed} deleteTodo={dispatch} />)
}|
</div>
<Layout>
)
}
Now let's create AddTodo
component and also lets update the Todo
component.
export const AddTodo = ({ onSubmit }: { onSubmit: React.Dispatch<AddTodoAction> }) => {
const [title, setTitle] = useState("")
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
onSubmit({ type: "addTodo", payload: { title: title } })
setTitle("")
}
return <form onSubmit={handleSubmit}>
<input type="text" value={title} onChange={(e) => setTitle(e.target.value)} />
<button>Add Todo</button>
</form>
}
Let's update our Todo component.
type TodoProps = {
id: number;
title: string;
completed: boolean;
deleteTodo: React.Dispatch<DeleteTodoAction>
};
export const Todo = ({ id, title, completed, deleteTodo }: TodoProps) => {
return (
<div style={{ display: "flex", columnGap: "0.5rem", justifyContent: "center", justifyItems: "center", alignItems: "center" }}>
<h4>{title}</h4>
<p>{completed ? "Completed" : "Not Completed"}</p>
<button onClick={() => deleteTodo({ type: "deleteTodo", payload: { id } })}>Delete Todo</button>
</div>
);
};
Typing Custom Hooks
Now, let's load our todos from an API and create a custom useTodos
hook. We can add loading and error states to indicate the status of our API call.
interface Todo {
id: number;
title: string;
completed: boolean;
}
const useTodos = () => {
const [todos, setTodos] = useState<Todo[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchTodos = async () => {
try {
const res = await fetch("https://jsonplaceholder.typicode.com/users/1/todos");
if (!res.ok) {
throw new Error("Failed to fetch todos");
}
const data: Todo[] = await res.json();
setTodos(data);
} catch (error) {
setError("Something went wrong");
} finally {
setLoading(false);
}
};
fetchTodos();
}, []);
return { todos, loading, error };
};
export default useTodos;
Now let's use our custom hook inside our App component.
function App() {
const { todos, loading, error } = useTodos();
if (loading) {
return <p>Loading...</p>;
}
if (error) {
return <div>{error}</div>;
}
return (
<Layout>
<div style={{ display: "flex", flexDirection: "column", justifyContent: "center", justifyItems: "center", alignItems: "center" }}>
{
todos.map(todo => <Todo key={todo.id} id={todo.id} title={todo.title} completed={todo.completed} />)
}|
</div>
</Layout >
)
}
export default App