How to implement Caching for Hacker News App in React

Yogesh Chavan - Feb 18 '21 - - Dev Community

In this article, we will implement a caching mechanism for the Hacker News Clone App which is explained in detail in this freeCodeCamp article.

You can find the complete GitHub source code for the Hacker News Clone App in this repository.

In this article, you will learn:

  • How to implement caching in React
  • How to approach fixing the bugs
  • How to change the API response data
  • ES6 destructuring syntax changes

and much more.

So let's get started.

Need of implementing caching

If you check the application live demo, you will notice that when we click on any of the top stories, latest stories or best stories link in the navigation, we're showing a loading message for some time while the response is coming from the API and once we receive the response, we're hiding the loading message and showing the response data.

Hacker News Clone demo

The application is working correctly and there is no issue with it. But we can improve it further by adding caching functionality.

When we first time clicks on any of the navigation links, we're loading the list of the first 30 news from the Hacker News API related to that type of story(top, latest or best) as shown below inside the utils/apis.js file.

export const getStories = async (type) => {
  try {
    const { data: storyIds } = await axios.get(
      `${BASE_API_URL}/${type}stories.json`
    );
    const stories = await Promise.all(storyIds.slice(0, 30).map(getStory));
    return stories;
  } catch (error) {
    console.log('Error while getting list of stories.');
  }
};
Enter fullscreen mode Exit fullscreen mode

But If we again click on any other story type(top, latest or best), we again get the loading message as the API call is made again because inside the useDataFetcher.js file, we have added a useEffect hook which makes API call every time the type changes.

If the API data is frequently changing within seconds then there is no need of implementing caching which we're about to see because we always want up-to-date data.

But in our Hacker News API, the data does not change quite frequently and it may not be a good user experience to load the data, again and again, every time we click on any type as the user have to wait for the response to come before it gets displayed.

Implementing Caching

We can fix this issue by caching the data once we receive it from the API. So the next time we click on any of the navigation links, we check If the data is already present in the cache(state in React) and make the API call only if it's not present otherwise we will load the same data which is present in the state.

To get started clone the repository code from this URL.

Once cloned, install the npm dependencies by executing the yarn install command from the terminal/command prompt and start the application by executing the yarn start command.

Now, If you open the hooks/dataFetcher.js file, you will see that we're storing the list of stories coming from the API in a state with the name stories as shown below:

const [stories, setStories] = useState([]);
...

useEffect(() => { 
  ...
 setStories(stories);
 ...
});
Enter fullscreen mode Exit fullscreen mode

So every time the response comes from the API, we're updating the stories array with that data.

Instead of storing the stories in an array, we will store them in an object in the following format:

const [stories, setStories] = useState({
   top: [],
   new: [],
   best: []
});
Enter fullscreen mode Exit fullscreen mode

So stores.top will contain the top stories, stories.new will contain the latest stories and stories.best will contain the best stories.

To start with, we will initialize the stories array with an empty object like this:

const [stories, setStories] = useState({});
Enter fullscreen mode Exit fullscreen mode

Now, replace your useEffect hook with the following code:

useEffect(() => {
  if (!stories[type]) {
    setIsLoading(true);
    getStories(type)
      .then((stories) => {
        console.log('stories', stories);
        setIsLoading(false);
      })
      .catch(() => {
        setIsLoading(false);
      });
  }
}, [type]);
Enter fullscreen mode Exit fullscreen mode

In the above code, we have added an if condition, so only when there is no already loaded top, new or best story inside the stories object, we will make an API call.

!stories[type]) is same as saying stories[type] does not exist or is null or undefined.

We also added a console.log statement once inside the .then handler so we can check how the stories array looks like.

Remember that we initially set the stories state to empty object {} so initially there will not be any top, new or best stories inside the stories object.

And now, instead of exporting story from the hook, we need to export the selected type of the story as story is an object now and story[type] is an array.

So change the below code:

return { isLoading, stories };
Enter fullscreen mode Exit fullscreen mode

to this code:

return { isLoading, stories: stories[type] };
Enter fullscreen mode Exit fullscreen mode

Your entire dataFetcher.js file will look like this now:

import { useState, useEffect } from 'react';
import { getStories } from '../utils/apis';

const useDataFetcher = (type) => {
  const [stories, setStories] = useState({});
  const [isLoading, setIsLoading] = useState(false);

  useEffect(() => {
    if (!stories[type]) {
      setIsLoading(true);
      getStories(type)
        .then((stories) => {
          console.log('stories', stories);
          setIsLoading(false);
        })
        .catch(() => {
          setIsLoading(false);
        });
    }
  }, [type]);

  return { isLoading, stories: stories[type] };
};

export default useDataFetcher;
Enter fullscreen mode Exit fullscreen mode

Now, If you run the application by executing yarn start command, you will see the following screen:

Stories Error

We're getting the error in the ShowStories.js file where we're using the map method. This is because, initially when the application is loaded, the stories state in the useDataFetcher.js file is an empty object and so stories[type] will be undefined.

Therefore when we use the stories.map method, it produces an error because map can be used only for arrays and not for undefined.

So to fix this, we need to initialize the stories to be an empty array in the ShowStories.js file.

Therefore, change the below code:

const { isLoading, stories } = useDataFetcher(type ? type : 'top');
Enter fullscreen mode Exit fullscreen mode

to this code:

const { isLoading, stories = [] } = useDataFetcher(type ? type : 'top');
Enter fullscreen mode Exit fullscreen mode

Here we're using ES6 destructuring syntax for assigning a default value of an empty array to the stories variable.

