loading...

Homebrew React Hooks: useCurrent

n1ru4l profile image Laurin Quast Updated on ・6 min read

Homebrew React Hooks (3 Part Series)

1) Homebrew React Hooks: useCurrent 2) Homebrew React Hooks: useAsyncEffect Or How to Handle Async Operations with useEffect 3) Homebrew React Hooks: useIsKeyPressed

You might probably have heard of React Suspense.

In 2018 Dan Abramov presented this new feature which is yet to be released. As part of the talk he showcased how suspense can make data fetching easier.

I was really fascinated by his words about loading indicators and how removing them can lead to better user experience.

Some of you might now think: Why should I show a blank page to my users?

Well, he actually meant not completely removing them, but only showing them when they are necessary.

Let's say you have a request that only takes 100 milliseconds. You would show a loading spinner for a very short time frame. The user might not even have enough time to realize he did just enter a loading state. He might just notice the page flashing.

On the other hand, if you do not have a loading indicator at all and the request takes a few seconds (e.g. due to bad internet connection) and all the user sees is a blank page, the situation becomes even worse.

Such "janky" behavior can confuse the user and make him assume that the page is buggy or broken, in the worst case he could even become angry (and we all know that only a happy user is a loyal customer).

In order to provide the best user experience we need to solve both problems at the same time!

So there are two big questions to answer, the first of them being:

What do we render while the request for new content is still pending?

Correct! We render The old content™️

Of course, in case we do not have any old content we still need to show a loading spinner.

Here is the second question:

What do we render when the request for new content takes forever?

Yes! We show a loading spinner.

Although suspense might be the silver bullet to solve this problem in the future, I asked myself: Can we achieve the same user experience with hooks today?



Let's take a look on existing data fetching libraries

In the following examples, I am going to use a hook that simulates a network request for the data fetching part. It uses setTimeout internally.

const {data, loading} = useFakeFetch("/endpoint")

The code should look familiar to folks that have worked with react-apollo-hooks or react-fetch-hook before.

Almost all of those data fetching hooks work the same, once a hook parameter changes, the data is re-fetched with the new parameters.

Try clicking the change endpoint button in the following example.

Did you notice that immediately after clicking the button data becomes null?

This is exactly the kind of behavior that we want to change!

So where do we start?

We could, of course, change the existing data fetching hook.

However, my first rule regarding hooks is the power of hooks lies in the composability of multiple hooks.

So instead of altering the existing hook we will now step by step build a new hook that is compatible with every data fetching hook that returns a data and loading value.

So let's start by storing the old data until the new data is available.

The perfect use-case for useState combined with useEffect

const {data, loading} = useFakeFetch("/endpoint")
const [currentData, setCurrentData] = React.useState(data);

React.useEffect(() => {
 // condition for updating the stored data
 if (loading === false && data !== currentData) {
   setCurrentData(data)
 }
}, [loading, data, setCurrentData, currentData]);

// use currentData instead of data

Check out the following example:

Did you notice the new value currentData which now holds the old value until the new data was fetched?

Half of the problem is already solved!

In the next step, we will ensure that the loading indicator should only be shown after a certain threshold has been exceeded (aka the request is taking longer than expected).

Reintroducing our old friends setTimeout and clearTimeout

const {data, loading} = useFakeFetch(endpoint);
const [currentData, setCurrentData] = React.useState(data);

React.useEffect(() => {
  if (loading === false && data !== currentData) {
    setCurrentData(data);
  }
}, [loading, data, setCurrentData, currentData]);

// NEW STUFF STARTS HERE
const previousLoadingRef = React.useRef(loading);
const [
  shouldShowLoadingIndicator,
  setShouldShowLoadingIndicator
] = React.useState(loading);

React.useEffect(() => {
  let timeout = undefined;
  // only update in case loading has changed between renders
  if (previousLoadingRef.current !== loading) {
    if (loading) {
      // should show loading indicator if request time
      // exceeds one second
      timeout = setTimeout(() => {
        setShouldShowLoadingIndicator(true);
      }, 1000);
    } else {
      setShouldShowLoadingIndicator(false);
    }
  }
  previousLoadingRef.current = loading;

  // cancel the timeout in case the data is available 
  // before one second has passed
  return () => timeout && clearTimeout(timeout);
}, [loading, setShouldShowLoadingIndicator]);

// use currentData instead of data
// use shouldShowLoadingIndicator instead of loading

In this example, we now have a long request, after one second we want to show a loading indicator!

Almost done! Now we have a working proof of concept implementation. Let's convert that code into a reusable hook:

