Building A Component Library With React And Emotion

According to Clearleft, a component library is:

“A collection of components, organised in a meaningful manner, and often (but not necessarily) providing some way to browse and preview those components and their associated assets.”

— “On Building Component Libraries,” Clearleft

We’ll learn how to build a component library by making one that comprises four components:

  1. Button
    A wrapper around the default HTML button
  2. Box
    A container (HTML div) with custom properties
  3. Columns
    A container whose children are spaced evenly across the x-axis
  4. Stack
    A container whose children are spaced evenly across the y-axis

These components could then be used in whatever application we are working on. We’ll build the component library using React and Emotion.

At the end of this piece, you should be able to create a component library that fits whatever use case you have in mind. This knowledge will come handy when you’re working with a team that needs to make use of reusable components.

First, let’s get started by establishing what the Emotion library is. The documentation explains:

“Emotion is a library designed for writing CSS styles with JavaScript. It provides powerful and predictable style composition in addition to a great developer experience with features such as source maps, labels, and testing utilities.”

— “Introduction,” Emotion Docs

In essence, Emotion is a CSS-in-JavaScript library, and an interesting thing about CSS-in-JavaScript libraries is that they enable you to collocate components with styles. Being able to tie them up together in a scope ensures that some component styles don’t interfere with others, which is crucial to our component library.

Emotion exposes two APIs for React:

  • @emotion/core
  • @emotion/styled

Before we dive into how these APIs work, note that they both support the styling of components with template strings and objects.

The core API is actually like the regular style property we currently use today when building apps with React, with the addition of vendor prefixing, nested selectors, media queries, and more.

Using the object approach with the core API would typically look like this:

import { jsx } from '@emotion/core'

let Box = props => {
  return (
    <div
      css={{
        backgroundColor: 'grey'
      }}
      {...props}
    />
  )
}

This is a rather contrived example that shows how we could style a Box component with Emotion. It’s like swapping out the style property for a css property, and then we’re good to go.

Now, let’s see how we could use the template string approach with the same core API:

import { jsx, css } from '@emotion/core'

let Box = props => {
  return (
    <div
      css={css`
        background-color: grey
      `}
      {...props}
    />
  )
}

All we did was wrap the template string with the css tag function, and Emotion handles the rest.

The styled API, which is built on the core API, takes a slightly different approach to styling components. This API is called with a particular HTML element or React component, and that element is called with an object or a template string that contains the styles for that element.

Let’s see how we could use the object approach with the styled API:

import styled from '@emotion/styled'

const Box = styled.div({
        backgroundColor: 'grey'
});

Here is one way to use the styled API, which is an alternative to using the core API. The rendered outputs are the same.

Now, let’s see how we could use the template string approach using the styled API:

import styled from '@emotion/styled'

const Box = styled.div`
        background-color: grey
`

This achieves the same thing as the object approach, only with a template string this time.

We could use either the core API or the styled API when building components or an application. I prefer the styled approach for a component library for a couple of reasons:

  • It achieves a lot with few keystrokes.
  • It takes in an as prop, which helps with dynamically changing the HTML element from the call site. Let’s say we default to a paragraph element, and we need a header element because of semantics; we can pass the header element as a value to the as property.

Getting Started

To get started, let’s clone the setup scripts on GitHub, which we can do on the command line:

git clone git@github.com:smashingmagazine/component-library.git

This command copies the code in that repository to the component-library’s folder. It contains the code required to set up a component library, which includes Rollup to help bundle our library.

We currently have a components folder with an index.js file, which does nothing. We’ll be creating new folders under the components folder for each component we build in our library. Each component’s folder will expose the following files:

  • Component.js
    This is the component we’re building.
  • index.js
    This exports the component from Component.js and makes referencing components from a different location easier.
  • Component.story.js
    This essentially renders our component in its multiple states using Storybook.

It also ships with a utils folder, which defines certain properties that would be used in our components. The folder contains several files:

  • helpers.js
    This contains helper functions that we are going to be using across our application.
  • units.js
    This defines spacing and font-size units, which we will use later.
  • theme.js
    This defines our component library’s palette, shadows, typography, and shape.