So as stories is an empty array initially, stories.map will not give an error.

Now, If you check the application, you will see the following screen:

Stories log

As we have added the console.log statement inside the dataFetcher.js file at line 13, you can see the list of stories we got from the API response.

Now, we got the stories from the API, we need to call the setStories function to set the stories inside the .then handler of the dataFetcher.js file so we can see the list of stories on the screen.

If you remember, our stories object will look like this once its populated with stories:

const [stories, setStories] = useState({
   top: [],
   new: [],
   best: []
});
Enter fullscreen mode Exit fullscreen mode

And as in React Hooks, in the case of the object, the state is not merged automatically but we need to manually merge it. Check out my this article to understand it better.

So inside the dataFetcher.js file, replace the console.log statement with the following code:

setStories((prevState) => {
  return {
    ...prevState,
    [type]: stories
  };
});
Enter fullscreen mode Exit fullscreen mode

Here, we're using the updater syntax of setState along with the ES6 dynamic key syntax for the object, so we're first spreading out the stories object and then adding the selected type with the stories array.

As we're returning just an object from the function, we can further simplify it to the below code where we're implicitly returning the object from the function:

setStories((prevState) => ({
  ...prevState,
  [type]: stories
}));
Enter fullscreen mode Exit fullscreen mode

Your entire dataFetcher.js file will look like this now:

import { useState, useEffect } from 'react';
import { getStories } from '../utils/apis';

const useDataFetcher = (type) => {
  const [stories, setStories] = useState({});
  const [isLoading, setIsLoading] = useState(false);

  useEffect(() => {
    if (!stories[type]) {
      setIsLoading(true);
      getStories(type)
        .then((stories) => {
          setStories((prevState) => ({
            ...prevState,
            [type]: stories
          }));
          setIsLoading(false);
        })
        .catch(() => {
          setIsLoading(false);
        });
    }
  }, [type]);

  return { isLoading, stories: stories[type] };
};

export default useDataFetcher;
Enter fullscreen mode Exit fullscreen mode

Now, If you check the application, you will see the following screen:

Instant Loading

As you can see in the above gif, when we first time clicks on the top, latest or best stories link, we get the loading message but once the content is loaded, the second time we click any of the links, the data is loaded instantly without the loading message because we're not making an API call as data is already present in the state because the data was already loaded in the first time click.

That's a great improvement to the application and with this, we're done with adding caching functionality to the application.

One thing you should remember is to use this caching technique only if the API data is not frequently changing otherwise you might see the old data until you refresh the page because we're not making an API call to get the updated data If the data is already loaded once.

Couple of optional code improvements

As seen previously, our stories array looks like this:

Stories log

Each array element is an object with properties like config, data, headers, request etc.
Out of these, only the data property is usable property. We're getting these extra properties because we're directly returning the story from the getStory function.

const getStory = async (id) => {
  try {
    const story = await axios.get(`${BASE_API_URL}/item/${id}.json`);
    return story;
  } catch (error) {
    console.log('Error while getting a story.');
  }
};
Enter fullscreen mode Exit fullscreen mode

But the Axios library gives an actual response only in the story.data property. So we can modify the code to just return the data property from the getStory function.

const getStory = async (id) => {
  try {
    const story = await axios.get(`${BASE_API_URL}/item/${id}.json`);
    return story.data;
  } catch (error) {
    console.log('Error while getting a story.');
  }
};
Enter fullscreen mode Exit fullscreen mode

We can further simplify it as shown below:

const getStory = async (id) => {
  try {
    const { data } = await axios.get(`${BASE_API_URL}/item/${id}.json`);
    return data;
  } catch (error) {
    console.log('Error while getting a story.');
  }
};
Enter fullscreen mode Exit fullscreen mode

Here, we're using destructuring to extract the data property of the response and return that from the function.

Also, add the console.log statement back inside the .then handler of dataFetcher.js file:

useEffect(() => { 
  ...
  .then((stories) => {
     console.log('stories', stories);
 ...
});
Enter fullscreen mode Exit fullscreen mode

Now, If you check the application, you will see the following screen:

Direct data

As you can see, now we're getting direct data inside each element of the array as opposed to the object seen previously.

But we also get an error saying Cannot read property 'id' of undefined inside the ShowStories.js file.

This is because we're using the array map method inside the ShowStories.js file like this:

{stories.map(({ data: story }) => (
  story && <Story key={story.id} story={story} />
))}
Enter fullscreen mode Exit fullscreen mode

Previously, each array element was an object containing the data property so it was working fine as we were destructuring the data property and renaming it to story.

Now, we have the contents of the data object directly inside each array element so we need to change the above code to the below code:

{stories.map((story) => (
  story && <Story key={story.id} story={story} />
))}
Enter fullscreen mode Exit fullscreen mode

You can name the callback function variable to anything you like, I have named it story here.

Now, after making this change, If you check the application, you will see that the application is working fine as before without any issue.

Instant Loading

That's it about this article. I hope you learned something new today.

Closing points

You can find the complete GitHub source code for this article, in this repository, and a live demo here.

Want to learn all ES6+ features in detail including let and const, promises, various promise methods, array and object destructuring, arrow functions, async/await, import and export and a whole lot more?

Check out my Mastering Modern JavaScript book. This book covers all the pre-requisites for learning React and helps you to become better at JavaScript and React.

Also, check out my free Introduction to React Router course to learn React Router from scratch.

Want to stay up to date with regular content regarding JavaScript, React, Node.js? Follow me on LinkedIn.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .