Learn React Hooks and Optimization Techniques

Author

Elle J

Date

Sep. 24, 2019

Time to Read

33 min

Prerequisites

  • React (basic knowledge)

React

hooks

frontend

The React team introduced so-called hooks in React 16.8 and have in my opinion increased the developer experience tremendously, allowing us to simplify the way in which we write and structure our code. This article will introduce and explain:

  • The general idea of hooks and why you should learn them
  • The State Hook (useState)
  • The Effect Hook (useEffect)
  • How to implement custom hooks
  • How and when to use context
  • The Context Hook (useContext)
  • The Reducer Hook (useReducer)
  • Why it has been called the “Redux Killer”
  • How to optimize performance in multiple ways

Hooks 101

When constructing our React components we either make them stateful or stateless. In the pre-hooks era, whenever a component needed a state we had to make it class-based in order for the this keyword to refer to the correct instance. However, with the rise of hooks there is no longer a need for classes at all. Hooks allow us to use all of the features of a class-based component, such as state, from within functional components. Not only does this clean up our code as you will see shortly, but it makes it possible to reuse stateful logic in different components. Let’s say you need to toggle a piece of state, usually meaning you need to implement a new function that negates the state, e.g. this.setState({ myState: !this.state.myState }). Using hooks we can use one function across all components that need it, and when called it will toggle whatever state we pass as the argument.

Hooks also facilitate the workaround of prop drilling (having to pass down props several levels deep) by introducing simple ways of working with context. Additionally, if you are familiar with Redux (a popular state container library) you will see many similarities with some hooks as they implement similar functionality using only the React library. So in short, as of v16.8.0 it is now possible for functional components to “hook into” React features, as the React team puts it.

Let’s look at some examples, but first, do note that:

  • There are no breaking changes with hooks.
  • Classes are not going anywhere in the near future.
  • The React concepts remain unchanged.
  • Hooks cannot be used within class-based components.

The State Hook: useState

In a class-based component we use this.state and this.setState to store and change the state. The State Hook useState provides the functional equivalents to the aforementioned. If we were to add a piece of state called score and initiate it to 0, this is how we would go about using both the traditional approach and hooks:

Example using classes:

import React, { Component } from 'react';

class ClassExample extends Component {
  constructor(props) {
    super(props);
    this.state = { score: 0 };
  }
  // or using the React shortcut:
  // state = { score: 0 };

  // ...

}

Example using hooks:

import React, { useState } from 'react';

function HooksExample(props) {
  const [ score, setScore ] = useState(0);

  // ...

}

When calling useState, the one argument we pass is what we want the initial value of our piece of state to be. Note that it does not have to be an object, it can be whatever we’d like it to be. useState returns an array with two elements:

  1. A variable referencing the current state--in our case score which we can call anything.
  2. A function to update that piece of state. By convention we start the name with “set” and end with whatever we named our state, so in our case setScore, but we can call this anything as well.

Since useState returns an array, we can use array destructuring to easily access the data returned.

const array = useState(0);
const score = array[0];     // class equivalent = this.state.score
const setScore = array[1];  // class equivalent = this.setState

// access the items in the array using destructuring instead
const [ score, setScore ] = useState(0);

The examples below increment the score by 1 when the button is clicked.

Example using classes:

import React, { Component } from 'react';

class ScoreKeeper extends Component {
  constructor(props) {
    super(props);
    this.state = { score: 0 };
  }

  render() { 
    return (
      <div>
        <p>Score: {this.state.score}</p>
        <button onClick={() => this.setState({
          score: this.state.score + 1
        })}>
          Increment Score
        </button>
      </div>
    );
  }
}

export default ScoreKeeper;

Example using hooks:

import React, { useState } from 'react';

function ScoreKeeper(props) {
  const [ score, setScore ] = useState(0);

  return (
    <div>
      <p>Score: {score}</p>
      <button onClick={() => setScore(score + 1)}>
        Increment Score
      </button>
    </div>
  );
}

export default ScoreKeeper;

The setScore function takes as an argument the new value that we want our state to be.

