React Hooks: A brief introduction

Chapter Outline

React Hooks: A Deep Dive into useState, useEffect, and Custom Hooks

React Hooks revolutionized the way we write React components by allowing us to use state and other React features without writing a class. Introduced in React 16.8, hooks like useState and useEffect have become essential tools for every React developer. In this post, we'll explore these hooks in detail, along with creating our own custom hooks to maximize reusability and maintainability in our applications.

Handling State and Side Effects Before Hooks

Before hooks were introduced, React relied heavily on class components for managing state and side effects. Understanding how this worked can give us a better appreciation for the simplicity and flexibility that hooks provide.

State Management in Class Components

In class components, state is managed using the state property and the setState method. Here's an example:

jsx
1import React, { Component } from 'react';
2
3class Counter extends Component {
4 constructor(props) {
5 super(props);
6 this.state = { count: 0 };
7 }
8
9 increment = () => {
10 this.setState((prevState) => ({ count: prevState.count + 1 }));
11 };
12
13 render() {
14 return (
15 <div>
16 <p>Count: {this.state.count}</p>
17 <button onClick={this.increment}>Increment</button>
18 </div>
19 );
20 }
21}
22
23export default Counter;

In this example, the Counter component initializes state in the constructor and uses this.setState to update the state. Each state change triggers a re-render of the component.

Side Effects in Class Components

Side effects in class components are handled using lifecycle methods such as componentDidMount, componentDidUpdate, and componentWillUnmount. Here’s an example of how you might set up a timer in a class component:

jsx
1import React, { Component } from 'react';
2
3class Timer extends Component {
4 constructor(props) {
5 super(props);
6 // Initialize the state with count set to 0
7 this.state = { count: 0 };
8 }
9
10 componentDidMount() {
11 // Set up a timer that increments the count state every second
12 this.timer = setInterval(() => {
13 this.setState((prevState) => ({ count: prevState.count + 1 }));
14 }, 1000);
15 }
16
17 componentDidUpdate(prevProps, prevState) {
18 // Check if the count has changed and log the new count
19 if (this.state.count !== prevState.count) {
20 console.log('Count changed:', this.state.count);
21 }
22 }
23
24 componentWillUnmount() {
25 // Clear the timer when the component unmounts to prevent memory leaks
26 clearInterval(this.timer);
27 }
28
29 render() {
30 return (
31 <div>
32 <p>Count: {this.state.count}</p>
33 </div>
34 );
35 }
36}
37
38export default Timer;

Here, componentDidMount sets up the timer when the component mounts, componentDidUpdate logs the count when it changes, and componentWillUnmount cleans up the timer when the component unmounts.

Explanation of Inefficiencies

  1. Unnecessary Updates: The componentDidUpdate method checks if this.state.count has changed to log the new count. However, this method runs on every update, which can be inefficient if there are other state or prop changes that don't affect count.
  2. Component-Based Timer Logic: Using setInterval in componentDidMount and clearing it in componentWillUnmount can be less intuitive and more error-prone, especially as the component grows in complexity. It's generally better to handle side effects like this in a more declarative manner using hooks (in functional components).

State and Side Effects in Functional Components

Before hooks, functional components were stateless and couldn’t handle side effects directly. They were simple functions that took props and returned JSX. Any state management or side effects had to be handled in parent class components.

jsx
1const Greeting = (props) => {
2 return <h1>Hello, {props.name}!</h1>;
3};
4
5export default Greeting;

Understanding useState

The useState hook is the most fundamental hook in React, enabling us to add state to functional components. It returns an array with two elements: the current state value and a function to update that state.

Basic Usage

Here's a simple example of how useState works:

jsx
1import React, { useState } from 'react';
2
3const Counter = () => {
4 // Declare a state variable 'count' and a function 'setCount' to update it
5 // Initialize 'count' to 0 using the useState hook
6 const [count, setCount] = useState(0);
7
8 // Define a function 'increment' that increments the 'count' state by 1
9 const increment = () => setCount(count + 1);
10
11 // Return the JSX to render
12 return (
13 <div>
14 {/* Display the current value of 'count' */}
15 <p>Count: {count}</p>
16 {/* Render a button that calls 'increment' function when clicked */}
17 <button onClick={increment}>Increment</button>
18 </div>
19 );
20};
21
22export default Counter;

