Hooks are a relatively recent addition to React. They represent a major shift in the way we use state, lifecycle and other React concepts within components. In this article, we will dive into the motivations behind React Hooks, explore the different built-in hooks, and learn how to re-use functionality by creating our own custom hooks.
Simply put, React Hooks are just functions that when called from a function-based component, allow it to “hook into” certain React features. This is a game-changer, as it lets you build advanced features using function-based components, which are simpler and easier to reason about.
There are various built-in hooks provided by React. useState
and useEffect
are the two most used ones. We will learn about these and other built-in hooks in upcoming sections.
Since hooks behave just like any other JavaScript function, you can also define your own hooks to reuse functionality across components within your app.
There are, however, some rules to follow when using hooks.
- Hooks can only be called from function-based components – The React documentation encourages developers to write any new functionality as function-based components. Class-based components are still supported, but will not be able to leverage hooks.
- Hooks must always be called from the top level of the function – When there are multiple hooks used in a function, React relies in the order they were called to maintain their state between calls. To ensure that this works, hooks must never be called conditionally, within loops or anywhere other than the top level of the function.
- Custom hooks must always start with ‘use’ – React uses the name of the function to determine whether it is a hook, which then lets it check for any violations of the prior rules. So it is recommended for custom hooks to start with the word ‘use’ –
useWindowWidth
, for example.
Why React Hooks?#
Some of the main pain points when using class-based components are:
Boilerplate Code π#
Whenever you create a class-based component, it has to inherit from React.Component
. This means that if you want to add a constructor with some logic to execute at instantiation time, you will always have to call super(props)
first.
import React from "react";
export class Textbox extends React.Component {
constructor(props) {
super(props);
// Some instantiation logic here
}
}
Additionally, because of the way the this
keyword works in JavaScript, event handlers are required to be specifically bound to the current context. π€―This further increases the amount of boilerplate needed.
import React from "react";
export class Textbox extends React.Component {
constructor(props) {
super(props);
this.state = { text: "foo" };
this.onTextChange = this.onChangeText.bind(this);
}
onChangeText() {
this.setState({ text: "bar" });
}
}
An alternative to this is to use arrow functions(introduced in ES6), which will have access to their bounding scope. That, however, is just one more thing to keep in mind.
Monolithic State π»#
In class-based components, the state
property is always an object and can not be any other primitive. This means that whenever you need to update a single value in your state, the setState
call will merge the new value with the current state object. This makes it difficult to separate concerns and to potentially extract reusable code.
In this example, we have two related properties firstName
and lastName
, co-located with an unrelated color
property in the state
object. If they could be separated, any logic around handling name changes could potentially be extracted out. However, this is not possible with a class-based component.
import React from "react";
export class Name extends React.Component {
constructor() {
super();
this.state = {
color: "blue",
firstName: "Mary",
lastName: "Poppins"
};
}
getFullname() {
return `${this.state.firstName} ${this.state.lastName}`;
}
render() {
return (
<div style={{ background: this.state.color }}>
<h3>{this.getFullname()}</h3>
</div>
)
}
}
Side Effects and Lifecycle Methods π#
As a consequence of how class-based components work, the only way to trigger code on state changes is via lifecycle methods such as ComponentDidMount
and ComponentDidUpdate
.
In this example, the Counter
component keeps the page title in sync with the counter value.
To accomplish this, we’ve had to repeat the same code in componentDidMount
(to set the page title when the component loads), and in componentDidUpdate
(to set the page title each time the counter is incremented). If we wanted to reset the title when the Counter
is removed from the screen, we would have had to repeat the code in componentWillUnmount
as well.
import React from "react";
export class Counter extends React.Component {
constructor() {
super()
this.state = {
counter: 0
}
this.handleIncrement = this.handleIncrement.bind(this)
}
componentDidMount() {
document.title = this.state.counter;
}
componentDidUpdate() {
document.title = this.state.counter;
}
handleIncrement() {
this.setState({
counter: this.state.counter += 1
})
}
render() {
return (
<div>
<div>{this.state.counter}</div>
<button onClick={this.handleIncrement}>+</button>
</div>
)
}
}
Now that we have understood all these pitfalls, let us explore how React Hooks provide solutions to the above problems.
Built-in Hooks#
useState
#
The useState
hook provides a means for function-based components to maintain state.
This state does not necessarily have to be an object – the useState
hook supports any primitive so you can use numbers, strings, booleans or arrays as well.
Here is a simple example of how to use it. It uses the Name
component we have seen in the previous section, now converted to be function-based.
import React, { useState } from "react";
const Name = () => {
const [name, setName] = useState({first: "Mary", last: "Poppins"});
const [color, setColor] = useState("blue");
const getFullName = () => {
return `${name.first} ${name.last}`;
}
return (
<div style={{ background: color }}>
<h3>{getFullName()}</h3>
</div>
);
}
The different state variables have now been decoupled and can be managed independently. The Name
component will re-render whenever the values of the state variables change.
useEffect
#
The useEffect
hook can be used to trigger side effects in function-based components. Earlier in the article, we saw how class-based components use lifecycle methods to allow for reacting to state changes. Let us now see how this can be done in a much more simplified way using a combination of the useState
and useEffect
hooks.
import React, { useState, useEffect } from "react";
export const Counter = () => {
const [counter, setCounter] = useState(0);
useEffect(() => {
document.title = counter;
}, [counter])
return (
<div>
<div>{counter}</div>
<button onClick={() => setCounter(counter + 1)}>+</button>
</div>
)
};
The useEffect
hook we have used here takes two parameters:
- The function to execute when the effect is triggered.
- An optional array of ‘dependencies’ – any change in these dependencies will trigger the effect.
In the example, the effect is triggered each time the value of the counter
variable changes. It sets the document title to the current counter value.
This handles two scenarios – the initial load scenario covered by componentDidMount
, and the subsequent update scenario covered by componentDidUpdate
.
Additionally, the function passed into useEffect
can optionally return another ‘clean up’ function, which can be used to provide componentWillUnmount
behaviour.
useEffect(() => {
document.title = counter;
return () => document.title = "Reset Title";
}, [counter])
If we wanted to perform an action just on initial load, we can use the useEffect
hook with an empty dependency array, like so:
useEffect(() => {
document.title = "My Custom Title";
}, [])
useCallback
#
Due to the asynchronous nature of state changes, it can not always be guaranteed that any functions declared within a component will have the most up-to-date values of state variables. This manifests most commonly when using an event handler to update the state, when the new state is based on the previous value. In the example below, the incrementCounter
function may not always have the newest value for the counter
import React, { useState, useEffect } from "react";
export const Counter = () => {
const [counter, setCounter] = useState(0);
useEffect(() => {
document.title = counter;
}, [counter])
const incrementCounter = () => {
setCounter(counter + 1)
}
return (
<div>
<div>{counter}</div>
<button onClick={incrementCounter}>+</button>
</div>
)
};
When declaring a function that depends on variables outside of it, we can make use of the useCallback
hook. This will make sure that the function is updated each time any of the variables it depends on changes.
import React, { useState, useEffect, useCallback } from "react";
export const Counter = () => {
const [counter, setCounter] = useState(0);
useEffect(() => {
document.title = counter;
}, [counter])
const incrementCounter = useCallback(() => {
setCounter(counter + 1)
}, [counter])
return (
<div>
<div>{counter}</div>
<button onClick={incrementCounter}>+</button>
</div>
)
};