Prior to hooks we had to convert stateless functional components into class-based components if they needed some state. As you can see, this is no longer required if using hooks. This is also why React now prefers just saying “functional components” rather than “stateless components”. In the examples above we have called useState once, initializing one piece of state, but we can call useState for as many pieces of state as we wish and use array destructuring as well for those.

The Effect Hook: useEffect

In order to execute side effects, for instance running some logic only after the component did mount, there are several lifecycle methods available when working with classes in React. useEffect is a function available in functional components that is used for managing these side effects.

React recommends thinking of this hook as a combination of the class lifecycle methods componentDidMount, componentDidUpdate, and componentWillUnmount. Thus, useEffect will run after the first render and after every update.

import React, { useState, useEffect } from 'react';

function ScoreKeeper(props) {
  const [ score, setScore ] = useState(0);

  // useEffect must be used within the body of the component
  // and be passed a callback as the argument
  useEffect(() => {
    // side effects will be executed here
    console.log(`score: ${score}`);
  });

  return (
    <div>
      <p>Score: {score}</p>
      <button onClick={() => setScore(score + 1)}>
        Increment Score
      </button>
    </div>
  );
}

export default ScoreKeeper;

In class components, if we want something to run only after a specific piece of state was updated we can do that by passing a second argument to this.setState(). When using hooks, we instead pass an array as a second argument to the Effect Hook containing that piece (or multiple pieces) of state. This array is called a dependency array as it lists all the state variables that the hook depends on which prevents the hook from running if those dependencies did not change. To run the hook only when the component mounts, simply pass an empty array as the second argument.

useEffect(() => {
  console.log('score was updated');

  // tell useEffect to run only if "score" changes
}, [score]);

The componentWillUnmount lifecycle method in classes can be simulated with useEffect by returning a function. This can then be used to perform any clean-up that needs to be done before the component unmounts.

useEffect(() => {
  
  /* perform some logic */

  // return a function (acts as a clean-up)
  return () => console.log('Cleaning up...');

}, [/* dependencies */]);

The example below illustrates how we can fetch data asynchronously and how useEffect only gets called when the piece of state number changes by showing a menu with three options to the user. Whenever the user selects an option a different message will be fetched and displayed. When using async functions they need to reside inside the callback (the first argument to useEffect) and be immediately invoked. (The axios library will be used in this case to demonstrate the AJAX call. The URL used here is fictitious.)

import React, { useState, useEffect } from 'react';
import axios from 'axios';

function MessageDisplay(props) {
  const [ number, setNumber ] = useState(1);
  const [ message, setMessage ] = useState('');

  useEffect(() => {
    // define the async function within this callback
    async function fetchMessage() {
      const res = await axios.get(`fake.io/api/msgs/${number}`);
      setMessage(res.data.message);
    }
    // invoke the async function immediately
    fetchMessage();

    // tell useEffect to run only if "number" changes
  }, [number]);

  // allow the user to trigger a rerender by changing "number"
  return (
    <div>
      <p>Choose Message 1, 2, or 3</p>
      <select
        value={number}
        onChange={e => setNumber(e.target.value)}
      >
        <option value="1">1</option>
        <option value="2">2</option>
        <option value="3">3</option>
      </select>
      <p>Message: {message}</p>
    </div>
  );
}

export default MessageDisplay;

Pay close attention to the fact that we are calling a function to change our state (setMessage) from within useEffect. Since changes to the state cause a rerender and since useEffect usually runs after each rerender, make sure to pass a second argument ([number] in our case) so as to avoid getting stuck in an infinite loop of rerenders!

Also, the reason why we need to define the async function within the callback and not make the callback itself async is because if we return something from it, useEffect expects it to return a function that can be used right away as a clean-up, not a promise! Thus, declaring the callback as async would return a promise.

Lastly, you can have multiple useEffect hooks set up to run only when different pieces of state change.

function MultipleUseEffectsExample(props) {

  // runs on mount and on each update
  useEffect(() => {
    /* side effects */
  });

  // runs only on mount
  useEffect(() => {
    /* side effects */
  }, []);

  // runs only on mount and when "state1" changes
  useEffect(() => {
    /* side effects */
  }, [state1]);

  // runs only on mount and when "state2" changes
  useEffect(() => {
    /* side effects */
  }, [state2]);

  // ...

}