Let’s look at what we’ve defined in the units.js file:

export const spacing = {
  none: 0,
  xxsmall: '4px',
  xsmall: '8px',
  small: '12px',
  medium: '20px',
  gutter: '24px',
  large: '32px',
  xlarge: '48px',
  xxlarge: '96px',
};

export const fontSizes = {
  xsmall: '0.79rem',
  small: '0.889rem',
  medium: '1rem',
  large: '1.125rem',
  xlarge: '1.266rem',
  xxlarge: '1.424rem',
};

This defines the spacing and fontSizes rules. The spacing rule was inspired by the Braid design system, which is based on multiples of four. The fontSizes are derived from the major second (1.125) type scale, which is a good scale for product websites. If you’re curious to learn more about type scale, “Exploring Responsive Type Scales” explains the value of knowing the scales appropriate for different websites.

Next, let’s through the theme.js file!

import { spacing } from './units';

const white = '#fff';
const black = '#111';

const palette = {
  common: {
    black,
    white,
  },
  primary: {
    main: '#0070F3',
    light: '#146DD6',
    contrastText: white,
  },
  error: {
    main: '#A51C30',
    light: '#A7333F',
    contrastText: white,
  },
  grey: {
    100: '#EAEAEA',
    200: '#C9C5C5',
    300: '#888',
    400: '#666',
  },
};

const shadows = {
  0: 'none',
  1: '0px 5px 10px rgba(0, 0, 0, 0.12)',
  2: '0px 8px 30px rgba(0, 0, 0, 0.24)',
};

const typography = {
  fontFamily:
    "Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, 'Helvetica Neue', sans-serif",
};

const shape = {
  borderRadius: spacing['xxsmall'],
};

export const theme = {
  palette,
  shadows,
  typography,
  shape,
};

In the theme file, we’ve defined our palette, which is essentially the colors we’re going to be using across all components in our library. We also have a shadows object, where we define our box-shadow values. There’s also the typography object, which currently just defines our fontFamily. Finally, shape is used for properties such as border-radius. This theme’s structure is inspired by Material-UI.

Next, our helpers.js file!

export const isObjectEmpty = (obj) => {
  return Object.keys(obj).length === 0;
};

Here, we only expose the isObjectEmpty function, which takes in an object and returns true if the object is empty. It returns false if it has any values. We’re going to make use of this function later.

Now that we’ve gone through all of the files in the utils folder, it’s about time to start building our components!

Buttons

Buttons are one of the most used components on the web. They’re used everywhere and can take different forms, shapes, sizes, and more.

Here are the buttons we’re going to build in Figma.

These subtle variations are going to be applied as properties to our button. We would like the buttons in our component library to accept properties such as variant, size, enableElevation (i.e. box-shadow), and color.

Starting with the button component, let’s create a Button folder, where we will define everything related to buttons, as discussed earlier.

Let's create our button component:

import styled from '@emotion/styled';
import isPropValid from '@emotion/is-prop-valid';

const StyledButton = () => {};

const IGNORED_PROPS = ['color'];

const buttonConfig = {
  shouldForwardProp: (prop) =>
    isPropValid(prop) && !IGNORED_PROPS.includes(prop),
};

export const Button = styled('button', buttonConfig)(StyledButton);

Here, we’ve started off by setting up our button component with a buttonConfig. The buttonConfig contains shouldForwardProp, which is used to control the properties that should be forwarded to the DOM, because properties such as color show up on the rendered element by default.

Next, let’s define our button sizes, which we’re going to use in the button component!

const buttonSizeProps = {
  small: {
    fontSize: fontSizes['xsmall'],
    padding: `${spacing['xsmall']} ${spacing['small']}`,
  },
  medium: {
    fontSize: fontSizes['small'],
    padding: `${spacing['small']} ${spacing['medium']}`,
  },
  large: {
    fontSize: fontSizes['medium'],
    padding: `${spacing['medium']} ${spacing['large']}`,
  },
};

