Extract a custom `useRequest` hook to simplify fetching data in a `react-blessed` Application
July 23, 2020
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.
- Bootstrap
react-blessed
Application - Add ESLint Rules to
react-blessed
App - Change text font with
figlet
- Extract Component and Add
useInterval
hook - Fetch and display current weather with
weather-js
- Extract custom hook to simplify data fetching
- Change text color with
chalk
andgradient-string
- Position and Align Text inside a
<box>
Element - Make a Percentage Based Layout
- Layout Dashboard with
react-blessed-contrib
Grid
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.
- A promise that will request the data (in our case the weather)
- An options object that will be passed to the promise when it's invoked
- 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:
- Introduce a
useRequest
custom hook and define its parameters - Generalize code to use
state
andsetState
(instead ofweather
andsetWeather
) - Replace
fetchWeather
withrequest
and in ourasync
function provide anoptions
parameter (passed into our hook) - Replace
findWeather
with thepromise
passed into our hook providingoptions
as it's argument - Update the
useCallback
dependency array to includepromise
- Replace
fetchWeather
in ouruseEffect
withrequest
passing inoptions
and change out its deps - Replace
fetchWeather
in ouruseInterval
withrequest
passingoptions
and use the optionalinterval
parameter - 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
Tweet about this post and have it show up here!