In this example, useState(0) initializes the state variable count to 0. The setCount function updates the state, and the component re-renders with the new count value when the button is clicked.

Using useState with Objects

State can also be an object, which is useful for managing more complex state:

jsx
1import React, { useState } from 'react';
2
3// Define a functional component named UserProfile
4const UserProfile = () => {
5 // Declare a state variable 'user' and a function 'setUser' to update it
6 // Initialize 'user' with an object containing 'name' and 'age' properties
7 const [user, setUser] = useState({ name: '', age: 0 });
8
9 // Define a function 'updateName' that updates the 'name' property of 'user'
10 const updateName = (name) => setUser({ ...user, name });
11
12 // Define a function 'updateAge' that updates the 'age' property of 'user'
13 const updateAge = (age) => setUser({ ...user, age });
14
15 // Return the JSX to render
16 return (
17 <div>
18 {/* Input field for user's name */}
19 <input
20 type="text"
21 value={user.name}
22 onChange={(e) => updateName(e.target.value)}
23 placeholder="Name"
24 />
25
26 {/* Input field for user's age */}
27 <input
28 type="number"
29 value={user.age}
30 onChange={(e) => updateAge(parseInt(e.target.value, 10))}
31 placeholder="Age"
32 />
33
34 {/* Display the user's name and age */}
35 <p>
36 Name: {user.name}, Age: {user.age}
37 </p>
38 </div>
39 );
40};
41
42// Export the UserProfile component as the default export
43export default UserProfile;

Here, useState initializes user as an object with name and age properties. The updateName and updateAge functions update the respective properties while preserving the other properties using the spread operator.

Exploring useEffect

The useEffect hook allows us to perform side effects in function components. It can be used for tasks like fetching data, directly manipulating the DOM, and setting up subscriptions.

Basic Usage

Here's an example of useEffect:

jsx
1import React, { useState, useEffect } from 'react';
2
3const Timer = () => {
4 // Declare a state variable 'count' and a function 'setCount' to update it
5 // Initialize 'count' to 0
6 const [count, setCount] = useState(0);
7
8 // useEffect hook to handle side effects
9 useEffect(() => {
10 // Set up an interval that updates 'count' every second (1000 milliseconds)
11 const timer = setInterval(() => {
12 // Update the 'count' state using the previous state value
13 setCount((prevCount) => prevCount + 1);
14 }, 1000);
15
16 // Return a cleanup function to clear the interval when the component unmounts
17 return () => clearInterval(timer);
18 }, []); // The empty dependency array ensures this effect runs only once after the initial render
19
20 // Return the JSX to render
21 return <div>Count: {count}</div>;
22};
23
24export default Timer;

In this example, useEffect sets up a timer that increments the count state every second. The function passed to useEffect runs after the component renders. The cleanup function returned by useEffect clears the timer when the component unmounts.

Dependencies in useEffect

The second argument to useEffect is a dependency array. This array tells React when to re-run the effect:

jsx
1useEffect(() => {
2 console.log('Count changed:', count);
3}, [count]);

Here, the effect runs only when count changes. If the dependency array is empty ([]), the effect runs only once after the initial render.

Creating Custom Hooks

Custom hooks let us encapsulate logic that we want to reuse across multiple components. A custom hook is simply a JavaScript function whose name starts with use and that can call other hooks.

Example: useLocalStorage

Let's create a custom hook called useLocalStorage to manage state that syncs with localStorage:

jsx
1import { useState, useEffect } from 'react';
2
3const useLocalStorage = (key, initialValue) => {
4 // State value stored in localStorage
5 const [value, setValue] = useState(() => {
6 // Retrieve stored value from localStorage based on key
7 const storedValue = localStorage.getItem(key);
8 // Parse JSON stored value or use initialValue if no stored value
9 return storedValue ? JSON.parse(storedValue) : initialValue;
10 });
11
12 // useEffect hook to update localStorage whenever value or key changes
13 useEffect(() => {
14 // Stringify value and store it in localStorage under specified key
15 localStorage.setItem(key, JSON.stringify(value));
16 // useEffect dependencies include key and value to trigger update
17 }, [key, value]);
18
19 // Return value state and setValue function to update it
20 return [value, setValue];
21};
22
23export default useLocalStorage;

