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.
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.');
}
};
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);
...
});
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: []
});
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({});
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]);
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 anytop
,new
orbest
stories inside thestories
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 };
to this code:
return { isLoading, stories: stories[type] };
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;
Now, If you run the application by executing yarn start
command, you will see the following screen:
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');
to this code:
const { isLoading, stories = [] } = useDataFetcher(type ? type : 'top');
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:
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: []
});
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
};
});
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
}));
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;
Now, If you check the application, you will see the following screen:
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:
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.');
}
};
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.');
}
};
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.');
}
};
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);
...
});
Now, If you check the application, you will see the following screen:
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} />
))}
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} />
))}
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.
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.