buttonSizeProps is a map of our size values (small, medium, and large), and it returns fontSize and padding values based on the sizes. For a small button, we’d need a small font with small padding. The same goes for the medium and large sizes to scale them appropriately.

Next, let’s define a function that provides valid CSS properties based on the passed variant:

const getPropsByVariant = ({ variant, color, theme }) => {

  const colorInPalette = theme.palette[color];

  const variants = {
    outline: colorInPalette
      ? outlineVariantPropsByPalette
      : defaultOutlineVariantProps,
    solid: colorInPalette
      ? solidVariantPropsByPalette
      : defaultSolidVariantProps,
  };

  return variants[variant] || variants.solid;
};

Here, the getPropsByVariant function takes in variant, color, and theme properties and returns the properties of the specified variant; if no variant is specified, it defaults to solid. colorInPalette retrieves the palette assigned to the specified color if found, and undefined if not found in our theme object.

In each variant, we check whether a palette actually exists for the color specified; if we don’t, then we use colors from the common and grey objects of our theme, which we will apply in defaultOutlineVariantProps and defaultSolidVariantProps.

Next, let’s define our variant properties!

const defaultSolidVariantProps = {
  main: {
    border: `1px solid ${theme.palette.grey[100]}`,
    backgroundColor: theme.palette.grey[100],
    color: theme.palette.common.black,
  },
  hover: {
    border: `1px solid ${theme.palette.grey[200]}`,
    backgroundColor: theme.palette.grey[200],
  },
};

const defaultOutlineVariantProps = {
  main: {
    border: `1px solid ${theme.palette.common.black}`,
    backgroundColor: theme.palette.common.white,
    color: theme.palette.common.black,
  },
  hover: {
    border: `1px solid ${theme.palette.common.black}`,
    backgroundColor: theme.palette.common.white,
    color: theme.palette.common.black,
  },
};

const solidVariantPropsByPalette = colorInPalette && {
  main: {
    border: `1px solid ${colorInPalette.main}`,
    backgroundColor: colorInPalette.main,
    color: colorInPalette.contrastText,
  },
  hover: {
    border: `1px solid ${colorInPalette.light}`,
    backgroundColor: colorInPalette.light,
  },
};

const outlineVariantPropsByPalette = colorInPalette && {
  main: {
    border: `1px solid ${colorInPalette.main}`,
    backgroundColor: theme.palette.common.white,
    color: colorInPalette.main,
  },
  hover: {
    border: `1px solid ${colorInPalette.light}`,
    backgroundColor: theme.palette.common.white,
    color: colorInPalette.light,
  },
};

Here, we define the properties that are going to be applied to our button based on the selected variants. And, as discussed earlier, defaultSolidVariantProps and defaultOutlineVariantProps use colors from our common and grey objects as fallbacks for when the color specified isn’t in our palette or when no color is specified for what we put in place.

By the way, the solidVariantPropsByPalette and outlineVariantPropsByPalette objects use the color from our palette as specified by the button. They both have main and hover properties that differentiate the button’s default and hover styles, respectively.

The button design we’ve used accounts for two variants, which we can check out in our component library design.

Next, let’s create our StyledButton function, which combines all we’ve done so far.

const StyledButton = ({
  color,
  size,
  variant,
  enableElevation,
  disabled,
  theme,
}) => {
  if (isObjectEmpty(theme)) {
    theme = defaultTheme;
  }

  const fontSizeBySize = buttonSizeProps[size]?.fontSize;
  const paddingBySize = buttonSizeProps[size]?.padding;
  const propsByVariant = getPropsByVariant({ variant, theme, color });

  return {
    fontWeight: 500,
    cursor: 'pointer',
    opacity: disabled && 0.7,
    transition: 'all 0.3s linear',
    padding: buttonSizeProps.medium.padding,
    fontSize: buttonSizeProps.medium.fontSize,
    borderRadius: theme.shape.borderRadius,
    fontFamily: theme.typography.fontFamily,
    boxShadow: enableElevation && theme.shadows[1],
    ...(propsByVariant && propsByVariant.main),
    ...(paddingBySize && { padding: paddingBySize }),
    ...(fontSizeBySize && { fontSize: fontSizeBySize }),
    '&:hover': !disabled && {
      boxShadow: enableElevation && theme.shadows[2],
      ...(propsByVariant && propsByVariant.hover),
    },
  };
};