Using useLocalStorage

Here's how you can use the useLocalStorage hook:

jsx
1import React from 'react';
2import useLocalStorage from './useLocalStorage';
3
4const Settings = () => {
5 const [username, setUsername] = useLocalStorage('username', '');
6
7 return (
8 <div>
9 <input
10 type="text"
11 value={username}
12 onChange={(e) => setUsername(e.target.value)}
13 placeholder="Username"
14 />
15 <p>Stored Username: {username}</p>
16 </div>
17 );
18};
19
20export default Settings;

This example demonstrates a form where the username input is synced with localStorage. The useLocalStorage hook manages the state and handles the side effect of updating localStorage.

Optimizing React Hooks

Optimizing hooks involves several strategies to ensure that your components run efficiently and avoid unnecessary re-renders.

Memoization

Use useMemo to memoize expensive calculations and useCallback to memoize functions, preventing unnecessary re-creations on every render.

jsx
1import React, { useState, useMemo, useCallback } from 'react';
2
3function ExpensiveComponent({ data }) {
4 const [count, setCount] = useState(0);
5
6 const expensiveCalculation = useMemo(() => {
7 return data.reduce((acc, value) => acc + value, 0);
8 }, [data]);
9
10 const handleClick = useCallback(() => {
11 setCount(count + 1);
12 }, [count]);
13
14 return (
15 <div>
16 <p>Sum: {expensiveCalculation}</p>
17 <button onClick={handleClick}>Increment</button>
18 </div>
19 );
20}

Dependency Arrays

Always specify dependency arrays in useEffect, useCallback, and useMemo to control when your side effects or memoizations should be re-executed. Omitting dependencies can lead to performance issues or incorrect behavior.

jsx
1useEffect(() => {
2 // Your effect logic
3}, [dependency1, dependency2]);

Gotchas with Hooks

Infinite Loops

Be cautious with dependency arrays in useEffect to avoid infinite loops. Ensure that you correctly manage dependencies and avoid unnecessary updates.

jsx
1useEffect(() => {
2 const interval = setInterval(() => {
3 setSeconds(s => s + 1);
4 }, 1000);
5
6 return () => clearInterval(interval);
7}, []); // Empty array to run only once

Here inside the useEffect callback function, an interval is set up using setInterval to update the state variable seconds every 1000 milliseconds (1 second). However, the component enters an infinite loop where the useEffect runs once, sets up the interval that updates seconds every second, but the seconds state actually never changes because the interval callback always references the initial state (0). This causes the effect to continuously execute the setSeconds function, creating an infinite loop of state updates.

Stale Closures

When using state within a function inside useEffect or event handlers, be aware of stale closures, which can lead to unexpected behavior. Let's consider a game of earning and spending money:

jsx
1import React, { useState } from 'react';
2
3export function App(props) {
4 const [balance, setBalance] = useState(0);
5
6 const addFund = (amount) => {
7 // This simulates an async
8 setTimeout(function delay() {
9 const newBalance = balance + amount;
10 console.log(`After adding $${amount}, the new account balance: $${newBalance}`);
11 setBalance(newBalance);
12 }, 1000);
13 };
14
15 const removeFund = (amount) => {
16 // This simulates an async
17 setTimeout(function delay() {
18 const newBalance = balance - amount;
19 if (newBalance >= 0) {
20 console.log(`After removing $${amount}, New account balance: $${newBalance}`);
21 setBalance(newBalance);
22 } else {
23 console.log(`Cannot spend $${amount}, because your current balance is $${balance}`);
24 }
25 }, 1000);
26 };
27
28 const viewBalance = () => console.log(`Current balance $${balance}`);
29
30 return (
31 <div>
32 <p style={{ color: 'white' }}>Add money to your account</p>
33 <div style={{ display: 'flex', justifyContent: 'space-between', padding: '1rem', width: '15rem' }}>
34 <button onClick={() => addFund(5)}>Add $5</button>
35 <button onClick={() => addFund(10)}>Add $10</button>
36 <button onClick={() => addFund(20)}>Add $20</button>
37 </div>
38 <p style={{ color: 'white' }}>Spend money from your account</p>
39 <div style={{ display: 'flex', justifyContent: 'space-between', padding: '1rem', width: '15rem' }}>
40 <button onClick={() => removeFund(5)}>Spend $5</button>
41 <button onClick={() => removeFund(10)}>Spend $10</button>
42 <button onClick={() => removeFund(20)}>Spend $20</button>
43 </div>
44 <div style={{ display: 'flex', justifyContent: 'center', padding: '1rem' }}>
45 <button onClick={() => viewBalance()}>Print balance</button>
46 </div>
47 </div>
48 );
49}