Implementing a Custom Hook and Reusing Stateful Logic

As previously hinted, we will be building our own custom hook to toggle a piece of state in order to be able to reuse it across multiple components. Often we might need some boolean flag in our state that we toggle back and forth. To keep files neat and organized, whenever we implement a custom hook it can be a good idea to store them in its own directory called something appropriate like “hooks” (especially if you are working with a larger application). The naming convention of hooks is use<WhatItPertainsTo>, thus we will call our toggling hook useToggle. Although, it too can be called anything. The purpose of this function will be to return the current state and a function to update that state.

// custom hook: /hooks/useToggle.js

import { useState } from 'react';

function useToggle(initialVal = false) {
  const [ state, setState ] = useState(initialVal);

  const toggle = () => setState(!state);

  return [ state, toggle ];
}

export default useToggle;

We can use an ES6 default parameter to set the initial value to false if no argument is passed to this function. We then call React’s useState hook, pass in that value, and destructure the state and the function to update that state. Our toggle function will, when called, in turn call setState with the negated value, thereby toggling whichever state was passed to useToggle. All that remains is to return the state and the function toggle that will be called from within other components.

Now our custom hook is ready to be imported by the components that need to be able to toggle something in their state. (Most examples used herein will be simplified in order to focus mainly on the concept at hand.)

import React from 'react';
import useToggle from '../hooks/useToggle';

function UsingCustomHookExample(props) {
  // whatever we call the first element in the returned
  // array will be the state, the second element will be
  // the "toggle" function implemented in our custom hook
  const [ editing, toggleEditing ] = useToggle(false);

  return (
    <div>
      <p>
        Editing: {editing ? 'ON' : 'OFF'}
      </p>
      <button onClick={toggleEditing}>
        Toggle Editing
      </button>
    </div>
  );
}

export default UsingCustomHookExample;

Moreover, you can build custom hooks for all stateful logic that is shared between components. For instance, if you have multiple forms in your application that each need some state keeping track of the user input you can use your own hook and clean up the code. Even if some logic is not shared between components, you can still significantly reduce the code in a component by moving related functionality to a separate hook as shown in a different example below.

// custom hook: /hooks/useSongs.js

import { useState } from 'react';

function useSongs(defaultSongs = []) {
  const [ songs, setSongs ] = useState(defaultSongs);
  
  const addSong = (/* arg */) => {
    /* perform some logic */
    setSongs(/* set new state */);
  };
  
  const editSong = (/* arg */) => {
    /* perform some logic */
    setSongs(/* set new state */);
  };
  
  const removeSong = (/* arg */) => {
    /* perform some logic */
    setSongs(/* set new state */);
  };

  return {
    songs,
    addSong,
    editSong,
    removeSong
  };
}

export default useSongs;

Use the hook in the component that would otherwise store that logic.

// now a cleaner component by calling the useSongs hook

import React from 'react';
import useSongs from '../hooks/useSongs';

function SongApp(props) {
  const defaultSongs = [/*...*/];

  const {
    songs,
    addSong,
    editSong,
    removeSong
  } = useSongs(defaultSongs);

  // ...
}

export default SongApp;

Having looked at several examples using hooks and hopefully understanding a lot more, I want to remind you again that hooks cannot be used in class components, and you...

  • Cannot call hooks inside loops.
  • Cannot call hooks inside conditions.
  • Cannot call hooks inside nested functions.

Understanding Context

So far we have learned about the usefulness of useState and how it can help us increase efficiency when writing code. Although, after declaring our state at the top level, deeply nested children that need one of these pieces of state may still only access it after being passed down as props through all components in between. Context eliminates these rather tedious steps and allows for a stateful context to be created and then consumed by the children we choose to wrap it in. Before continuing, do note that:

  • Context is most appropriately used when there are several components in need of the same piece of data
  • If there is only a single child component that needs access to data higher up, React recommends using component composition instead (this article will not be introducing that concept, but you can read about in their docs)
  • The reusability of components become more difficult so context ought to be used moderately

We’ll begin by studying the traditional class-based examples using context and eventually refactor the code using hooks.

Components that consume a certain context object need to subscribe to any changes within it, but first the object has to be created. The React.createContext API can be called with some default value (or no value) which in turn returns our newly created context.