In the StyledButton function, we’re assigning defaultTheme to the theme if the theme object is empty which makes it optional for the consumers of our library to use Emotion’s ThemeProvider in order to make use of the library. We assigned fontSize and padding based on the buttonSizeProps object. We defined several default button properties, such as fontWeight and cursor, which aren’t tied to any property, and we also derived color, backgroundColor, and border values based on the result of propsByVariant.

Now that we’ve created our Button component, let’s see how we can use it:

<Button
    variant="solid"
    color="primary"
    size="small"
    enableElevation
    disabled
>
    Small Outline Elevated Button
</Button>

We can check what that looks like on CodeSandbox:

That’s how to use the Button component. We define the following properties:

  • We define a variant with a solid value. We could have specified outline instead. If the variant prop isn’t provided, we would also default to solid.
  • We define color, with a value of primary. We also support error as a color value or a color from a theme object. If the color property isn’t specified, we would fall back to our default color state.
  • We define size, with a value of small. It could be medium (the default) or large.
  • We define EnableElevation because we want some box-shadow on our button. We could have chosen not to use it.
  • Finally, we define disabled because we want our button to be disabled. The additional thing we do to a disabled button is reduce its opacity.

The button doesn’t need to take any property. It defaults to a solid medium-sized button.

Box Component

A box component is a container that can hold any component or HTML element. It accepts but is not limited to properties such as padding, margin, display, and width. It can also be used as a base component for some of the other components we’ll get into later.

Here’s what it looks like on Figma:

Before diving into the code, let’s not forget to create a new folder for this component.

Now, let’s create our Box component:


import styled from '@emotion/styled';
import isPropValid from '@emotion/is-prop-valid';
import { spacing, theme as defaultTheme } from '../../utils';

const StyledBox = ({
  paddingX,
  paddingY,
  marginX,
  marginY,
  width,
  display,
  theme,
  ...props
}) => {

  if (isObjectEmpty(theme)) {
    theme = defaultTheme;
  }

  const padding = spacing[props.padding];
  let paddingTop = spacing[props.paddingTop];
  let paddingRight = spacing[props.paddingRight];
  let paddingBottom = spacing[props.paddingBottom];
  let paddingLeft = spacing[props.paddingLeft];
  if (paddingX) {
    paddingLeft = spacing[paddingX];
    paddingRight = spacing[paddingX];
  }
  if (paddingY) {
    paddingTop = spacing[paddingY];
    paddingBottom = spacing[paddingY];
  }
  let margin = spacing[props.margin];
  let marginTop = spacing[props.marginTop];
  let marginRight = spacing[props.marginRight];
  let marginBottom = spacing[props.marginBottom];
  let marginLeft = spacing[props.marginLeft];
  if (marginX) {
    marginLeft = spacing[marginX];
    marginRight = spacing[marginX];
  }
  if (marginY) {
    marginTop = spacing[marginY];
    marginBottom = spacing[marginY];
  }
  return {
    padding,
    paddingTop,
    paddingRight,
    paddingBottom,
    paddingLeft,
    margin,
    marginTop,
    marginRight,
    marginBottom,
    marginLeft,
    width,
    display,
    fontFamily: theme.typography.fontFamily,
  };
};

const IGNORED_PROPS = ['display', 'width'];

const boxConfig = {
  shouldForwardProp: (prop) =>
    isPropValid(prop) && !IGNORED_PROPS.includes(prop),
};

export const Box = styled('div', boxConfig)(StyledBox);

The spacing rule we defined earlier is being applied to both padding and margin, as we can see in the Box component. We receive contextual values for padding and margin, and we look up their actual values from the spacing object.

