React Hooks
Introduction
I have been reading this book on React 18 and its best practices. As I go through this book, I will distill the core pieces of the most interesting tid-bits in this series of articles on React. In this article we will talk about React hooks, which — since React 16.8 — have allowed developers to switch from using class based React components to functional ones by providing some powerful and crucial capabilities. We will be looking at some of the most common hooks: useState, useEffect, useReducer, useMemo, memo and useCallback.
In the book example, we built up our understanding of the hooks using a todo app.
The Task Component
This component displays a single task in our todo list, it also has a function which is passed in to handle deleting the task. We will talk about useEffect and memo in this component.
import { FC, memo, useEffect } from "react"
interface Props {
id: number,
task: string,
handleDelete: (taskId: number) => void
}
export const Task: FC<Props> = memo(({id, task, handleDelete}) => {
useEffect(() => {
console.log("rendering <Task/>", task);
});
return (
<li>
{task}
<button onClick={ () => handleDelete(id)}>X</button>
</li>
)
});
The rendered HTML of this component is quite simple and there is very little functionality in the component itself.
The useEffect fires AFTER every render and executes the code inside the callback (in this trivial case it just console logs that we are rendering the component. The useEffect hook can also take in an optional second param of dependencies which trigger a re-render which we will visit in depth later on in this article.
memo is a higher order component that is used to optimize the app’s performance and improve re-rendering. Essentially, memo checks the props of the component and only triggers a re-render if the shallow check ‘===’ between the old and new props differs. In general, we should not memoize a component unless we need to (i.e. our web app is slow and laggy due to lots of expensive re-renders).
The List Component
For the sake of completeness of the example, the List component is provided below. However, it does not introduce any new hooks — or functionalities of hooks — that were not discussed in Task.
import { FC, memo, useEffect } from "react"
import { Task } from "./Task"
export type Todo = {
id: number,
task: string
}
interface Props {
todoList: Todo[],
handleDelete: (taskId: number) => void
}
export const List: FC<Props> = memo(({todoList, handleDelete}) => {
useEffect(() => {
console.log("Rendering <List/>");
})
return (
<ul>
{todoList.map((todo: Todo) => (
<Task key={todo.id} id={todo.id} task={todo.task} handleDelete={handleDelete}/>
))}
</ul>
)
});
The App Component
The bulk of our hooks live in the App component. Below is the code, we will walk through each new hook used.
import { ChangeEvent, FC, useCallback, useEffect, useMemo, useState } from "react"
import { List, Todo } from "./List"
const initialTodos: Todo[] = [
{id: 1, task: "Go Shopping"},
{id: 2, task: "Pay Electricity Bill"}
]
export const App: FC = () => {
const [todoList, setTodoList] = useState<Todo[]>(initialTodos);
const [task, setTask] = useState<string>("");
const [term, setTerm] = useState<string>("");
useEffect(() => {
console.log("Rendring <App/>");
});
const printTodoList = useCallback(() => {
console.log("printing Todo List", todoList);
}, [todoList])
useEffect(() => {
printTodoList();
}, [todoList, printTodoList])
const handleCreate = () => {
const newTodo: Todo = {
id: Date.now(),
task: task
};
setTodoList([...todoList, newTodo]);
setTask("");
}
const handleSearch = () => {
setTerm(task);
}
const handleDelete = useCallback((taskId: number) => {
const newTodoList = todoList.filter((todo: Todo) => todo.id !== taskId);
setTodoList(newTodoList);
}, [todoList]);
const filteredTodoList = useMemo(() => todoList.filter((todo: Todo) => {
console.log("Filtering...");
return todo.task.toLowerCase().includes(term.toLowerCase());
}), [term, todoList]);
return (
<>
<input
type="text"
value={task}
onChange={(e: ChangeEvent<HTMLInputElement>) => setTask(e.target.value)}
/>
<button onClick={handleCreate}>Create</button>
<button onClick={handleSearch}>Search</button>
<List todoList={filteredTodoList} handleDelete={handleDelete}/>
</>
)
}
The App component has an input field and some buttons to create a new task or search existing ones.
useState
The useState hook is the most useful and popular React hook out there. It gives functional components the power to store and manage dynamic data throughout their lifecycle.
To create a state, we declare a state value and a setter function as the result of a two element array calling useState with its initial state.
const [task, setTask] = useState<string>("");
We can then use the function to update the value of our state like so
(e: ChangeEvent<HTMLInputElement>) => setTask(e.target.value)
and we can use the value of the state variable anywhere in our code or html render.
useEffect
In this component, we included a useEffect with a dependency array. The dependency array allows us to specify values we want to watch which will trigger this useEffect. If any of them change, then the useEffect is re-fired — an empty array would only fire once when the component first renders.
useEffect(() => {
printTodoList();
}, [todoList, printTodoList])
Here, we have a useEffect that depends on the todoList state and printTodoList function, anytime one of those change, the printTodoLost function is called to log out our current todo items.
useMemo
If our component needs to compute a value to use, it would be a pain to re-compute the value every re-render if the input values do not change. We have such a case here with our filteredTodoList where we only need to re-compute the value when the search term or todoList changes.
const filteredTodoList = useMemo(() => todoList.filter((todo: Todo) => {
console.log("Filtering...");
return todo.task.toLowerCase().includes(term.toLowerCase());
}), [term, todoList]);
Now we will only re-compute the filtered list value if its dependencies change instead of on each re-render as we have done previously. Like memo, this hook should only be used if we need to use it (i.e. a compute intensive value calculation is slowing down our render).
useCallback
Finally, we will discuss useCallback. Like values, our functions are recalculated each render, this might not be too bad since defining a function itself is not too compute heavy. However, if a function is a dependency of a useEffect or passed into a child component as a prop, this will cause the useEffect to be called and child component to re-render on each parent re-render since the new function is no longer ‘===’ to the old one. UseCallback ensures the same function is passed so long as the dependencies are unchanged.
const handleDelete = useCallback((taskId: number) => {
const newTodoList = todoList.filter((todo: Todo) => todo.id !== taskId);
setTodoList(newTodoList);
}, [todoList]);
Our handle delete — which we pass all the way down to task — will now only be re-created when the todoList state changes allowing us to reduce unnecessary re-renders of our child components which receive this function as a prop. Again — like memo and useMemo — this hook should only be used if we need to improve App performance.
UseReducer
The last hook we will cover is UseReducer, for this hook, the book decided to make an entirely new notes app.
The reducer allows us to do much of the same things react-redux does, but is limited to just this component unlike react-redux which is global.
import { FormEvent, useReducer, useState } from "react"
type Note = {
id: number,
note: string
}
type Action = {
type: ActionType,
payload?: any
}
enum ActionType {
ADD = 'ADD',
UPDATE = 'UPDATE',
DELETE = 'DELETE'
}
const initialNotes: Note[] = [
{
id: 1,
note: "Note 1"
},
{
id: 2,
note: "Note 2"
}
]
const reducer = (state: Note[], action: Action) => {
switch (action.type) {
case ActionType.ADD:
return [...state, action.payload];
case ActionType.DELETE:
return state.filter(note => note.id !== action.payload);
case ActionType.UPDATE:
const updatedNote = action.payload;
return state.map((note: Note) => note.id === updatedNote.id ? updatedNote : note);
default:
return state;
}
}
export const Notes = () => {
const [notes, dispatch] = useReducer(reducer, initialNotes);
const [note, setNote] = useState<string>('');
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const newNote: Note = {
id: Date.now(),
note: note
};
dispatch({type: ActionType.ADD, payload: newNote});
}
return (
<div>
<h2>Notes</h2>
<ul>
{notes.map((n: Note) => (
<li key={n.id}>
{n.note}{' '}
<button onClick={() => dispatch({type: ActionType.DELETE, payload: n.id})}>X</button>
<button onClick={() => dispatch({type: ActionType.UPDATE, payload: {...n, note: note}})}>Update</button>
</li>
))}
</ul>
<form onSubmit={handleSubmit}>
<input
placeholder="New Note"
value={note}
onChange={e => setNote(e.target.value)}
/>
</form>
</div>
)
}
To create a reducer, we first must define a reducer function which takes in the current state and an action to be preformed, this essentially encapsulates all state changes in a single function reducing the possibility for duplicate code we would write to do some state changes on a useState hook.
to useReducer, we pass in the reducer function and our initial state, we get back the state variable — notes in this case — and a dispatch function.
When we want to update the state, we dispatch an action which we have defined with a type and a payload (quite common).
The useReducer can be thought of as an alternative to useState for those devs who like the Redux reducers style of state management.
Conclusion
In this article, we explored a couple of powerful React hooks. The main powerhouse hooks for making our app function were useState and useEffect. We tried useReducer as an alternative to useState. Lastly, we learned how memo, useMemo and useCallback can improve our app’s performance by caching computed components, values and functions but should only be used if it is absolutely necessary.