import { createContext } from 'react';

// naming convention for contexts use Pascal case
// (capitalize every word, including the first)
const OurContext = createContext();

// (optional but good)
// give the context a display name for use in dev tools
OurContext.displayName = 'OurContext';

OurContext is now created, but before we can pass down data to eventually enable children to subscribe to changes within this context we need to provide it for them. This is done through a React component called Provider which all context objects come with. All the children that we wrap in this provider will be able to read the value that it stores no matter how deeply nested the children are.

<OurContext.Provider value={/* some data to be shared */}>
  {/* children */}
</OurContext.Provider>

Since context is mainly used for global data, let’s say our application uses data about what theme (light or dark) the user has opted for. Most likely, this piece of state will need to be accessible in several components. We could add our Provider directly in the component that manages that state, and then pass in this.state.theme as the value prop in the Provider. However, we could also create a separate theme class and let the state be managed there instead. The render method in this class would then have to return the Provider wrapped around this.props.children (see below).

We will create a new directory called “contexts” and add our themeContext.js here. (Both the file and folder can be called anything.)

// /contexts/themeContext.js

import React, { Component, createContext } from 'react';

export const ThemeContext = createContext();
ThemeContext.displayName = 'ThemeContext';

// we could also pass in a default value:
// export const ThemeContext = createContext({
//   theme: 'light',
//   toggleTheme: () => {}
// });

// we will import this ThemeProvider in App.js or another
// top level component and wrap it around the children
export class ThemeProvider extends Component {
  constructor(props) {
    super(props);
    this.state = { theme: 'light' };
  }

  // let's also define a function that we can
  // pass down to toggle the theme
  toggleTheme = () => {
    this.setState({ theme: this.state.theme === 'light' ? 'dark' : 'light' });
  };

  render() {
    // every context object comes with a Provider
    return (
      <ThemeContext.Provider
        value={{
          ...this.state,
          toggleTheme: this.toggleTheme
        }}
      >
        {this.props.children}
      </ThemeContext.Provider>
    );
  }
}

Important to note is that the Provider’s value prop can only take 1 item, so we could either pass an object, a string, a number, or anything else, but we cannot spread everything that is in the state (if we had multiple pieces of state) directly into the value prop; instead, we will have to spread it into a new object. Also, the default argument to React.createContext (if provided) is only used in the absence of a matching Provider higher up in the tree.

// /components/App.js

import React from 'react';
import { ThemeProvider } from '../contexts/themeContext';
import Child1 from './Child1';

function App() {
  // the ThemeProvider component returns the context's Provider
  return (
    <ThemeProvider>
      <Child1 />
    </ThemeProvider>
  );
}

// Imagine this tree structure starting at Child1:

<Child1 />
// ... which in turn renders ...
<Child2 />
// ... which in turn renders ...
<Child3 />

Remember, our ThemeProvider returns the Provider React component containing the value prop with our theme state currently set to “light”. Components that subscribe to this context will read the value of the nearest Provider in the tree when rendered.

Our setup is ready for the child components to consume our ThemeContext.

Consuming Context

If our theme state would have been passed down as props, children would access it using this.props.theme. When consuming a value in a context, children can access it using this.context in all lifecycle methods including render. Prior to that, however, we need to assign our ThemeContext object to the contextType property that exists on classes. That is how we set a static property, i.e. a property that belongs to the class and not the object.

There are two possible syntax options to choose from:

  1. Assign it outside of the class (non-experimental).
  2. Assign it using a static class field inside of the class (experimental).
// syntax option 1
class Example1 extends React.Component {
  // ...
}
Example1.contextType = OurContext;    // outside

// syntax option 2 (experimental)
class Example2 extends React.Component {
  static contextType = OurContext;    // inside

  // ...
}

Using the experimental option #2, in the example below our child component Child3 at the bottom of the tree can consume the context we created in themeContext.js since Child3 is (indirectly) wrapped in the Provider in App.js. Thus, we can go ahead and import the context in this child component. When rendered, the component will have a different background color depending on the value of theme in the context.

// /components/Child3.js