We accept paddingX and paddingY props to update padding across the horizontal and vertical axis, respectively. We do the same for marginX and marginY as well.

Also, we don’t want the display and width props to get forwarded to the DOM because we only need them in CSS. So, we add them to our list of props to ignore, and pass that on to our config.

Here’s how we could use the Box component:

<Box
  padding="small"
  paddingTop="medium"
  paddingBottom="medium"
>
  Simple Box Component
</Box>

We can see what this looks like on CodeSandbox.

In this Box component, we’ve assigned small as a value to our padding property, and medium to the paddingTop and paddingBottom properties. When rendered, the Box component will have its padding-left and padding-right properties set to 12px each, and its padding-top and padding-bottom properties set to 20px. We could have replaced paddingTop and paddingBottom with paddingY and gotten the same result.

Columns Component

The Columns component is a variation of our Box component, with a display type of flex and with children spaced evenly across the x-axis.

Here is a representation of the Columns component in Figma:

Let’s build our Columns component!

import React from 'react';
import { Box } from '../Box';

export const Columns = ({ children, space, ...props }) => {
  return (
    <Box display="flex" {...props}>
      {React.Children.map(children, (child, index) => {
        if (child.type !== Box) {
          console.warn(
            'Each child in a Columns component should be a Box component'
          );
        }

        if (index > 0) {
          return React.cloneElement(child, {
            marginLeft: space,
            width: '100%',
          });
        }

        return React.cloneElement(child, { width: '100%' });
      })}
    </Box>
  );
};

We’re using React.Children to map over the Columns component’s children. And we’re adding marginLeft and width properties to each of the children, except the first child, which doesn’t need a marginLeft property because it’s the leftmost child in the column. We expect each child to be a Box element to ensure that the necessary styles are applied to it.

Here’s how we could use the Columns component:

<Columns space="small">
  <Box> Item 1</Box>
  <Box> Item 2</Box>
  <Box> Item 3</Box>
</Columns>

We can see what that looks like on CodeSandbox.

The Columns children here are spaced evenly across the x-axis by 12 pixels because that’s what the value of small resolves to, as we’ve defined earlier. Because the Columns component is literally a Box component, it can take in other Box component properties, and we can customize it as much as we want.

Stack Component

This is also a variation of our Box component that takes the full width of the parent element and whose children are spaced evenly across the y-axis.

Here is a representation of the Stack component in Figma:

Let’s build our Stack component:

import React from 'react';
import { Box } from '../Box';
import { Columns } from '../Columns';

const StackChildrenTypes = [Box, Columns];
const UnsupportedChildTypeWarning =
  'Each child in a Stack component should be one of the types: Box, Columns';

export const Stack = ({ children, space, ...props }) => {
  return (
    <Box {...props}>
      {React.Children.map(children, (child, index) => {
        if (!StackChildrenTypes.includes(child.type)) {
          console.warn(UnsupportedChildTypeWarning);
        }

        if (index > 0) {
          return React.cloneElement(child, { marginTop: space });
        }

        return child;
      })}
    </Box>
  );
};

Here, we map over each child with React.Children and apply a paddingTop property to it with the value of the space argument. As for the first child, we need it to take its original position, so we skip adding a marginTop property to it. We also accept each child to be a Box so that we can apply the necessary properties to it.

Here’s how we could use the Stack component:

<Stack space="small">
  <Box marginTop="medium"> Item 1</Box>
  <Box> Item 2</Box>
  <Box> Item 3</Box>
</Stack>

We can see what that looks like on CodeSandbox.

Here, the Box elements are spaced evenly with the small unit, and the first Box takes a separate marginTop property. This shows that you can customize components however you wish.

Conclusion

We’ve gone through the basics of using Emotion to create components in React using the APIs that it provides. This is just one of many ways to go about building a component library. There are some nuances to building it for a brand because you might not have to take theming and some other things into consideration. But if you plan to release the library to the public one day, then you’ll have to deal with requests for those missing pieces, so consider that possibility and make the library a little flexible ahead of time.