const useCurrent = (data, loading, showLoadingIndicatorThereshold = 300) => {
  const [currentData, setCurrentData] = React.useState(data);
  const previousLoadingRef = React.useRef(loading);
  const [
    shouldShowLoadingIndicator,
    setShouldShowLoadingIndicator
  ] = React.useState(loading);

  React.useEffect(() => {
    if (loading === false && data !== currentData) {
      setCurrentData(data);
    }
  }, [loading, data, setCurrentData, currentData]);

  React.useEffect(() => {
    let timeout = undefined;
    if (previousLoadingRef.current !== loading) {
      if (loading) {
        timeout = setTimeout(() => {
          setShouldShowLoadingIndicator(true);
        }, showLoadingIndicatorThereshold);
      } else {
        setShouldShowLoadingIndicator(false);
      }
    }
    previousLoadingRef.current = loading;
    return () => timeout && clearTimeout(timeout);
  }, [loading, setShouldShowLoadingIndicator, showLoadingIndicatorThereshold]);

  return [shouldShowLoadingIndicator, currentData];
};

Here's a usage example example

const { data, loading } = useFakeFetch(endpoint);
const [shouldShowLoadingIndicator, currentData] = useCurrent(
  data,
  loading,
  300
);

And of course, there is also a live example

We are done! This hook is now officially compatible with the following libraries:

  • react-apollo (with the HOC API)
  • react-fetch-hook
  • react-apollo-hooks
  • insert every hook data fetching library here

Let's make the web a better place for users by using this hook until React Suspense finally lands!

Bonus: Decrease amount of rerenders (possible performance optimization)

It is further possible to even decrease the amount of rerenders.

Open the console of the following example, wit until the initial data is loaded, then click the button Change endpoint button once.

The last output should be render 8. Which means 8 renders occurred during the whole process. We can reduce the total amount of necessary renders for this procedure to 6, having 2 less rerenders.

Before we optimize the example I want to mention, that we probably do not need to do such an optimization. However, when we have a large component tree and use this hook is used on top of it, you might feel the impact of the two additional rerenders!

Let's start with the currentData state. We do not need an actual useState hook for storing it. That's because of every time data or shouldShowLoadingIndicator changes a rerender is already triggered.

We can, therefore, store currentData using useRef. As a result, we can spare also the useEffect for updating currentData.

Furthermore, shouldShowLoadingIndicator is only changed under two conditions:

  • timeout was not cancelled (request duration was exceeded)
  • during useEffect when loading has changed and is now false

The latter is actually unnecessary, we can refactor shouldShowLoadingIndicatorR to a useRef as well. That's because shouldShowLoadingIndicator is automatically false when loading is false, which means we can update shouldShowLoadingIndicator at the same time currentData is updated.

But how do we update shouldShowLoadingIndicator to true? Mutating a ref is not triggering any rerenders. The solution is the introduction a new state which soles purpose is to trigger a rerender.

Let's take a look at the optimized version:

const useCurrent = (data, loading, showLoadingIndicatorThereshold = 300) => {
  const currentDataRef = React.useRef(data);
  const previousLoadingRef = React.useRef(loading);
  const shouldShowLoadingIndicatorRef = React.useRef(loading);
  const [, triggerStateUpdate] = React.useState(0);

  // those values should always reflect the input when loading is false 
  if (!loading) {
    currentDataRef.current = data;
    shouldShowLoadingIndicatorRef.current = false;
  }

  React.useEffect(() => {
    let timeout = undefined;
    // only when loading has changed from false to true
    if (previousLoadingRef.current !== loading && loading) {
      timeout = setTimeout(() => {
        // mutate ref
        shouldShowLoadingIndicatorRef.current = true;
        // trigger rerender
        triggerStateUpdate(i => i + 1);
      }, showLoadingIndicatorThereshold);
    }
    previousLoadingRef.current = loading;
    return () => timeout && clearTimeout(timeout);
  }, [loading, triggerStateUpdate, showLoadingIndicatorThereshold]);

  return [shouldShowLoadingIndicatorRef.current, currentDataRef.current];
};

Again, open the console in the following example! Wait until the initial loading has happened, click the button and observer the console logs.

We have successfully removed two unnecessary rerenders!

I hope you enjoyed reading this blog post!

I wanna thank @mfpiccolo and @sseraphini which helped my by proofreading this article!

This is only the first of many blog posts about utility hooks and other topics (including react and GraphQL).

Stay updated by following me here on DEV or Twitter and Github.

Homebrew React Hooks (3 Part Series)

1) Homebrew React Hooks: useCurrent 2) Homebrew React Hooks: useAsyncEffect Or How to Handle Async Operations with useEffect 3) Homebrew React Hooks: useIsKeyPressed

Posted on by:

Discussion

markdown guide