import React, { Component } from 'react';
import { ThemeContext } from '../contexts/themeContext';

class Child3 extends Component {
  // make it possible to consume the context object
  static contextType = ThemeContext;

  render() {
    // consume the context object
    const { theme, toggleTheme } = this.context;

    const styles = {
      light: { backgroundColor: 'white', color: 'black' },
      dark: { backgroundColor: 'black', color: 'white' }
    };

    return (
      <div styles={styles[theme]}>
        <p>Want to toggle the theme?</p>
        <button onClick={toggleTheme}>
          Click Me
        </button>
      </div>
    );
  }
}

export default Child3;

To clarify, the code static contextType = ThemeContext makes the component search upward for the nearest ThemeContext.Provider. In this example, we use object destructuring to grab the value in this.context only because we had put these values in an object on our value prop in ThemeContext.Provider (see themeContext.js).

If a component has to consume multiple contexts we need to do slightly more work; however, I will leave that up to you to check out in the React docs if you wish as we will instead explore how to more easily achieve this using hooks.

Be Aware of This Gotcha...

There is a gotcha to be aware of when setting a value for the Provider. Take a look at the example below.

<OurContext.Provider value={{ key: 'some value' }}>
  {/* children */}
</OurContext.Provider>

When the provider rerenders, consumers of OurContext will also rerender if the value has changed. The value in this case has been set to an object, and since reference identity is used to determine whether or not it is a new value all consumers will always rerender due to that a new object is created for the value prop each time. So even if we do not actually change whatever is inside of the object, the object itself is new and, hence, is a new reference.

One way to solve this performance issue is to have the component store the object in its state and then reference this.state.someObject.

<OurContext.Provider value={this.state.someObject}>
  {/* children */}
</OurContext.Provider>

But again, if we need to pass down multiple pieces of data that we cannot structure as the example above, it will become an easier task with our next hook to be introduced.

The Context Hook: useContext

Time to reenter the exciting world of hooks. In the examples of working with a global theme state we have seen how to avoid passing down props manually at each level of the tree by creating a context, wrapping the children in its provider, then consuming it in class-based components. Our App component will remain unchanged since it was already a stateless functional component, but we will do some refactoring in our themeContext.js and Child3 to instead use hooks. Our Provider (ThemeContext.Provider) was the return value of the render method of the class we chose to call ThemeProvider which was storing a piece of state ({ theme: 'light' }). ThemeProvider will now (see below) be a stateful functional component by importing and using the hook useState that was introduced earlier.

// refactored /contexts/themeContext.js (using hooks)

import React, { useState, createContext } from 'react';

export const ThemeContext = createContext();
ThemeContext.displayName = 'ThemeContext';

export function ThemeProvider(props) {
  const [ theme, setTheme ] = useState('light');

  const toggleTheme = () => setTheme(theme === 'light' ? 'dark' : 'light');

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {props.children}
    </ThemeContext.Provider>
  );
}

The way we can consume a context in a functional component using hooks is with useContext. The argument that we pass to useContext should be the context object itself, in our case ThemeContext. So our Child3 component furthest down in the tree will also be a functional component.

// refactored /components/Child3.js (using hooks)

import React, { useContext } from 'react';
import { ThemeContext } from '../contexts/themeContext';

function Child3(props) {
  // consume the context object
  const { theme, toggleTheme } = useContext(ThemeContext);

  const styles = {
    light: { backgroundColor: 'white', color: 'black' },
    dark: { backgroundColor: 'black', color: 'white' }
  };

  return (
    <div styles={styles[theme]}>
      <p>Want to toggle the theme?</p>
      <button onClick={toggleTheme}>
        Click Me
      </button>
    </div>
  );
}

export default Child3;

Issues with What We Know So Far

Let’s head back to our SongApp introduced a while back in which we used our own custom hook called useSongs. This hook used useState to set a piece of state called songs and contained a number of functions (e.g. addSong and removeSong) which were returned in an object. Imagine we have created a new context for these songs along with their helper functions called SongsContext. As we have seen, we can pass down these functions in the value prop of the SongsContext.Provider in order for its children to be able to access it with useContext.

The example below shows a child consuming some functions from this context.

// /components/SongChild1.js