If you have any questions, feel free to drop them as comments.

The repository for this article is on GitHub, and the button designs we’ve used are on Figma.

References

How To Create A Custom React Hook To Fetch And Cache Data

How To Create A Custom React Hook To Fetch And Cache Data

How To Create A Custom React Hook To Fetch And Cache Data

Ademola Adegbuyi

If you are a newbie to React Hooks, you can start by checking the official documentation to get a grasp of it. After that, I’d recommend reading Shedrack Akintayo’s “Getting Started With React Hooks API”. To ensure you’re following along, there is also an article written by Adeneye David Abiodun that covers best practices with React Hooks which I’m sure will prove to be useful to you.

Throughout this article, we’ll be making use of Hacker News Search API to build a custom hook which we can use to fetch data. While this tutorial will cover the Hacker News Search API, we’ll have the hook work in a way that it will return response from any valid API link we pass to it.

Best Practices With React

React is a fantastic JavaScript library for building rich user interfaces. It provides a great component abstraction for organizing your interfaces into well-functioning code, and there’s just about anything you can use it for. Read more articles on React →

Fetching Data In A React Component

Before React hooks, it was conventional to fetch initial data in the componentDidMount() lifecycle method, and data based on prop or state changes in componentDidUpdate() lifecycle method.

Here’s how it works:

componentDidMount() {
  const fetchData = async () => {
    const response = await fetch(
      `https://hn.algolia.com/api/v1/search?query=JavaScript`
    );
    const data = await response.json();
    this.setState({ data });
  };
  
  fetchData();
}


componentDidUpdate(previousProps, previousState) {
    if (previousState.query !== this.state.query) {
      const fetchData = async () => {
        const response = await fetch(
          `https://hn.algolia.com/api/v1/search?query=${this.state.query}`
        );
        const data = await response.json();
        this.setState({ data });
      };

      fetchData();
    }
  }

The componentDidMount lifecycle method gets invoked as soon as the component gets mounted, and when that is done, what we did was to make a request to search for “JavaScript” via the Hacker News API and update the state based on the response.

The componentDidUpdate lifecycle method, on the other hand, gets invoked when there’s a change in the component. We compared the previous query in the state with the current query to prevent the method from getting invoked every time we set “data” in state. One thing we get from using hooks is to combine both lifecycle methods in a cleaner way — meaning that we won’t need to have two lifecycle methods for when the component mounts and when it updates.

Fetching Data With useEffect Hook

The useEffect hook gets invoked as soon as the component is mounted. If we need the hook to rerun based on some prop or state changes, we’ll need to pass them to the dependency array (which is the second argument of the useEffect hook).

Let’s explore how to fetch data with hooks:

import { useState, useEffect } from 'react';

const [status, setStatus] = useState('idle');
const [query, setQuery] = useState('');
const [data, setData] = useState([]);

useEffect(() => {
    if (!query) return;

    const fetchData = async () => {
        setStatus('fetching');
        const response = await fetch(
            `https://hn.algolia.com/api/v1/search?query=${query}`
        );
        const data = await response.json();
        setData(data.hits);
        setStatus('fetched');
    };

    fetchData();
}, [query]);

In the example above, we passed query as a dependency to our useEffect hook. By doing that, we’re telling useEffect to track query changes. If the previous query value isn’t the same as the current value, the useEffect get invoked again.

With that said, we’re also setting several status on the component as needed, as this will better convey some message to the screen based on some finite states status. In the idle state, we could let users know that they could make use of the search box to get started. In the fetching state, we could show a spinner. And, in the fetched state, we’ll render the data.

It’s important to set the data before you attempt to set status to fetched so that you can prevent a flicker which occurs as a result of the data being empty while you’re setting the fetched status.

Creating A Custom Hook

“A custom hook is a JavaScript function whose name starts with ‘use’ and that may call other Hooks.”

React Docs

That’s really what it is, and along with a JavaScript function, it allows you to reuse some piece of code in several parts of your app.

