Type Script Handbook for React Developers – How to Build a Type Safe Todo App
TypeScript Handbook for React Developers – How to Build a Type-Safe Todo App #
Excerpt #
In today’s JavaScript landscape, TypeScript is gaining more and more popularity. And React developers are starting to use it more and more.
If you’re a React developer looking to explore TypeScript or enhance your skills with it, this handbook is just for you. I’ll guide you through using TypeScript in a React application by building a classic todo app.
I’m going to cover everything you need to know in order to get started with TypeScript as a React developer. You will learn how to handle stat
In today’s JavaScript landscape, TypeScript is gaining more and more popularity. And React developers are starting to use it more and more.
If you’re a React developer looking to explore TypeScript or enhance your skills with it, this handbook is just for you. I’ll guide you through using TypeScript in a React application by building a classic todo app.
I’m going to cover everything you need to know in order to get started with TypeScript as a React developer. You will learn how to handle state and props with strong typing, how to create React components with TypeScript, how to use TypeScript with React Hooks, and how to use TypeScript with the Context API.
By the end of this tutorial, you’ll have a solid understanding of TypeScript and be ready to develop type-safe React applications with confidence. So, without further due let’s get started!
Here is What We’ll Cover #
- Prerequisites
- What Are We Going to Build?
- Getting Started
- How to set up the Todo app component
- How to create a simple form element in React
- What is a type error in TypeScript and how to fix it?
- What are the Generic types in TypeScript?
- How to handle form submission with TypeScript in React
- How to automatically focus on an input field in React
- What is
useRef
and how to to use it with TypeScript - How to create type-safe React components with TypeScript
- What is
forwardRef
in React? - How to create a todo item on the form submission?
- What is React Context?
- How to use React Context with TypeScript?
- What are Interfaces in TypeScript?
- How to use TypeScript interfaces with React Context
- How to create a custom hook to consume React Context
- How to define an interface for Todo items
- How to build a custom React component for displaying Todo items
- How to implement functionality: Edit, Delete, and Update Todo items
- Conclusion
Prerequisites #
No prior knowledge of TypeScript is necessary to begin this tutorial, making it completely beginner-friendly. However, having a background in React will greatly enhance your understanding and maximize your learning potential throughout this tutorial.
Throughout this tutorial, you’ll be utilizing the following tools:
- React 18.2.0: React is a JavaScript library used for building user interfaces. It allows developers to create reusable UI components and efficiently update the UI based on data changes.
- TypeScript: TypeScript is a statically typed superset of JavaScript that adds optional type annotations. It provides enhanced tooling and helps catch potential errors during development, making code more reliable and easier to maintain.
- Vite: Vite is a fast development server and build tool for modern web applications. It offers instant server start, hot module replacement, and optimized build output, enabling quick and efficient development workflows.
- Framer Motion: Framer Motion is a popular animation library for React. It provides an easy-to-use interface for creating smooth, interactive animations and transitions in web applications, enhancing the overall user experience.
In the upcoming section, you’ll get a concise preview of the project you’ll be building in this tutorial.
What Are We Going to Build? #
We are going to build a classic todo application. It will have the following features:
- Add a todo item.
- Edit a todo item.
- Delete a todo item.
- Mark a todo item as completed or not.
- Storing todo items in the browser’s local storage.
- Displaying proper error messages when the user tries to add or edit a todo item with an empty title.
Preview of the final app
Getting Started #
To get started with this tutorial, I’ve already prepared you a boilerplate project which contains all the required dependencies. This eliminates the need to set up your project from scratch.
Simply clone the starter boilerplate from the GitHub repository and then follow along with the tutorial. This way, you can focus on learning and implementing the concepts without getting caught up in setup details.
- Starter Boilerplate: View on GitHub
- Final Version: View on GitHub
Once you have set up the starter boilerplate and successfully run it on your local machine, you should be able to see the initial page. This page will serve as the starting point for our journey.
Starter Boilerplate
Now, we’ll start adding exciting features to our application. Let’s jump in and get started right away!
How to Set Up the Todo App Component #
In this section, you will set up the main component of your Todo App and gradually enhance it with additional functionality. Open up ./src/App.tsx
and add the following code:
//📂./src/App.tsx
import { TodoList, AddTodo } from './components'
import { Toaster } from 'react-hot-toast'
function App() {
return (
<div>
<Toaster position="bottom-center" />
<AddTodo />
<TodoList />
</div>
)
}
export default App
Let’s break it down step by step:
<Toaster position="bottom-center" />
: This component is responsible for displaying toast notifications at the bottom center of the screen.<AddTodo />
: This component will represents an input field and button to add new todo items to the app.<TodoList />
: This component will render a list of existing todo items.
Now, open up your local server on your browser and you will be able to see the following page:
Preview of the App.tsx
These two components play a critical role in your application. In the upcoming section, you will be building the functionality to add a todo item using the <AddTodo />
component. Specifically, you will learn how to handle form submissions with TypeScript in React.
How to Create a Simple Form Element in React #
First of all, you need to create a form element for creating a todo item. To achieve this in your application, you need to create a form and handle the form submission effectively. In this section, you will be exploring how to handle form submission using TypeScript in a React application.
I just want to give you a quick heads up since you’re about to face your first type-error in TypeScript! Add the following code to the components/AddTodo.tsx
:
//📂./src/components/AddTodo.tsx
//⚠️TypeScript is not happy with this code
import React, { useEffect, useRef, useState } from 'react'
import { toast } from 'react-hot-toast'
import { useTodo } from '../context'
import { Input } from './Input'
export const AddTodo = () => {
const [input, setInput] = useState()
return (
<form>
<div className="flex items-center w-full max-w-lg gap-2 p-5 m-auto">
<input
value={input}
onChange={e => setInput(e.target.value)}
type="text"
className="w-full px-5 py-2 bg-transparent border-2 outline-none border-zinc-600 rounded-xl placeholder:text-zinc-500 focus:border-white"
placeholder="start typing ..."
/>
<button
type="submit"
className="px-5 py-2 text-sm font-normal text-blue-300 bg-blue-900 border-2 border-blue-900 active:scale-95 rounded-xl"
>
Submit
</button>
</div>
</form>
)
}
You’ve created a useState hook that updates the state with the value of the input as it changes. However, TypeScript is not happy with this code. But why is TypeScript unhappy?
What is a type error in TypeScript and how to fix it #
Types in TypeScript define the kind of data that variables can hold and enable the detection of errors and bugs during development.
A type error in TypeScript occurs when a value is used in a way that is incompatible with its expected type, leading to potential bugs or unexpected behavior in the code.
In our case, TypeScript is showing an error in this code because it cannot infer the type of the state variable input
automatically. To fix this, you need to provide TypeScript with the type information explicitly. In this case, you want input to be of type string since it represents the value of the input field.
To fix this error, you have two options. The easy solution is adding a initial value to the useState
hook and TypeScript will automatically infer the input
type as a string:
const [input, setInput] = useState('')
By adding the above code, you may notice that the error disappears and TypeScript is satisfied. But not all errors can be resolved this easily in TypeScript.
Let’s consider a situation where you are uncertain about the type of your state and cannot determine whether it should be initialized as a number or a string. This uncertainty leads us to the second option, which is using generic types.
What are the Generic types in TypeScript? #
Generic types provide a way to handle situations when you are unsure about the specific type of a value. With generic types, you can define a placeholder that represents the actual type, allowing you to make your code more flexible and reusable:
const [state, setState] = useState<string | number>('')
The above code is initializing a state variable named “state” with an initial value of an empty string, but it allows the state to hold either a string or a number as its value.
Now, let’s introduce a generic type into your app. You don’t want your users to add a number as a todo – we want them to be able to only add a string:
//📂./src/components/AddTodo.tsx
//✅TypeScript is happy with this code
import React, { useEffect, useRef, useState } from 'react'
import { toast } from 'react-hot-toast'
import { useTodo } from '../context'
import { Input } from './Input'
export const AddTodo = () => {
const [input, setInput] = useState<string>('')
return (
<form>
<div className="flex items-center w-full max-w-lg gap-2 p-5 m-auto">
<input
value={input}
onChange={e => setInput(e.target.value)}
type="text"
className="w-full px-5 py-2 bg-transparent border-2 outline-none border-zinc-600 rounded-xl placeholder:text-zinc-500 focus:border-white"
placeholder="start typing ..."
/>
<button
type="submit"
className="px-5 py-2 text-sm font-normal text-blue-300 bg-blue-900 border-2 border-blue-900 active:scale-95 rounded-xl"
>
Submit
</button>
</div>
</form>
)
}
By specifying <string>
after the useState
function, we ensure that the state variable input
can only hold values of type string. This prevents users from entering numbers or any other incompatible data types as todos.
How to handle form submission with TypeScript in React #
Now that you have successfully stored the input value in the state, let’s proceed with handling the form submission itself:
//📂./src/components/AddTodo.tsx
import React, { useEffect, useRef, useState } from 'react'
import { toast } from 'react-hot-toast'
import { useTodo } from '../context'
import { Input } from './Input'
export const AddTodo = () => {
const [input, setInput] = useState<string>('')
const handleSubmission = (e: React.FormEvent) => {
e.preventDefault()
console.log('form has been submitted')
}
return (
<form onSubmit={handleSubmission}>
<div className="flex items-center w-full max-w-lg gap-2 p-5 m-auto">
<input
value={input}
onChange={e => setInput(e.target.value)}
type="text"
className="w-full px-5 py-2 bg-transparent border-2 outline-none border-zinc-600 rounded-xl placeholder:text-zinc-500 focus:border-white"
placeholder="start typing ..."
/>
<button
type="submit"
className="px-5 py-2 text-sm font-normal text-blue-300 bg-blue-900 border-2 border-blue-900 active:scale-95 rounded-xl"
>
Submit
</button>
</div>
</form>
)
}
The handleSubmission
function is called when the form is submitted. Let’s break it down step by step:
(e: React.FormEvent)
is the function’s parameter declaration. It specifies that the function expects an event object of typeReact.FormEvent
to be passed as an argument. TheReact.FormEvent
is a type of event object that represents an event occurring on a form element, such as submitting the form or interacting with form fields.e.preventDefault()
is a method that belongs to the event object (e
). It is called to prevent the default behavior of form submission, which is to refresh the page. By callingpreventDefault()
, we override the default behavior and prevent the page from refreshing.console.log('form has been submitted')
is a simple statement that logs a message to the browser’s console. In this case, it logs the message “form has been submitted” when the form submission event occurs.
Great! You’ve completed the necessary steps to handle the form submission. Now let’s proceed to the next section where you enhance your form’s functionality by making some modifications.
How to automatically focus on an input field in React #
To enhance user experience, you can automatically set focus on the “add todo” input field when the app is initially loaded. This eliminates the need for users to manually click on the input upon opening the app.
To implement this functionality, you can utilize a specific React hook called useRef
, which allows you to incorporate this feature into the input.
What is useRef
and how to to use it with TypeScript
#
useRef
is a special hook in React that creates a reference to an element or value in your component. This reference can be used to access and manipulate the referenced element directly, without causing re-renders.
You’ll commonly use it to access DOM elements, manage focus, or store mutable values across component renders.
Open app components/AddTodo.tsx
and add the following code:
//📂./src/components/AddTodo.tsx
import React, { useEffect, useRef, useState } from 'react'
import { toast } from 'react-hot-toast'
import { useTodo } from '../context'
import { Input } from './Input'
export const AddTodo = () => {
const [input, setInput] = useState<string>('')
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus()
}
}, [])
const handleSubmission = (e: React.FormEvent) => {
e.preventDefault()
console.log('form has been submitted')
}
return (
<form onSubmit={handleSubmission}>
<div className="flex items-center w-full max-w-lg gap-2 p-5 m-auto">
<input
ref={inputRef}
value={input}
onChange={e => setInput(e.target.value)}
type="text"
className="w-full px-5 py-2 bg-transparent border-2 outline-none border-zinc-600 rounded-xl placeholder:text-zinc-500 focus:border-white"
placeholder="start typing ..."
/>
<button
type="submit"
className="px-5 py-2 text-sm font-normal text-blue-300 bg-blue-900 border-2 border-blue-900 active:scale-95 rounded-xl"
>
Submit
</button>
</div>
</form>
)
}
Here, the useRef
hook from React is being used along with TypeScript.
- The line
const inputRef = useRef<HTMLInputElement>(null)
declares a reference variable calledinputRef
using the useRef hook. The type parameter<HTMLInputElement>
specifies that the ref is intended for an input element. The initial value of the ref is set tonull
. - In the useEffect hook,
inputRef.current
is checked to see if it exists. If it does, thefocus()
method is called on it, which means the input field will receive focus when the component is mounted.
The useRef
hook is type-parameterized with <HTMLInputElement>
to ensure that the reference is compatible with input elements.
By using useRef and TypeScript together, the code benefits from TypeScript’s static type checking and the ability to interact with the input element’s DOM reference using useRef.
While this code functions correctly, it would be beneficial to reuse this input component in other parts of your application. Therefore, let’s create a reusable input component and explore how to develop type-safe React components by implementing this input.
How to create type-safe React components with TypeScript #
In this section, you will create a type-safe Input component for future use cases in your app.
To create this custom Input component, you will need to pass the ref you created in the previous section as a prop to this component.
Refs are passed as normal props, and in order to pass refs to child components, you need to implement a special built-in React function called forwardRef.
What is forwardRef
in React?
#
In React, the forwardRef
function is a feature that allows you to pass a ref from a parent component to a child component. Refs are used to access and manipulate the underlying DOM elements directly.
By using forwardRef
, you can create a custom component that can receive a ref and pass it down to a specific element within the component.
This enables the parent component to interact with the child component’s underlying element, such as focusing an input field or triggering certain actions.
In simple terms, forwardRef
helps you to connect a ref between components, allowing you to control or access the child component’s inner element if needed.
Now, let’s create a re-usable Input component. Open up components/Input.tsx
:
// 📂./src/components/Input.tsx
import { InputHTMLAttributes, forwardRef } from 'react'
import cn from 'classnames'
export const Input = forwardRef<
HTMLInputElement,
InputHTMLAttributes<HTMLInputElement>
>(({ className, ...rest }, ref) => {
return (
<input
{...rest}
ref={ref}
className={cn(
'w-full px-5 py-2 bg-transparent border-2 outline-none border-zinc-600 rounded-xl placeholder:text-zinc-500 focus:border-white',
className,
)}
/>
)
})
Let’s break this component down step by step:
- The component utilizes the
forwardRef
function from React to forward the ref to the underlying<input>
element. This allows the parent components to access and manipulate the input element directly. HTMLInputElement
specifies the type of the ref that will be forwarded to the underlying<input>
element. This ensures that the ref is compatible with the input element’s expected type.InputHTMLAttributes<HTMLInputElement>
specifies the type of the props object that the component accepts. This includes all the standard HTML input element attributes, such asvalue
,placeholder
,onChange
, and so on.- The component destructures the
className
prop from therest
object and also receives theref
as a parameter. - Inside the component, a JSX expression is used to render an
<input>
element. The spread operator ({...rest}
) is used to pass all the props (exceptclassName
andref
) received by the component to the<input>
element. This ensures that any additional attributes passed to the<Input>
component will be applied to the underlying<input>
element. - The
ref
is assigned to the underlying<input>
element using theref
attribute, enabling the parent component to reference the input element. - The
className
is constructed using thecn
function from theclassnames
module. This function combines multiple CSS class names based on the provided conditions. In this case, it combines the default input element class names with theclassName
prop passed to the<Input>
component.
The final rendered <input>
element will have the combined class names and inherit all other props passed to the <Input>
component.
Now, let’s update the <AddTodo />
component to utilize the custom <Input />
instead of the default HTML input element:
//📂./src/components/AddTodo.tsx
import React, { useEffect, useRef, useState } from 'react'
import { toast } from 'react-hot-toast'
import { useTodo } from '../context'
import { Input } from './Input'
export const AddTodo = () => {
const [input, setInput] = useState<string>('')
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus()
}
}, [])
const handleSubmission = (e: React.FormEvent) => {
e.preventDefault()
console.log('form has been submitted')
}
return (
<form onSubmit={handleSubmission}>
<div className="flex items-center w-full max-w-lg gap-2 p-5 m-auto">
<Input
ref={inputRef}
value={input}
onChange={e => setInput(e.target.value)}
type="text"
className="w-full px-5 py-2 bg-transparent border-2 outline-none border-zinc-600 rounded-xl placeholder:text-zinc-500 focus:border-white"
placeholder="start typing ..."
/>
<button
type="submit"
className="px-5 py-2 text-sm font-normal text-blue-300 bg-blue-900 border-2 border-blue-900 active:scale-95 rounded-xl"
>
Submit
</button>
</div>
</form>
)
}
Now, you are able to use this custom <Input />
component across your whole application. In the next section, you’ll be creating the funcionality to add a todo item on the form submission.
How to create a todo item on the form submission #
To store each todo item, you can utilize an array that holds the user’s input. Essentially, we require an array of strings to store each todo:
const [todos, setTodos] = useState<string[]>([])
string[]
specifies the type of data that will be stored in the todos
state variable. In this case, it is an array of strings, meaning it will hold a list of todo items, where each item is represented as a string.
Now let’s add an item to the todos
on form submission:
//📂./src/components/AddTodo.tsx
import React, { useEffect, useRef, useState } from 'react'
import { toast } from 'react-hot-toast'
import { useTodo } from '../context'
import { Input } from './Input'
export const AddTodo = () => {
const [input, setInput] = useState<string>('')
const [todos, setTodos] = useState<string[]>([])
const handleSubmission = (e: React.FormEvent) => {
e.preventDefault()
if (input.trim() !== '') {
setTodos([...todos, input])
setInput('')
}
}
return (
<form onSubmit={handleSubmission}>
<div className="flex items-center w-full max-w-lg gap-2 p-5 m-auto">
<input
value={input}
onChange={e => setInput(e.target.value)}
type="text"
className="w-full px-5 py-2 bg-transparent border-2 outline-none border-zinc-600 rounded-xl placeholder:text-zinc-500 focus:border-white"
placeholder="start typing ..."
/>
<button
type="submit"
className="px-5 py-2 text-sm font-normal text-blue-300 bg-blue-900 border-2 border-blue-900 active:scale-95 rounded-xl"
>
Submit
</button>
</div>
</form>
)
}
The `handleSubmission`
checks if the input
(the user’s entered todo) is not an empty string after removing any leading or trailing whitespace using input.trim() !== ''
.
If it’s not empty, it adds the input
value to the existing todos
array using setTodos([...todos, input])
. This creates a new array with all the previous todos and the new todo added at the end. It resets the input
value to an empty string using setInput('')
so that the input field becomes empty and ready for the next todo entry.
Now, while you have successfully implemented the functionality to create a todo item, it cannot be displayed on the screen yet.
This is because the <AddTodo />
component is responsible for adding todo items, not displaying them.
On the other hand, the <TodoList />
component is responsible for displaying all the items. To bridge this gap and share todos between these components, you can leverage the power of React Context.
What is React Context? #
React Context API is a feature in React that allows data to be shared and accessed by components without passing it explicitly through props. It provides a way to create a global state that can be accessed by any component in the application.
Imagine you have a tree-like structure of components, where certain data needs to be accessed by multiple components at different levels. Rather than passing the data through multiple layers of components, you can use React Context to create a central store for that data.
Here’s how it works:
- Create a Context: First, you define a context using the
createContext()
function. This creates a context object that holds the shared data. - Provide the Context: You wrap the parent component or a specific part of your application with a
<Context.Provider>
. This provider component accepts avalue
prop where you can pass the data you want to share. - Consume the Context: To access the shared data within a component, you use the
useContext()
hook provided by React. By passing the created context as an argument touseContext()
, you can access the shared data and use it within that component. - Update the Context: If you need to update the shared data, you can do so by modifying the value in the provider component. This change will automatically propagate to all the components that are consuming the context.
React Context API simplifies the process of sharing data across components, eliminating the need for manual prop drilling.
In your situation, you need to create a Context to share todo items across multiple components. Let’s create a Context to see how this mechanism works in practice.
How to use React Context with TypeScript #
In this section, you will learn how to create a React Context to isolate the application logic and improve the state management capabilities of your app.
If you open up context/TodoContext.tsx
, you’ll see the following code:
// 📂./src/context/TodoContext.tsx
import React, { createContext } from 'react'
import { nanoid } from 'nanoid'
import { useLocalStorage } from 'usehooks-ts'
export const TodoContext = createContext<undefined>(undefined)
export const TodoProvider = (props: { children: React.ReactNode }) => {
return (
<TodoContext.Provider value={undefined}>
{props.children}
</TodoContext.Provider>
)
}
Let’s break it down step by step:
- The
TodoContext
is created using thecreateContext
function provided by React. It is initialized with an undefined value. - Additionally, a
TodoProvider
component is defined. It takes achildren
prop, which represents the child components that will be wrapped by this provider. - Inside the
TodoProvider
component, a<TodoContext.Provider>
component is rendered. It wraps theprops.children
, which allows the child components to access the TodoContext. - The value provided to the
<TodoContext.Provider>
component is set toundefined
for now.
In the upcoming section, you’ll make a more complex Context by learning about something called an Interface in TypeScript.
What are Interfaces in TypeScript? #
In TypeScript, interfaces are a way to define the structure and shape of an object. They allow you to specify the properties and their types that an object should have. Think of an interface as a blueprint or a contract that describes what an object should look like.
Imagine you are building a house. Before starting the construction, you would have a blueprint that outlines the design and layout of the house. Similarly, an interface in TypeScript is like a blueprint for an object.
Let’s look at a simple example of an interface:
interface Person {
name: string;
age: number;
}
In this example, we define an interface called Person
that describes the structure of a person object. It specifies that a person object should have two properties: name
, which should be of type string
, and age
, which should be of type number
.
Let’s consider your Todo Context and the props you want to pass to its consumers. In this case, you will need an interface that defines the required props, including an array of strings that contains all the todo items, as well as a function that accepts a string and adds it to the todo list.
interface TodoContextProps {
todos: string[]
addTodo: (text: string) => void
}
The TodoContextProps
interface specifies the structure of the properties expected in the TodoContext. It has two properties:
todos
: An array of strings that represents the todo items. This property holds all the existing todos.addTodo
: A function that accepts a parameter of type string (text
) and has a return type ofvoid
. This function is responsible for adding a new todo item to the list. It takes the new todo item as input and performs the necessary action without returning any value.
How to use TypeScript Interfaces with React Context #
Now that you have a grasp of the benefits of TypeScript interfaces, it’s time to enhance your Context by incorporating this interface:
// 📂./src/context/TodoContext.tsx
import React, { createContext, useState } from 'react'
import { nanoid } from 'nanoid'
import { useLocalStorage } from 'usehooks-ts'
interface TodoContextProps {
todos: string[]
addTodo: (text: string) => void
}
export const TodoContext = createContext<TodoContextProps | undefined>(
undefined,
)
export const TodoProvider = (props: { children: React.ReactNode }) => {
const [todos, setTodos] = useState<string[]>([])
// ::: ADD NEW TODO :::
const addTodo = (text: string) => {
setTodos([...todos, text])
}
const value: TodoContextProps = {
todos,
addTodo,
}
return (
<TodoContext.Provider value={value}>{props.children}</TodoContext.Provider>
)
}
In this updated code, there are significant changes compared to the previous version. These changes introduce TypeScript and modify the TodoContext and TodoProvider components:
- Here,
TodoContextProps
specifies that it should have two properties:todos
, which is an array of strings representing todo items, andaddTodo
, a function that takes a string parameter and returns void (no return value). - The
TodoContext
is now created withcreateContext
and initialized with a type ofTodoContextProps | undefined
. This means the context value can be of typeTodoContextProps
or undefined. - The
TodoProvider
component now initializes thetodos
state using theuseState
hook. It keeps track of the todo items using an array of strings. - A new function
addTodo
is introduced, which takes a stringtext
as a parameter. It uses thesetTodos
function to update thetodos
state by appending the new todo item to the existing array. - Creating the value for the context: The
value
variable is assigned an object of typeTodoContextProps
, containing thetodos
array and theaddTodo
function. - Providing the context value: The
<TodoContext.Provider>
component wraps theprops.children
, and the value prop is set tovalue
, which provides thetodos
andaddTodo
to the child components.
In summary, you are using TypeScript to define an interface for the TodoContextProps, adds a new todo using useState and a custom function, and provides the updated context value to the child components.
How to create a custom hook to consume React Context #
To utilize the values provided by the context, you need to create a custom hook that consumes this context and provides its values to the child components. Open up context/useTodo.ts
and add the following code:
// 📂./src/context/useTodo.ts
import { useContext } from 'react'
import { TodoContext } from './TodoContext'
export const useTodo = () => {
const context = useContext(TodoContext)
if (!context) {
throw new Error('useTodo must be used within a TodoProvider')
}
return context
}
Let’s break it down step by step:
- You import the
useContext
hook from the ‘react’ module and theTodoContext
from the./TodoContext
file. - Inside the hook, the
useContext
hook is called withTodoContext
as the argument. This hooks into theTodoContext
and retrieves its current value. - If the
context
value isundefined
, it means that theuseTodo
hook is being used outside the scope of theTodoProvider
. In such cases, an error is thrown with the message ‘useTodo must be used within a TodoProvider’.
Overall, this code allows you to create a custom hook named useTodo
that can be used within your components.
By calling this hook, you can access the TodoContext
and retrieve its value, which includes the todo-related data and functions defined in the TodoProvider
.
It also ensures that the useTodo
hook is used only within the scope of the TodoProvider
to maintain the correct usage and prevent any errors.
Next, you’ll need to wrap your entire app with the TodoProvider component. This ensures that the context values are accessible to its children components by utilizing the useTodo
hook:
// 📂 ./src/main.tsx
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<TodoProvider>
<App />
</TodoProvider>
</React.StrictMode>,
)
<TodoProvider>
wraps the entire application and provides the necessary context for managing todo-related data.
Now, let’s integrate the useTodo hook within the <AddTodo />
component to efficiently manage todo items through the context. Additionally, let’s implement toast notifications to provide feedback based on user interactions:
//📂./src/components/AddTodo.tsx
import React, { useEffect, useRef, useState } from 'react'
import { toast } from 'react-hot-toast'
import { useTodo } from '../context/useTodo'
import { Input } from './Input'
export const AddTodo = () => {
const [input, setInput] = useState<string>('')
const inputRef = useRef<HTMLInputElement>(null)
const { addTodo } = useTodo()
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus()
}
}, [])
const handleSubmission = (e: React.FormEvent) => {
e.preventDefault()
if (input.trim() !== '') {
addTodo(input)
setInput('')
toast.success('Todo added successfully!')
} else {
toast.error('Todo field cannot be empty!')
}
}
return (
<form onSubmit={handleSubmission}>
<div className="flex items-center w-full max-w-lg gap-2 p-5 m-auto">
<Input
ref={inputRef}
type="text"
placeholder="start typing ..."
value={input}
onChange={e => setInput(e.target.value)}
/>
<button
type="submit"
className="px-5 py-2 text-sm font-normal text-blue-300 bg-blue-900 border-2 border-blue-900 active:scale-95 rounded-xl"
>
Submit
</button>
</div>
</form>
)
}
- The line
const { addTodo } = useTodo()
uses theuseTodo
hook to retrieve theaddTodo
function from the todo context. This allows us to add new todo items. - The
toast.success('Todo added successfully!')
line displays a success toast notification indicating that the todo was added successfully. - The
toast.error('Todo field cannot be empty!')
line displays an error toast notification if the todo field is empty when attempting to submit. - If the
input
value (trimmed of whitespace) is not empty, theaddTodo
function is called with the input value, theinput
state is cleared, and a success toast notification is displayed. - If the
input
value is empty, an error toast notification is displayed indicating that the todo field cannot be empty.
This code integrates the useTodo
hook to manage todo items through context. It captures user input, adds todos, and displays toast notifications to provide feedback on the success or failure of adding a todo item.
Now, let’s also modify <TodoList />
component and display the todo items on the screen. Open up components/TodoList.tsx
and add the following code:
//📂./src/components/TodoList.tsx
import { useTodo } from '../context/useTodo'
import { SiStarship } from 'react-icons/si'
export const TodoList = () => {
const { todos } = useTodo()
if (!todos.length) {
return (
<div className="max-w-lg px-5 m-auto">
<h1 className="flex flex-col items-center gap-5 px-5 py-10 text-xl font-bold text-center rounded-xl bg-zinc-900">
<SiStarship className="text-5xl" />
You have nothing to do!
</h1>
</div>
)
}
return (
<ul className="grid max-w-lg gap-2 px-5 m-auto">
{todos.map(todo => (
<li key={todo}>{todo}</li>
))}
</ul>
)
}
- The import statement
import { useTodo } from '../context/useTodo'
imports theuseTodo
hook from the custom context, which allows us to access thetodos
array. - If the
todos
array is empty (!todos.length
), meaning there are no todos, a message is displayed indicating that there is nothing to do. - If there are todos in the
todos
array, an unordered list (<ul>
) is rendered. - Inside the
<ul>
, thetodos
array is iterated using themap
function. For each todo item, a list item (<li>
) is created with a uniquekey
set to the value of the todo item. - The todo item itself is then displayed inside the list item.
This component retrieves the todos
array from the context using the useTodo
hook. If there are no todos, it displays a message. If there are todos, it renders an unordered list and populates it with list items for each todo item.
Adding todo items and displaying toast notifications
Great job so far! You now have a functioning basic todo app. It’s time to take it up a notch and add some exciting features to enhance your app even further.
How to Define an Interface for Todo Items #
In this section, you will build upon the existing context from the previous section and enhance it to create a more complex todo item with additional features.
Each todo item consists of three attributes:
- id: a unique string that serves as an identifier for the item
- text: a simple string representing the content of the todo item
- status: the status of the todo item, which can be either “undone” or “completed”
Based on the above information, the proper todo interface would be as follows:
interface Todo {
id: string
text: string
status: 'undone' | 'completed'
}
To implement the Todo interface into your context, we will make necessary updates and modifications to utilize this enhanced context effectively:
//📂./src/context/TodoContext.tsx
import React, { createContext, useState } from 'react'
import { nanoid } from 'nanoid'
import { useLocalStorage } from 'usehooks-ts'
interface TodoContextProps {
todos: Todo[]
addTodo: (text: string) => void
}
export interface Todo {
id: string
text: string
status: 'undone' | 'completed'
}
export const TodoContext = createContext<TodoContextProps | undefined>(
undefined,
)
export const TodoProvider = (props: { children: React.ReactNode }) => {
const [todos, setTodos] = useState<Todo[]>([])
// ::: ADD NEW TODO :::
const addTodo = (text: string) => {
const newTodo: Todo = {
id: nanoid(),
text,
status: 'undone',
}
setTodos([...todos, newTodo])
}
const value: TodoContextProps = {
todos,
addTodo,
}
return (
<TodoContext.Provider value={value}>{props.children}</TodoContext.Provider>
)
}
Here’s an explanation of what’s changed in the context:
Todo interface:
- The Todo interface defines the structure of a todo item.
- It consists of three properties: id (a string), text (a string representing the content of the todo item), and status (a string that can have the value ‘undone’ or ‘completed’).
- This interface helps ensure that todo items have consistent properties and data types.
useState<Todo[]>:
- The useState hook is used to manage state in a functional component.
- In this case,
useState<Todo[]>
initializes a state variable called “todos” as an array of Todo items. - The “todos” state variable will be used to store and update the todo items.
addTodo
function and the newTodo
variable:
- The addTodo function is a callback function that takes a text parameter (string).
- Inside the addTodo function, a newTodo variable is declared as a Todo object.
- The newTodo object is created with a unique id generated by the nanoid() function, the provided text, and an initial status of ‘undone’.
- The setTodos function from useState is called to update the todos state by adding the newTodo object to the existing array of todos.
- This allows new todo items to be added to the list.
Now, you need to update the <TodoList />
components to reflect the changes you made to the context:
//📂./src/components/TodoList.tsx
import { useTodo } from '../context/useTodo'
import { SiStarship } from 'react-icons/si'
export const TodoList = () => {
const { todos } = useTodo()
if (!todos.length) {
return (
<div className="max-w-lg px-5 m-auto">
<h1 className="flex flex-col items-center gap-5 px-5 py-10 text-xl font-bold text-center rounded-xl bg-zinc-900">
<SiStarship className="text-5xl" />
You have nothing to do!
</h1>
</div>
)
}
return (
<ul className="grid max-w-lg gap-2 px-5 m-auto">
{todos.map(todo => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
)
}
With this updated code, the todo’s id is now being used as the key prop for each rendered todo item, and the todo’s text is being used to display the content of each todo item.
Now, let’s create a custom React component to appropriately display each todo item and introduce additional functionalities like editing, deleting, and updating individual todo items in our app.
How to Build a Custom React Component for Displaying Todo Items #
In this section, you will be creating a custom React component that handles the display and management of each individual todo item.
Open up components/TodoItem.tsx
and add the following code:
//📂./src/components/TodoItem.tsx
export const TodoItem = (props: { todo: Todo }) => {
const { todo } = props
return (
<motion.li
layout
className={cn(
'p-5 rounded-xl bg-zinc-900',
todo.status === 'completed' && 'bg-opacity-50 text-zinc-500',
)}
>
<motion.span
layout
style={{
textDecoration: todo.status === 'completed' ? 'line-through' : 'none',
}}
>
{todo.text}
</motion.span>
</motion.li>
)
}
<TodoItem />
is responsible for rendering an individual todo item:
- The component takes a prop called
props
, which is an object containing a property calledtodo
. Thetodo
property is of typeTodo
, representing a single todo item. - Inside the component, the
todo
property is extracted from theprops
object using destructuring assignment. - The
motion.li
component is used from a Framer Motion to provide animations. It represents a list item (<li>
) and supports layout animations. - The
className
attribute uses thecn
utility function (from theclassnames
library) to conditionally apply CSS classes based on thetodo.status
. If the todo is completed, it adds classes for a semi-transparent background and text color. - Inside the list item, a
motion.span
component is used to wrap the todo text. It also supports layout animations. - The style of the span element is set based on the
todo.status
. If the todo is completed, a line-through text decoration is applied. - The
{todo.text}
expression renders the text content of the todo item.
TodoItem receives a todo item as a prop and renders it with optional animations, styling, and conditional CSS classes based on the todo’s status.
Now let’s modify <TodoList />
component to use the <TodoItem />
component:
//📂./src/components/TodoList.tsx
import { TodoItem } from './TodoItem'
import { useTodo } from '../context/useTodo'
import { SiStarship } from 'react-icons/si'
import { motion } from 'framer-motion'
export const TodoList = () => {
const { todos } = useTodo()
if (!todos.length) {
return (
<div className="max-w-lg px-5 m-auto">
<h1 className="flex flex-col items-center gap-5 px-5 py-10 text-xl font-bold text-center rounded-xl bg-zinc-900">
<SiStarship className="text-5xl" />
You have nothing to do!
</h1>
</div>
)
}
return (
<motion.ul className="grid max-w-lg gap-2 px-5 m-auto">
{todos.map(todo => (
<TodoItem todo={todo} key={todo.id} />
))}
</motion.ul>
)
}
Here’s an explanation of what’s changed in the <TodoList />
:
Importing additional dependencies:
- The code now imports the
motion
component from theframer-motion
library. This allows for animations in the component.
Rendering TodoItem component:
- Previously, the todo items were rendered as simple list items (
<li>
) directly within the TodoList component. - In the updated version, the TodoItem component is imported (
import { TodoItem } from './TodoItem'
) and used to render each todo item. - The TodoItem component is passed the
todo
prop, which represents an individual todo item. - The
key
prop is also provided to each TodoItem component, ensuring a unique identifier for each rendered todo item.
Wrapping the list with motion component:
- The
<ul>
element is now wrapped with the<motion.ul>
component to enable animations using theframer-motion
library. - This allows for dynamic and smooth transitions when adding, removing, or updating todo items.
Overall, the updated TodoList component introduces animations using the motion
component from framer-motion
and replaces the direct rendering of todo items with the <TodoItem />
component.
Now that you have successfully created the <TodoItem />
component, let’s shift our focus towards implementing the necessary functionalities to enable editing, deleting, and updating of each todo item using the Todo Context and TodoItem component.
How to Implement Functionality: Edit, Delete, and Update Todo Items #
In this section, you will enhance your Todo app by incorporating additional functionalities.
Firstly, you will implement the necessary logic within the todo context to handle these functionalities. Then, you will add the corresponding JSX to the <TodoItem />
component to introduce interactivity and enable users to interact with the app.
As you recall, you utilized context to handle adding todo items to the app, and you will follow a similar approach for the edit, delete, and update functionalities.
The logic for these actions will be encapsulated within the todo context, and the useTodo hook will be utilized to leverage this logic within the <TodoItem />
component. You will also store the todo items in the browser’s local storage to ensure that users do not lose their progress when they leave the app.
Open up context/TodoContext.tsx
and add the following code:
// 📂./src/context/TodoContext.tsx
import React, { createContext } from 'react'
import { nanoid } from 'nanoid'
import { useLocalStorage } from 'usehooks-ts'
interface TodoContextProps {
todos: Todo[]
addTodo: (text: string) => void
deleteTodo: (id: string) => void
editTodo: (id: string, text: string) => void
updateTodoStatus: (id: string) => void
}
export interface Todo {
id: string
text: string
status: 'undone' | 'completed'
}
export const TodoContext = createContext<TodoContextProps | undefined>(
undefined,
)
export const TodoProvider = (props: { children: React.ReactNode }) => {
const [todos, setTodos] = useLocalStorage<Todo[]>('todos', [])
// ::: ADD NEW TODO :::
const addTodo = (text: string) => {
const newTodo: Todo = {
id: nanoid(),
text,
status: 'undone',
}
setTodos([...todos, newTodo])
}
// ::: DELETE A TODO :::
const deleteTodo = (id: string) => {
setTodos(prevTodos => prevTodos.filter(todo => todo.id !== id))
}
// ::: EDIT A TODO :::
const editTodo = (id: string, text: string) => {
setTodos(prevTodos => {
return prevTodos.map(todo => {
if (todo.id === id) {
return { ...todo, text }
}
return todo
})
})
}
// ::: UPDATE TODO STATUS :::
const updateTodoStatus = (id: string) => {
setTodos(prevTodos => {
return prevTodos.map(todo => {
if (todo.id === id) {
return {
...todo,
status: todo.status === 'undone' ? 'completed' : 'undone',
}
}
return todo
})
})
}
const value: TodoContextProps = {
todos,
addTodo,
deleteTodo,
editTodo,
updateTodoStatus,
}
return (
<TodoContext.Provider value={value}>{props.children}</TodoContext.Provider>
)
}
Here’s an explanation of what’s happening:
Defining TodoContextProps:
- TodoContextProps is an interface that specifies the structure of the TodoContext’s value.
- It includes properties such as todos (an array of Todo items) and functions for adding, deleting, editing, and updating the status of todo items.
Implementing addTodo
:
- The addTodo function takes a text parameter, generates a unique ID using nanoid, and creates a new todo object with the provided text and an initial status of ‘undone’.
- It uses the setTodos function, provided by useLocalStorage, to update the todos state by appending the newTodo to the existing array of todos.
Implementing deleteTodo
:
- The deleteTodo function takes an id parameter and uses the setTodos function to filter out the todo item with the matching id from the todos state.
Implementing editTodo
:
- The editTodo function takes an id and text parameter.
- It uses the setTodos function to map over the todos state and update the text of the todo item with the matching id.
Implementing updateTodoStatus
:
- The updateTodoStatus function takes an id parameter.
- It uses the setTodos function to map over the todos state and toggle the status of the todo item with the matching id between ‘undone’ and ‘completed’.
Providing the value and rendering child components:
- The value object is created with the todos array and the defined functions.
- It is passed as the value prop to the TodoContext.Provider component to provide the defined values to its nested child components.
In summary, the TodoContext
and TodoProvider
handle the state and logic related to managing todo items. They provide the necessary functions and data through the TodoContext to be used by child components, such as <TodoItem />
, for performing operations like adding, deleting, editing, and updating todo items.
Now, let’s incorporate the corresponding JSX to enable users to interact with the logic that you have just implemented. Open up components/TodoItem.tsx
and add the following code:
//📂./src/components/TodoItem.tsx
import { useEffect, useRef, useState } from 'react'
import { Todo } from '../context/TodoContext'
import { useTodo } from '../context/useTodo'
import { Input } from './Input'
import { BsCheck2Square } from 'react-icons/bs'
import { TbRefresh } from 'react-icons/tb'
import { FaRegEdit } from 'react-icons/fa'
import { RiDeleteBin7Line } from 'react-icons/ri'
import { toast } from 'react-hot-toast'
import cn from 'classnames'
import { motion } from 'framer-motion'
export const TodoItem = (props: { todo: Todo }) => {
const { todo } = props
const [editingTodoText, setEditingTodoText] = useState<string>('')
const [editingTodoId, setEditingTodoId] = useState<string | null>(null)
const { deleteTodo, editTodo, updateTodoStatus } = useTodo()
const editInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (editingTodoId !== null && editInputRef.current) {
editInputRef.current.focus()
}
}, [editingTodoId])
const handleEdit = (todoId: string, todoText: string) => {
setEditingTodoId(todoId)
setEditingTodoText(todoText)
if (editInputRef.current) {
editInputRef.current.focus()
}
}
const handleUpdate = (todoId: string) => {
if (editingTodoText.trim() !== '') {
editTodo(todoId, editingTodoText)
setEditingTodoId(null)
setEditingTodoText('')
toast.success('Todo updated successfully!')
} else {
toast.error('Todo field cannot be empty!')
}
}
const handleDelete = (todoId: string) => {
deleteTodo(todoId)
toast.success('Todo deleted successfully!')
}
const handleStatusUpdate = (todoId: string) => {
updateTodoStatus(todoId)
toast.success('Todo status updated successfully!')
}
return (
<motion.li
layout
key={todo.id}
className={cn(
'p-5 rounded-xl bg-zinc-900',
todo.status === 'completed' && 'bg-opacity-50 text-zinc-500',
)}
>
{editingTodoId === todo.id ? (
<motion.div layout className="flex gap-2">
<Input
ref={editInputRef}
type="text"
value={editingTodoText}
onChange={e => setEditingTodoText(e.target.value)}
/>
<button
className="px-5 py-2 text-sm font-normal text-orange-300 bg-orange-900 border-2 border-orange-900 active:scale-95 rounded-xl"
onClick={() => handleUpdate(todo.id)}
>
Update
</button>
</motion.div>
) : (
<div className="flex flex-col gap-5">
<motion.span
layout
style={{
textDecoration:
todo.status === 'completed' ? 'line-through' : 'none',
}}
>
{todo.text}
</motion.span>
<div className="flex justify-between gap-5 text-white">
<button onClick={() => handleStatusUpdate(todo.id)}>
{todo.status === 'undone' ? (
<span className="flex items-center gap-1">
<BsCheck2Square />
Mark Completed
</span>
) : (
<span className="flex items-center gap-1">
<TbRefresh />
Mark Undone
</span>
)}
</button>
<div className="flex items-center gap-2">
<button
onClick={() => handleEdit(todo.id, todo.text)}
className="flex items-center gap-1 "
>
<FaRegEdit />
Edit
</button>
<button
onClick={() => handleDelete(todo.id)}
className="flex items-center gap-1 text-red-500"
>
<RiDeleteBin7Line />
Delete
</button>
</div>
</div>
</div>
)}
</motion.li>
)
}
Let’s focus on the handleEdit
, handleUpdate
, handleDelete
, and handleStatusUpdate
functions and how they work:
handleEdit
function:
This function is called when the user clicks the “Edit” button. It takes the todoId
(unique identifier for the todo item) and todoText
(current text of the todo item) as parameters.
It sets the editingTodoId
state to the todoId
and the editingTodoText
state to the todoText
. Also, if the editInputRef
(a reference to the input field) exists, it sets the focus on the input field using the focus
method.
handleUpdate
function:
This function is called when the user clicks the “Update” button after editing a todo item. It takes the todoId
as a parameter.
It first checks if the trimmed editingTodoText
is not empty. If it’s not empty, it calls the editTodo
function from the useTodo
hook, passing the todoId
and editingTodoText
as arguments. It then resets the editingTodoId
and editingTodoText
states to null and an empty string, respectively.
Finally, it displays a success toast message if the update was successful or an error toast message if the todo field was empty.
handleDelete
function:
This function is called when the user clicks the “Delete” button. It takes the todoId
as a parameter. It calls the deleteTodo
function from the useTodo
hook, passing the todoId
as an argument. It then displays a success toast message indicating that the todo item was deleted successfully.
handleStatusUpdate
function:
This function is called when the user clicks the “Mark Completed” or “Mark Undone” button. It takes the todoId
as a parameter.
It calls the updateTodoStatus
function from the useTodo
hook, passing the todoId
as an argument. It then displays a success toast message indicating that the todo item’s status was updated successfully.
These functions handle the interactions and actions related to editing, updating, deleting, and updating the status of a todo item in the TodoItem component.
The JSX displays the todo’s text with the option to edit, delete, and update its status. The appearance and behavior of the todo item are determined by the values of the todo
object and the component’s state variables.
If the todo is being edited, an input field and an “Update” button are shown. Otherwise, the todo’s text is displayed, and buttons for marking it as completed or undone, editing, and deleting are available.
The handleEdit
, handleUpdate
, handleDelete
, and handleStatusUpdate
functions are used as event handlers for these buttons, enabling the user to interact with and modify the todo item.
Final result
Congratulations! You have successfully created a beautiful Todo app with the essential features.
With the knowledge gained from this article, you are now well-equipped to further enhance and customize the app based on your specific needs and preferences.
Conclusion #
Throughout this article, we’ve covered the fundamentals of React development with TypeScript and learned how to create a fully functional Todo app.
We explored concepts like state management, context, and hooks, enabling you to add, edit, delete, and update todo items.
With this knowledge, you’re now ready to apply these principles to your future projects and build type-safe applications with React. Keep exploring and experimenting with new features to take your app to the next level.
You can follow me on Twitter where I share more useful tips on web development. Happy coding!
Learn to code for free. freeCodeCamp’s open source curriculum has helped more than 40,000 people get jobs as developers. Get started