import React, { useContext } from 'react';
import { SongsContext } from '../contexts/songsContext';

function SongChild1(props) {
  const { addSong, removeSong } = useContext(SongsContext);

  return (
    <div>
      <button onClick={() => addSong(/* some arg */)}>
        Click to add a song
      </button>
      <button onClick={() => removeSong(/* some arg */)}>
        Click to remove a song
      </button>
    </div>
  );
}

export default SongChild1;

After the example below, which shows a child consuming the songs state from the SongsContext, we will examine the current issues we are facing.

// /components/SongChild2.js

import React, { useContext } from 'react';
import { SongsContext } from '../contexts/songsContext';

function SongChild2(props) {
  const { songs } = useContext(SongsContext);

  return (
    <div>
      {songs.map(song => (
        <div key={song.id}>
          <p>{song.name}</p>
        </div>
      ))}
    </div>
  );
}

export default SongChild2;

Firstly, whenever the context changes, by for instance clicking on one of the buttons in SongChild1, all components that consume that context will rerender even if they are only using data that was not changed. Since SongChild1 is merely destructuring the addSong and removeSong functions from the context, it is unnecessary to rerender that component whenever the songs state changes. SongChild2 uses the songs from the context, thus it is appropriate (and necessary) in this case to rerender this component as songs changed.

One solution is to instead use two separate contexts, one for the songs (the state that is changing), and one for the functions. Consequently, SongChild1 could then consume a different context and only rerender if any data was to change in that context. Momentarily we’ll implement the code for this solution.

Secondly, SongChild1 currently has to destructure all of the functions it needs access to. Let's say we were to add another function, we would first need to define it in our useSongs hook, return it, destructure it, then use it. This may not seem like an issue at first glance, but a more convenient way would be to just tell the application which of the functions to run using only one function.

One solution is to use what has been referred to as the “Redux Killer”, namely the Reducer Hook useReducer. One function (a reducer) can replace all of the previous functions and still perform the same tasks depending on the argument we pass it. Let’s jump right to it.

The Reducer Hook: useReducer

State management using only the React library has become a lot easier thanks to hooks. It has many similarities to the very popular library Redux (used as a data store for various UI layers), creating a lot of excitement within the React community.

The useReducer hook takes a reducer as a first argument (a function that we will define shortly) and an initial argument (the initial state) as the second argument. It then returns an array of the current state and a dispatch method that can be called for triggering different executions within the reducer.

const [ state, dispatch ] = useReducer(reducer, initialArg);

(There are two other less common ways to initialize the state which you can read about in the React docs.)

Having seen many examples up until now using the State Hook useState, can you see the resemblance? The State Hook also takes some initial argument and returns a list of the current state and a method to change it. From this point on we will be replacing useState with useReducer as the latter is usually preferred when dealing with complex state logic or when changes to the state depend on the previous state.

The reducer itself takes the form of:

const reducer = (state, action) => newState

Thus, given some action and state, the reducer returns a new state. The overall idea of the Reducer Hook is to:

  1. Call useReducer to get a state and a dispatch method returned.
  2. When needing to change the state (traditionally used with this.setState()) call the dispatch method with an object containing information on how to change the state.
    • We refer to this object as an action.
    • This action object should have a key conveniently called type to indicate to the reducer what type of action to take.
    • An example of an action object: { type: 'SET_USER' }
  3. The reducer receives the action and based on action.type it executes some stateful logic.
  4. The reducer returns the new state.
  5. This new state will then be the first list item that useReducer returns.

Since we might be dispatching actions from various places in our application, using a hardcoded string as the type could potentially cause a lot of headache to manually change if we had to, and more importantly, it would increase the likelihood of introducing bugs. So we’ll go ahead and store these actions in a separate module in a directory called “actions”.

// /actions/types.js

export const ADD_SONG = 'ADD_SONG';
export const EDIT_SONG = 'EDIT_SONG';
export const REMOVE_SONG = 'REMOVE_SONG';

Below you’ll find the implementation of our first reducer that will be used for altering our songs state. (This module will be put in its own directory called “reducers”.)

// /reducers/songsReducer.js

import { ADD_SONG, EDIT_SONG, REMOVE_SONG } from '../actions/types';

