
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:
jsx1import React, { Component } from 'react';23class Counter extends Component {4 constructor(props) {5 super(props);6 this.state = { count: 0 };7 }89 increment = () => {10 this.setState((prevState) => ({ count: prevState.count + 1 }));11 };1213 render() {14 return (15 <div>16 <p>Count: {this.state.count}</p>17 <button onClick={this.increment}>Increment</button>18 </div>19 );20 }21}2223export 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:
jsx1import React, { Component } from 'react';23class Timer extends Component {4 constructor(props) {5 super(props);6 // Initialize the state with count set to 07 this.state = { count: 0 };8 }910 componentDidMount() {11 // Set up a timer that increments the count state every second12 this.timer = setInterval(() => {13 this.setState((prevState) => ({ count: prevState.count + 1 }));14 }, 1000);15 }1617 componentDidUpdate(prevProps, prevState) {18 // Check if the count has changed and log the new count19 if (this.state.count !== prevState.count) {20 console.log('Count changed:', this.state.count);21 }22 }2324 componentWillUnmount() {25 // Clear the timer when the component unmounts to prevent memory leaks26 clearInterval(this.timer);27 }2829 render() {30 return (31 <div>32 <p>Count: {this.state.count}</p>33 </div>34 );35 }36}3738export 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
- Unnecessary Updates: The
componentDidUpdatemethod checks ifthis.state.counthas 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. - Component-Based Timer Logic: Using
setIntervalincomponentDidMountand clearing it incomponentWillUnmountcan 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.
jsx1const Greeting = (props) => {2 return <h1>Hello, {props.name}!</h1>;3};45export 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:
jsx1import React, { useState } from 'react';23const Counter = () => {4 // Declare a state variable 'count' and a function 'setCount' to update it5 // Initialize 'count' to 0 using the useState hook6 const [count, setCount] = useState(0);78 // Define a function 'increment' that increments the 'count' state by 19 const increment = () => setCount(count + 1);1011 // Return the JSX to render12 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};2122export 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:
jsx1import React, { useState } from 'react';23// Define a functional component named UserProfile4const UserProfile = () => {5 // Declare a state variable 'user' and a function 'setUser' to update it6 // Initialize 'user' with an object containing 'name' and 'age' properties7 const [user, setUser] = useState({ name: '', age: 0 });89 // Define a function 'updateName' that updates the 'name' property of 'user'10 const updateName = (name) => setUser({ ...user, name });1112 // Define a function 'updateAge' that updates the 'age' property of 'user'13 const updateAge = (age) => setUser({ ...user, age });1415 // Return the JSX to render16 return (17 <div>18 {/* Input field for user's name */}19 <input20 type="text"21 value={user.name}22 onChange={(e) => updateName(e.target.value)}23 placeholder="Name"24 />2526 {/* Input field for user's age */}27 <input28 type="number"29 value={user.age}30 onChange={(e) => updateAge(parseInt(e.target.value, 10))}31 placeholder="Age"32 />3334 {/* Display the user's name and age */}35 <p>36 Name: {user.name}, Age: {user.age}37 </p>38 </div>39 );40};4142// Export the UserProfile component as the default export43export 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:
jsx1import React, { useState, useEffect } from 'react';23const Timer = () => {4 // Declare a state variable 'count' and a function 'setCount' to update it5 // Initialize 'count' to 06 const [count, setCount] = useState(0);78 // useEffect hook to handle side effects9 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 value13 setCount((prevCount) => prevCount + 1);14 }, 1000);1516 // Return a cleanup function to clear the interval when the component unmounts17 return () => clearInterval(timer);18 }, []); // The empty dependency array ensures this effect runs only once after the initial render1920 // Return the JSX to render21 return <div>Count: {count}</div>;22};2324export 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:
jsx1useEffect(() => {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:
jsx1import { useState, useEffect } from 'react';23const useLocalStorage = (key, initialValue) => {4 // State value stored in localStorage5 const [value, setValue] = useState(() => {6 // Retrieve stored value from localStorage based on key7 const storedValue = localStorage.getItem(key);8 // Parse JSON stored value or use initialValue if no stored value9 return storedValue ? JSON.parse(storedValue) : initialValue;10 });1112 // useEffect hook to update localStorage whenever value or key changes13 useEffect(() => {14 // Stringify value and store it in localStorage under specified key15 localStorage.setItem(key, JSON.stringify(value));16 // useEffect dependencies include key and value to trigger update17 }, [key, value]);1819 // Return value state and setValue function to update it20 return [value, setValue];21};2223export default useLocalStorage;
Using useLocalStorage
Here's how you can use the useLocalStorage hook:
jsx1import React from 'react';2import useLocalStorage from './useLocalStorage';34const Settings = () => {5 const [username, setUsername] = useLocalStorage('username', '');67 return (8 <div>9 <input10 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};1920export 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.
jsx1import React, { useState, useMemo, useCallback } from 'react';23function ExpensiveComponent({ data }) {4 const [count, setCount] = useState(0);56 const expensiveCalculation = useMemo(() => {7 return data.reduce((acc, value) => acc + value, 0);8 }, [data]);910 const handleClick = useCallback(() => {11 setCount(count + 1);12 }, [count]);1314 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.
jsx1useEffect(() => {2 // Your effect logic3}, [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.
jsx1useEffect(() => {2 const interval = setInterval(() => {3 setSeconds(s => s + 1);4 }, 1000);56 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:
jsx1import React, { useState } from 'react';23export function App(props) {4 const [balance, setBalance] = useState(0);56 const addFund = (amount) => {7 // This simulates an async8 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 };1415 const removeFund = (amount) => {16 // This simulates an async17 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 };2728 const viewBalance = () => console.log(`Current balance $${balance}`);2930 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.
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:
jsx1import React, { useState } from 'react';23export function App(props) {4 const [balance, setBalance] = useState(0);56 const addFund = (amount) => {7 // This simulates an async8 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 };1617 const removeFund = (amount) => {18 // This simulates an async19 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 };3233 const viewBalance = () => console.log(`Current balance $${balance}`);3435 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.
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:
javascriptsetCount(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.