TECH

Custom React Hook Example

JARRETT RETZ July 20th, 2021 reactjs react programming web development sanity gatsby frontend hooks custom hooks react hooks gatsby sanity

Introduction

I remember first learning about React hooks with the release of version 16.8.0. I thought they were wonderfully helpful, and I can count on one hand the times—since that moment—when I reverted to writing class components (instead of a functional component).

Also, I remember the intended adage when creating custom React hooks:

Custom hooks allow you to reuse state, logic, and component features.

It's been a while since I first learned how to write a custom hook. Unfortunately, I do not write custom hooks that often, despite how helpful they can be.

In this article, I'll share an example custom React hook that implements a search feature on a website. The code shows you how to use useState, useEffect, useReducer, and useCallback in a custom hook.

Code Overview

First, I'll provide a little context for the code and project. The project uses the GatsbyJS framework and pulls content from Sanity.io's content management system (CMS).

I built the hook to handle a user searching for articles on my blog using text input.

The input sits toward the top of the page, and after it receives the text, provides a dropdown with the title, image, and link to blog posts matching the criteria.

The whole process is as follows:

  1. The user starts entering text into the search input.
  2. Hook waits for the user to stop typing.
  3. Hook sends an HTTP request to API and retrieves results.
  4. The hook provides variables or objects for the results, loading, error, error message, and a function for clearing the search results.
  5. The component gives feedback to the user, utilizing the hook variables, and finally returns the results that appear as small tiles in a dropdown.

Below is an image of the result:

Search Component

<Search> is a single component that uses the reusable hook. This React component utilizes the return values from the custom hook:

  • loading
  • error
  • results
  • errorMessage
  • clear (function)

It's also responsible for rendering the search input and result data. A redacted version of the component is below:

// Search.js
import React from 'react';
import { Link } from 'gatsby';
import { GatsbyImage } from 'gatsby-plugin-image';
import TextField from '@material-ui/core/TextField';
import { getGatsbyImageData } from 'gatsby-source-sanity';
import Skeleton from 'react-loading-skeleton';
// ...
import { sanityConfig } from '../../util/sanityClient';
// ...
import useSearch from '../../hooks/useSearch';

const DisplaySearchResults = ({ results = [] }) => (
    <>
     //...
    </>
);

const Search = () => {
    const [search, setSearch] = React.useState('');
    const searchInputEl = React.useRef(null);
    const query = 'hard coded query with corresponding params';

    const params = React.useMemo(() => ({ 
      // query params with values
    }), [search]);

    const {
        loading, result: results, error, clear,
    } = useSearch(search, query, params, sanityConfig);

    const clearSearch = () => {
        setSearch('');
        clear();
        searchInputEl.current.focus();
    };

    return (
        <div className={Wrapper}>
            <form>
                <TextField
                    id="quick search"
                    label="QUICK SEARCH"
                    value={search}
                    inputRef={searchInputEl}
                    onChange={(e) => setSearch(e.target.value)}
                    // ...
                />
            </form>
            <div>
                {!loading ?
                  <DisplaySearchResults results={results} />
                  :
                  <Skeleton className={LinkStyle} count={9} width={280} height={85} />
                }
            </div>
            {results && results.length > 0 && (
                <Button onClick={clearSearch}>
                    <Close /> Clear
                </Button>
            )}
            // ...
        </div>
    );
};

export default Search;

Notice that we destructure the values from the hook. Therefore, we can expect the hook's return statement to look similar to:

// useSearch.js
const useSearch = (userEnteredSearch, sanityQuery, params, sanityConfig, delay = 1000) => {
    
    // function code

    return {
        loading: state.loading,
        result: state.result,
        error: state.error,
        errorMessage: state.errorMessage,
        clear,
    };
};

export default useSearch;

Also, we pass a series of arguments into the hook.

  • userEnteredSearch: the query string
  • sanityQuery: The formatted GROQ query for Sanity
  • params: Object that holds dynamic values that insert into the GROQ query
  • sanityConfig: Object used to build the Sanity API client
  • delay = 1000: How many milliseconds the function waits before calling the API. Theoretically, if the user stops typing for a couple of seconds, they have entered their entire search query.

Custom React Hook Code

First, the custom hook, useSearch, manages state with useReducer.