const reducer = (state, action) => {
  // a switch statement is most commonly used in reducers
  switch (action.type) {
    case ADD_SONG:
      return [
        ...state,
        { id: action.id, name: action.name }
      ];
    case EDIT_SONG:
      return state.map(song => (
        song.id === action.id
          ? { ...song, name: action.name }
          : song
      ));
    case REMOVE_SONG:
      return state.filter(song => song.id !== action.id);
    default:
      throw new Error(`Unhandled type: ${action.type}`);
  }
}

export default reducer;

Whenever we dispatch an action we must be aware of what properties the reducer is expecting the action object to have. For instance, if action.type === ADD_SONG there ought to be a name and id property included.

In our module where we create the SongsContext we can set up our state using useReducer instead of both useState and the custom useSongs hook that we built.

// /contexts/songsContext.js

import React, { createContext, useReducer } from 'react';
import songsReducer from '../reducers/songsReducer';

const defaultSongs = [/*...*/];

export const SongsContext = createContext();

export function SongsProvider(props) {
  const [ songs, dispatch ] = useReducer(songsReducer, defaultSongs);

  return (
    <SongsContext.Provider value={{ songs, dispatch }}>
      {props.children}
    </SongsContext.Provider>
  );
}

Consumers of this context can then destructure the dispatch method from the value prop. Although, we did discuss the optimization problem of having a new object as the value prop, so it would be preferable to use only the reference itself, i.e. songs or dispatch, as the value. I mentioned earlier that hooks allow for an easier way of using and consuming multiple contexts. This will be examined now as it solves the current performance issue.

To create another context, you simply call createContext one more time and then wrap the children in that context’s Provider as well.

// /contexts/songsContext.js (using multiple contexts)

import React, { createContext, useReducer } from 'react';
import songsReducer from '../reducers/songsReducer';

const defaultSongs = [/*...*/];

export const SongsContext = createContext();
export const DispatchContext = createContext();

export function SongsProvider(props) {
  const [ songs, dispatch ] = useReducer(songsReducer, defaultSongs);

  return (
    <SongsContext.Provider value={songs}>
      <DispatchContext.Provider value={dispatch}>
        {props.children}
      </DispatchContext.Provider>
    </SongsContext.Provider>
  );
}

From now on components that solely use songs can consume the SongsContext whereas components that only need to dispatch actions can consume the DispatchContext, causing them to rerender more appropriately.

Our top level component looks like so:

// /components/SongApp.js

import React from 'react';
import { SongsProvider } from '../contexts/songsContext';

function SongApp(props) {
  // the SongsProvider component returns the providers
  return (
    <SongsProvider>
      {/* children */}
    </SongsProvider>
  );
}

export default SongApp;

Revisiting the consumers of these contexts, SongChild1 and SongChild2, that were previously destructuring either the methods they needed or just the state that was changing (songs), let’s refactor them using only the relevant context and dispatch actions instead of calling individual functions.

// refactored /components/SongChild1.js

import React, { useContext } from 'react';
import { DispatchContext } from '../contexts/songsContext';
import { ADD_SONG, REMOVE_SONG } from '../actions/types';

function SongChild1(props) {
  // consume only the DispatchContext since we
  // are not using "songs" in this component
  const dispatch = useContext(DispatchContext);

  return (
    <div>
      <button onClick={() => dispatch({
        type: ADD_SONG, name: 'Purple Rain'
      })}>
        Click to add a song
      </button>
      <button onClick={() => dispatch({
        type: REMOVE_SONG, id: '12345'
      })}>
        Click to remove a song
      </button>
    </div>
  );
}

export default SongChild1;

Notice how the dispatch methods are called with a type property and whatever other properties that the reducer expects.

SongChild2 does not need as much refactoring.

// refactored /components/SongChild2.js

import React, { useContext } from 'react';
import { SongsContext } from '../contexts/songsContext';

function SongChild2(props) {
  // no need to use destructuring anymore
  const songs = useContext(SongsContext);

  // the rest remains the same
  return (
    <div>
      {songs.map(song => (
        <div key={song.id}>
          <p>{song.name}</p>
        </div>
      ))}
    </div>
  );
}

export default SongChild2;