The definition from the React Docs has given it away but let’s see how it works in practice with a counter custom hook:

const useCounter = (initialState = 0) => {
      const [count, setCount] = useState(initialState);
      const add = () => setCount(count + 1);
      const subtract = () => setCount(count - 1);
      return { count, add, subtract };
};

Here, we have a regular function where we take in an optional argument, set the value to our state, as well as add the add and the subtract methods that could be used to update it.

Everywhere in our app where we need a counter, we can call useCounter like a regular function and pass an initialState so we know where to start counting from. When we don’t have an initial state, we default to 0.

Here’s how it works in practice:

import { useCounter } from './customHookPath';

const { count, add, subtract } = useCounter(100);

eventHandler(() => {
  add(); // or subtract();
});

What we did here was to import our custom hook from the file we declared it in, so we could make use of it in our app. We set its initial state to 100, so whenever we call add(), it increases count by 1, and whenever we call subtract(), it decreases count by 1.

Creating useFetch Hook

Now that we’ve learned how to create a simple custom hook, let’s extract our logic to fetch data into a custom hook.

const useFetch = (query) => {
    const [status, setStatus] = useState('idle');
    const [data, setData] = useState([]);

    useEffect(() => {
        if (!query) return;

        const fetchData = async () => {
            setStatus('fetching');
            const response = await fetch(
                `https://hn.algolia.com/api/v1/search?query=${query}`
            );
            const data = await response.json();
            setData(data.hits);
            setStatus('fetched');
        };

        fetchData();
    }, [query]);

    return { status, data };
};

It’s pretty much the same thing we did above with the exception of it being a function that takes in query and returns status and data. And, that’s a useFetch hook that we could use in several components in our React application.

This works, but the problem with this implementation now is, it’s specific to Hacker News so we might just call it useHackerNews. What we intend to do is, to create a useFetch hook that can be used to call any URL. Let’s revamp it to take in a URL instead!

const useFetch = (url) => {
    const [status, setStatus] = useState('idle');
    const [data, setData] = useState([]);

    useEffect(() => {
        if (!url) return;
        const fetchData = async () => {
            setStatus('fetching');
            const response = await fetch(url);
            const data = await response.json();
            setData(data);
            setStatus('fetched');
        };

        fetchData();
    }, [url]);

    return { status, data };
};

Now, our useFetch hook is generic and we can use it as we want in our various components.

Here’s one way of consuming it:

const [query, setQuery] = useState('');

const url = query && `https://hn.algolia.com/api/v1/search?query=${query}`;
const { status, data } = useFetch(url);

In this case, if the value of query is truthy, we go ahead to set the URL and if it’s not, we’re fine with passing undefined as it’d get handled in our hook. The effect will attempt to run once, regardless.

Memoizing Fetched Data

Memoization is a technique we would use to make sure that we don’t hit the hackernews endpoint if we have made some kind of request to fetch it at some initial phase. Storing the result of expensive fetch calls will save the users some load time, therefore, increasing overall performance.

Note: For more context, you could check out Wikipedia’s explanation on Memoization.

Let’s explore how we could do that!

const cache = {};

const useFetch = (url) => {
    const [status, setStatus] = useState('idle');
    const [data, setData] = useState([]);

    useEffect(() => {
        if (!url) return;

        const fetchData = async () => {
            setStatus('fetching');
            if (cache[url]) {
                const data = cache[url];
                setData(data);
                setStatus('fetched');
            } else {
                const response = await fetch(url);
                const data = await response.json();
                cache[url] = data; // set response in cache;
                setData(data);
                setStatus('fetched');
            }
        };

        fetchData();
    }, [url]);

    return { status, data };
};

Here, we’re mapping URLs to their data. So, if we make a request to fetch some existing data, we set the data from our local cache, else, we go ahead to make the request and set the result in the cache. This ensures we do not make an API call when we have the data available to us locally. We’ll also notice that we’re killing off the effect if the URL is falsy, so it makes sure we don’t proceed to fetch data that doesn’t exist. We can’t do it before the useEffect hook as that will go against one of the rules of hooks, which is to always call hooks at the top level.