This is typical with HTTP call because we often have to change the state of different variables simultaneously. This situation makes it advantageous to use events to modify values at once and in a clean fashion.

import React from 'react';
const sanityClient = require('@sanity/client');

const initialState = {
    loading: false,
    result: [],
    error: false,
    errorMessage: '',
};

const reducer = (state, action) => {
    switch (action.type) {
    case 'FETCHING':
        return {
            ...state,
            loading: true,
            error: false,
            errorMessage: '',
        };
    case 'SUCCESS':
        return {
            ...state,
            loading: false,
            result: action.result,
        };
    case 'ERROR':
        return {
            ...state,
            error: true,
            errorMessage: action.error,
            loading: false,
            result: [],
        };
    case 'CLEAR':
        return {
            ...state,
            ...initialState,
        };
    default:
        throw new Error(`Event unknown: ${action.type}`);
    }
};

/**
 * Queries Sanity CMS providing common HTTP request helper variables.
 *
 * @param {string} userEnteredSearch Query string
 * @param {string} sanityQuery Sanity GROQ query
 * @param {object} params Object with properties for dynamic GROQ query values
 * @param {int} delay Milliseconds to wait for user to stop typing before sending request to API
 * @returns {
 *      loading: boolean,
 *      result: array,
 *      errorMessage: string,
 *      error: boolean,
 *      clear: function
 * }
 */
const useSearch = (userEnteredSearch, sanityQuery, params, sanityConfig, delay = 1000) => {
    const [state, dispatch] = React.useReducer(reducer, initialState);

    /**
     * Build Sanity client
     */
    const client = sanityClient({ ...sanityConfig });

    const sanitySearch = React.useCallback(() => {
        /**
         * Clear errors and set loading
         */
        dispatch({ type: 'FETCHING' });

        /**
         * Query Sanity CMS
         */
        client
            .fetch(sanityQuery, params)
            .then((result) => {
                if (result) {
                    dispatch({ type: 'SUCCESS', result });
                }
            })
            .catch((e) => {
                dispatch({ type: 'ERROR', error: JSON.stringify(e) });
            });
    }, [sanityQuery, params]);

    React.useEffect(() => {
        if (userEnteredSearch !== '') {
            /**
             * Waits for the user to stop typing.
             */
            const debouncer = setTimeout(() => {
                sanitySearch();
            }, delay);

            /**
             * Clears timeout function on unmount
             */
            return () => {
                clearTimeout(debouncer);
            };
        }
    }, [sanitySearch, userEnteredSearch, delay]);

    const clear = () => {
        dispatch({ type: 'CLEAR' });
    };

    return {
        loading: state.loading,
        result: state.result,
        error: state.error,
        errorMessage: state.errorMessage,
        clear,
    };
};

export default useSearch;

Second, we have a function that calls the Sanity API. We wrapped the function inside the useCallback hook to avoid an infinite loop of rendering by the component.

Two things can happen in this function:

  1. Results return, and a 'SUCCESS' event dispatches.
  2. An error returns, and an 'ERROR' event dispatches

Thirdly, let's look at the useEffect hook. Remember, this hook belongs inside the <Search> component, so the useEffect runs every time that component rerenders. Inside of useEffect, we check if there is a search value. If not, we don't run the function. If there is a search query, we set a timeout to execute the API call after the delay.

Every time the user enters a new character in the input box, this hook rerenders—along with its parent component. That's because the search query text is held in the state of <Search>. Consequently, the HTTP request timeout waits until the user does not enter a new character for longer than the specified delay.

Code Reuse

I tried to boil this hook down to essentials to use in other projects or even in other parts of the site. That's why, for example, we're passing in the query, delay, and Sanity.io configuration object. But, conversely, I could hard code the config, parts of the query, or the delay.

Furthermore, I am hesitant to move the search query variable inside the hook. That's not the only place the component may need to improve. The state is handled through a reducer, and developers often want to add more props and more state variables, which is not bad, but it can hijack the simple use case of the hook's origin.

I digress; the point is that a hook is a reusable function, and I believe I should look for more ways to use them in my React code.

Conclusion

I enjoyed writing this hook and look forward to using more hooks. As a last note, I think it may be easier to test React components using hooks due to their functional nature, but I'll have to look into that on a different day. Thanks for reading!


Have a thought about the article?

Send JRTS a message!

We'll use this email to respond to your message.
Contact