Optimize Using Memoization

We have stumbled upon (and improved) some performance issues throughout the examples, nonetheless there is still another optimization technique that we haven’t used yet that is definitely worth mentioning. First, have a look at the following example where a TasksList component is rendering individual Task components and passing down some props to each one.

// /components/TaskList.js

import React, { useContext } from 'react';
import { TasksContext } from '../contexts/tasksContext';
import Task from './Task';

function TasksList(props) {
  const tasks = useContext(TasksContext);

  return (
    <div>
      {tasks.map(task => (
        <Task
          key={task.id}
          id={task.id}
          status={task.status}
          text={task.text}
        />
      ))}
    </div>
  );
}

export default TasksList;

The Task component (see below) renders something based on the props received and is also consuming a DispatchContext in order to be able to update some state.

// /components/Task.js

import React, { useContext } from 'react';
import { DispatchContext } from '../contexts/tasksContext';

function Task({ id, status, text }) {
  const dispatch = useContext(DispatchContext);

  return (
    <div>
      <p>Task: {text}</p>
      <p>Status: {status}</p>
      <button onClick={() => dispatch({
        type: 'UPDATE', id, status: 'completed'
      })}>
        Mark as "completed"
      </button>
    </div>
  );
}

export default Task;

If an action is dispatched that changes the state, such as updating the status of one of the Task components rendered by TasksList, all of the Task components will be rerendered since new props are sent down, even though the values of the props did not change for all but one of the Task components.

For class-based components we can extend React.PureComponent instead of React.Component to fix this issue.

// memoization in class-based components

class Task extends React.PureComponent {
  // ...
}

When using functional components, however, we can call a higher order component (HOC) and pass our component as the argument. HOCs are functions that take a component and return a new one. This HOC is called memo and will memoize the result, hence React will reuse the previously rendered result if it is the same given the same props, instead of executing the render function again.

// memoization in functional components

const Task = React.memo(function Task(props) {
  // ...
});

A few things to note:

  • Passing only one argument to React.memo leads to a shallow comparison of complex objects coming from props. A custom function can also be passed as a second argument to perform some deeper comparison (see React docs).
  • Don’t let any of your code depend on this memoization. Use this technique solely as a performance boost.

To illustrate this last piece of integration more clearly using our tasks example, we can wrap our Task component in React’s memo HOC.

import React, { useContext, memo } from 'react';
import { DispatchContext } from '../contexts/tasksContext';

function Task({ id, status, text }) {
  /* same content as before */
}

export default memo(Task);

Bullet Point Recap

We have seen how to get started with hooks and why they are worth your time. Let’s recap some valuable lessons. We have learned...

  • That hooks allow us to access features of class components from functional components.
  • How to reuse stateful logic and build our own hooks which cleans up the code.
  • How to circumvent the steps of manually passing down props to deeply nested components by creating a context object, providing it, then having children consume it.
  • How to create and consume multiple contexts.
  • How useReducer helps us manage state by returning only one method (dispatch) used for triggering some stateful logic in the reducer.

There were also three main performance issues caused by unnecessary rerenders that we tackled:

  • Problem: The Provider React component that all context objects come with has a value prop as we’ve seen. Setting the value to a new object will cause all consumers to rerender every time because a new object is always created. So it does not matter if whatever is inside the object has not actually changed.
    • Solution: Store the object in the component’s state and set the value prop to be the reference itself.
  • Problem: When something in the context actually changes, components that only use a piece of the state that has not changed will still rerender.
    • Solution: Create a new context with its own Provider and separate the data that is changing from the data that is not changing into separate contexts. Each Provider can then have its own value prop. Components may then consume only the appropriate context.
  • Problem: If a parent is passing down some state through props to multiple children, and one child dispatches some action that changes the state for that particular child, all of those children will rerender even if the rerender produces the same result.
    • Solution: For traditional class-based components the child can extend React.PureComponent, but for functional components we can memoize the result using the React.memo HOC and wrap the child component in it.

I am excited for you to get started experimenting with hooks and familiarizing yourself with what the React team seems to go all in on!

Keep Coding and Stay Curious! :)

Comments powered by Talkyard.

Comments powered byTalkyard.