Extract a custom `useRequest` hook to simplify fetching data in a `react-blessed` Application

AvatarElijah Manor 3 months
1588 words 8 min read
The above video is hosted on egghead.io.

This is the 6th post in a series where we will be creating a developer dashboard in the terminal using react-blessed and react-blessed-contrib. For more information about the series and to take a sneak peak at what we're building, go to the 1st post in the series for more context.

  1. Bootstrap react-blessed Application
  2. Add ESLint Rules to react-blessed App
  3. Change text font with figlet
  4. Extract Component and Add useInterval hook
  5. Fetch and display current weather with weather-js
  6. Extract custom hook to simplify data fetching
  7. Change text color with chalk and gradient-string
  8. Position and Align Text inside a <box> Element
  9. Make a Percentage Based Layout
  10. Layout Dashboard with react-blessed-contrib Grid
... ~10 more lessons to come ...

NOTE: You can find the code for this project in GitHub and you watch the whole Build a Terminal Dashboard with React video series on egghead.io.

Fetching Logic Before

The following code snippet is part of the React Terminal Dashboard that we are building in this series. So far, it only shows the time, date, and weather information.

The point of this post is to extract out the weather fetching logic into it's own custom React hook instead of having all of the logic inside the Today component itself.

export default function Today({
  updateInterval = 900000,
  search = 'Nashville, TN',
  degreeType = 'F'
}) {
  /* ... */

  const [weather, setWeather] = React.useState({
    status: 'loading',
    error: null,
    data: null
  })

  const fetchWeather = React.useCallback(async () => {
    setWeather({ status: 'loading', error: null, data: null })
    let data
    try {
      data = await findWeather({ search, degreeType })
      setWeather({ status: 'complete', error: null, data })
    } catch (error) {
      setWeather({ status: 'error', error, data: null })
    }
  }, [search, degreeType])

  React.useEffect(() => {
    fetchWeather()
  }, [fetchWeather])

  useInterval(() => {
    fetchWeather()
  }, updateInterval)

  /* ... */

  return (
    <box { /* ... */ }>
      {`${ /* ... */ }
${
  weather.status === 'loading'
    ? 'Loading...'
    : weather.error
    ? `Error: ${weather.error}`
    : formatWeather(weather.data)
}`}
    </box>
  )
}

Pull Out Code to a Custom Hook

It can be helpful to think about the API of a custom React hook before you actually create it. The following code snippet is how we want to interact with our custom React hook. It'll have 3 main arguments.

  1. A promise that will request the data (in our case the weather)
  2. An options object that will be passed to the promise when it's invoked
  3. An optional internal (in milliseconds) if you'd like the request to be regularly invoked
const weather = useRequest(
  findWeather, // Promise that will request data
  { search, degreeType }, // Options passed to promise
  updateInterval, // Optional interval in milliseconds
);

Now, we'll focus on actually creating our custom React hook. For the most part we'll copy and paste the code from our main Today Component and make some minor adjustments to generalize the code.

One of the Rules of Hooks is that a custom hook must start with the word use.

const useRequest = (promise, options, interval = null) => {
const [state, setState] = React.useState({
status: 'loading', error: null, data: null, });
const request = async (options) => {
setState({ status: 'loading', error: null, data: null });
let data; try { data = await promise(options);
setState({ status: 'complete', error: null, data });
} catch (error) {
setState({ status: 'error', error, data: null });
} }; React.useEffect(() => {
request(options);
}, [options]);
useInterval(() => {
request(options);
}, interval);
return state;
};

Here are the main changes that were made:

  1. Introduce a useRequest custom hook and define its parameters
  2. Generalize code to use state and setState (instead of weather and setWeather)
  3. Replace fetchWeather with request and in our async function provide an options parameter (passed into our hook)
  4. Replace findWeather with the promise passed into our hook providing options as it's argument
  5. Update the useCallback dependency array to include promise
  6. Replace fetchWeather in our useEffect with request passing in options and change out its deps
  7. Replace fetchWeather in our useInterval with request passing options and use the optional interval parameter
  8. Return from our custom hook the state we defined at the top

Explain the Run-Away Effect Bug

Now, you might think everything is great at this point... but it's not! We have a pretty horrible bug at this point when we run our code.

INFINITE LOOP WARNING

The program will most likely lock up your machine and you'll need to forcefully kill the process to regain control.

So, Why is there an Infinite Loop?

So, what's going on here? When calling our new custom react hook, we're passing the options that I want to be supplied to my request. However, it's an object literal which means it'll get a new object reference each time the code is executed. And that's a problem becuase in our custom react hook, we have options listed inside our useEffect dependency array, which means it'll re-run the useEffect, which will then setState, which will then re-run our component, which will then make a new options object, and the cycle goes round and round...