Clicking Add or Spend adds or removes funds from your account after a 1 second delay. Everything works as expected, until you try to add or spend money rapidly. The value of balance does not update as expected.

useState stale clodure problem

This is because, on each click setTimeout(delay, 1000) schedules the execution of delay() after 1 second. delay() captures the variable balance as being the value when it was scheduled. Since setBalance() are executed asynchronously, the value of balance is bound within the closure of when it was scheduled, not when it is executed.

To fix the problem, let's use a functional way setBalance(balance => balance + amount) to update count state:

jsx
1import React, { useState } from 'react';
2
3export function App(props) {
4 const [balance, setBalance] = useState(0);
5
6 const addFund = (amount) => {
7 // This simulates an async
8 setTimeout(function delay() {
9 setBalance(balance => {
10 const newBalance = balance + amount;
11 console.log(`After adding $${amount}, the new account balance: $${newBalance}`);
12 return newBalance;
13 });
14 }, 1000);
15 };
16
17 const removeFund = (amount) => {
18 // This simulates an async
19 setTimeout(function delay() {
20 setBalance(balance => {
21 const newBalance = balance - amount;
22 if (newBalance >= 0) {
23 console.log(`After removing $${amount}, New account balance: $${newBalance}`);
24 return newBalance;
25 } else {
26 console.log(`Cannot spend $${amount}, because your current balance is $${balance}`);
27 return balance;
28 }
29 });
30 }, 1000);
31 };
32
33 const viewBalance = () => console.log(`Current balance $${balance}`);
34
35 return (
36 <div>
37 <p style={{ color: 'white' }}>Add money to your account</p>
38 <div style={{ display: 'flex', justifyContent: 'space-between', padding: '1rem', width: '15rem' }}>
39 <button onClick={() => addFund(5)}>Add $5</button>
40 <button onClick={() => addFund(10)}>Add $10</button>
41 <button onClick={() => addFund(20)}>Add $20</button>
42 </div>
43 <p style={{ color: 'white' }}>Spend money from your account</p>
44 <div style={{ display: 'flex', justifyContent: 'space-between', padding: '1rem', width: '15rem' }}>
45 <button onClick={() => removeFund(5)}>Spend $5</button>
46 <button onClick={() => removeFund(10)}>Spend $10</button>
47 <button onClick={() => removeFund(20)}>Spend $20</button>
48 </div>
49 <div style={{ display: 'flex', justifyContent: 'center', padding: '1rem' }}>
50 <button onClick={() => viewBalance()}>Print balance</button>
51 </div>
52 </div>
53 );
54}

This time adding and removing funds properly when performed in rapid successions.

useState stale clodure fixed

When a callback that returns the new state based on the previous one is supplied to the state update function, React makes sure that the latest state value is supplied as an argument to that callback:

javascript
setCount(alwaysActualStateValue => newStateValue);

Performance Issues

Improper use of hooks can lead to performance issues. Always be mindful of how often effects are triggered and optimize accordingly using memoization and proper dependency management.

Conclusion

React hooks like useState and useEffect have transformed how we write functional components by making state management and side effects more intuitive and less verbose. Custom hooks further enhance code reuse and maintainability by encapsulating common logic. As you continue your journey with React, mastering these hooks will significantly boost your productivity and the quality of your applications.

For further reading and more advanced use cases, check out the official React documentation on hooks and explore other hooks such as useContext, useReducer, and useMemo.

Feedback