Declaring cache in a different scope works but it makes our hook go against the principle of a pure function. Besides, we also want to make sure that React helps in cleaning up our mess when we no longer want to make use of the component. We’ll explore useRef to help us in achieving that.

Memoizing Data With useRef

useRef is like a box that can hold a mutable value in its .current property.”

React Docs

With useRef, we can set and retrieve mutable values at ease and its value persists throughout the component’s lifecycle.

Let’s replace our cache implementation with some useRef magic!

const useFetch = (url) => {
    const cache = useRef({});
    const [status, setStatus] = useState('idle');
    const [data, setData] = useState([]);

    useEffect(() => {
        if (!url) return;
        const fetchData = async () => {
            setStatus('fetching');
            if (cache.current[url]) {
                const data = cache.current[url];
                setData(data);
                setStatus('fetched');
            } else {
                const response = await fetch(url);
                const data = await response.json();
                cache.current[url] = data; // set response in cache;
                setData(data);
                setStatus('fetched');
            }
        };

        fetchData();
    }, [url]);

    return { status, data };
};

Here, our cache is now in our useFetch hook with an empty object as an initial value.

Wrapping Up

Well, I did state that setting the data before setting the fetched status was a good idea, but there are two potential problems we could have with that, too:

  1. Our unit test could fail as a result of the data array not being empty while we’re in the fetching state. React could actually batch state changes but it can’t do that if it’s triggered asynchronously;
  2. Our app re-renders more than it should.

Let’s do a final clean-up to our useFetch hook.,We’re going to start by switching our useStates to a useReducer. Let’s see how that works!

const initialState = {
    status: 'idle',
    error: null,
    data: [],
};

const [state, dispatch] = useReducer((state, action) => {
    switch (action.type) {
        case 'FETCHING':
            return { ...initialState, status: 'fetching' };
        case 'FETCHED':
            return { ...initialState, status: 'fetched', data: action.payload };
        case 'FETCH_ERROR':
            return { ...initialState, status: 'error', error: action.payload };
        default:
            return state;
    }
}, initialState);

Here, we added an initial state which is the initial value we passed to each of our individual useStates. In our useReducer, we check what type of action we want to perform, and set the appropriate values to state based on that.

This resolves the two problems we discussed earlier, as we now get to set the status and data at the same time in order to help prevent impossible states and unnecessary re-renders.

There’s just one more thing left: cleaning up our side effect. Fetch implements the Promise API, in the sense that it could be resolved or rejected. If our hook tries to make an update while the component has unmounted because of some Promise just got resolved, React would return Can't perform a React state update on an unmounted component.

Let’s see how we can fix that with useEffect clean-up!

useEffect(() => {
    let cancelRequest = false;
    if (!url) return;

    const fetchData = async () => {
        dispatch({ type: 'FETCHING' });
        if (cache.current[url]) {
            const data = cache.current[url];
            dispatch({ type: 'FETCHED', payload: data });
        } else {
            try {
                const response = await fetch(url);
                const data = await response.json();
                cache.current[url] = data;
                if (cancelRequest) return;
                dispatch({ type: 'FETCHED', payload: data });
            } catch (error) {
                if (cancelRequest) return;
                dispatch({ type: 'FETCH_ERROR', payload: error.message });
            }
        }
    };

    fetchData();

    return function cleanup() {
        cancelRequest = true;
    };
}, [url]);

Here, we set cancelRequest to true after having defined it inside the effect. So, before we attempt to make state changes, we first confirm if the component has been unmounted. If it has been unmounted, we skip updating the state and if it hasn’t been unmounted, we update the state. This will resolve the React state update error, and also prevent race conditions in our components.

Conclusion

We’ve explored several hooks concepts to help fetch and cache data in our components. We also went through cleaning up our useEffect hook which helps prevent a good number of problems in our app.

If you have any questions, please feel free to drop them in the comments section below!

References

Smashing Editorial (ks, ra, yk, il)