// Code inside Today Component
const weather = useRequest(
  findWeather,
  // Step 1. A new options object is created on every render
  { search, degreeType },
  updateInterval,
);

// Code inside useRequest custom hook
React.useEffect(() => {
  request(options);
}, [options]);
// Step 2. useEffect is defined to re-trigger if options change

const request = async (options) => {
  /* Step 3. In request we setState, which will eventually re-render
     Goto Step 1
  */
  setState({ status: 'loading', error: null, data: null });
  /* ... */
};

Detect the Run-Away Effect Bug

It would be nice to find this problem before our program calls an external API hundreds of thousands of times. Thankfully, there is a library called stop-runaway-react-effects by @kentcdodds that can help us out here. You can conditionally add this tool during development to catch useEffects that may be running way too frequently!

NOTE: I've actually had myself locked out of a weather service and a geolocation service for this very reason 🤦‍♂️

Install Dependency

npm install --save stop-runaway-react-effects

Use the Tool

This tool is meant for development-time only, and we can totally set it up that way (see the docs), but for now I'm just going to run it all the time as I'm building the Terminal Dashboard.

We'll pull off the hijackEffects function from stop-runaway-react-effects and then call the function.

require('@babel/register')({
  presets: [['@babel/preset-env'], ['@babel/preset-react']],
  plugins: ['@babel/plugin-transform-runtime'],
});
const { hijackEffects } = require('stop-runaway-react-effects');
hijackEffects();
require('./dashboard');

Now, if we run our app again we'll now get an error instead of it locking up our computer. In addition it'll show some nice information about how frequently our run-away effect was called and show the dependencies that were used to help us track down the problem.

Fix Run-Away Bug with useMemo

So, how can you fix this problem? Well, there are a couple of ways. One is to introduce an options variable and leverage the React.useMemo hook passing a function that returns a NEW object containing the search and degreeType properties, but only return a NEW object when the value of one of those changes (which is why I'm supplying those inside the dependency array).

Then we'll swap out the object literal that we had (which was creating a new object instance every time) and replace it with our memoised options object. This will guarantee I have the same object reference (as long as search and degreeType don't change) each time this component is executed such that the underlying useEffect will not get re-triggered unnecessarily.

const options = React.useMemo(() => ({ search, degreeType }), [
  search,
  degreeType,
]);
const weather = useRequest(findWeather, options, updateInterval);

Fix Run-Away Bug with useDeepCompareEffect

Although we fixed the bug in the previous section with useMemo, that requires the consumer of your hook to know they need to useMemo or be aware of the problem and handle it another way.

Instead, let's solve this problem by introducing another hook called useDeepCompareEffect. This hook was created also by @kentcdodds (thank you Kent), and it'll do a deep comparison inside of the dependency array, which solves our problem because now the code will notice that the contents of our options have not changed, so it won't re-run the effect.

Install Dependency

npm install --save use-deep-compare-effect

Use the Hook

We no longer need the code tweaks we made in the previous useMemo section. Other than importing the hook into our file, all that is needed to fix our original issues is to replace the React.useEffect in our custom hook with useDeepCompareEffect. And that'll fix the problem as well, but doesn't rely on the consumer to manage object reference equality.

import useDeepCompareEffect from 'use-deep-compare-effect';
const useRequest = (promise, options, interval = null) => { /* ... */ // React.useEffect(() => { useDeepCompareEffect(() => { request(options); }, [options]); /* ... */ };

Fake out Requests During Development

Because of tricky runaway useEffects, you'll want to be careful when dealing with external request (especially those that are rate limited or have tokens that could lock you out... like a weather service for example). In our case I was actually resolving a fake promise with random data just so I wouldn't bombard the weather service.

NOTE: The stop-runaway-react-effects tool referenced above should help reduce this problem, but it's something good to keep in mind when first integrating with such services.

import weather from 'weather-js';
import util from 'util';
import { random } from 'lodash';

const findWeather = (options) => {
  return new Promise((resolve) =>
    setTimeout(() => {
      resolve([
        {
          location: { degreetype: options.degreeType },
          current: {
            temperature: random(50, 100),
            skytext: 'Normal',
          },
          forecast: [
            {},
            { low: random(0, 50), high: random(50, 100) },
          ],
        },
      ]);
    }, 1000),
  );
  return util.promisify(weather.find)(options);
};

NOTE: The final result of this series will have many more widgets than just the Today component. Here is a preview of what the final output may look like... https://twitter.com/elijahmanor/status/1282883611398635521

Share article