An Interest In:
Web News this Week
- April 24, 2024
- April 23, 2024
- April 22, 2024
- April 21, 2024
- April 20, 2024
- April 19, 2024
- April 18, 2024
Hacker News Clone using React Hooks
Introduction
This is the continuation of the multi-part series. If you missed the previous parts, then you can check them out here : part1, part 2 and part 3.
In this final part, we will build a Hacker News clone using all the things we have learned in the previous 3 parts.
We will be using React Hooks for building this application. So If you're new to React Hooks, check out my this article for the introduction to Hooks.
So let's get started.
API Introduction
We will be using the Hackernews API from here.
API to get top stories: https://hacker-news.firebaseio.com/v0/topstories.json?print=pretty
API to get new stories: https://hacker-news.firebaseio.com/v0/newstories.json?print=pretty
API to get best stories: https://hacker-news.firebaseio.com/v0/beststories.json?print=pretty
Each of the above story API returns only an array of ids representing a story.
So to get the details of that particular story we need to make another API call.
API to get story details: https://hacker-news.firebaseio.com/v0/item/story_id.json?print=pretty
Initial setup
Create a new project using create-react-app
:
create-react-app hackernews-clone
Once the project is created, delete all files from the src
folder and create index.js
and styles.scss
files inside the src
folder. Also, create components
, hooks
, router
, utils
folders inside the src
folder.
Install the required dependencies:
yarn add axios@0.21.0 bootstrap@4.5.3 node-sass@4.14.1 react-bootstrap@1.4.0 react-router-dom@5.2.0
Open styles.scss
and add the contents from here inside it.
Creating Initial Pages
Create a new file Header.js
inside the components
folder with the following content:
import React from 'react';import { NavLink } from 'react-router-dom';const Header = () => { return ( <React.Fragment> <h1>Hacker News Clone</h1> <div className="nav-link"> <NavLink to="/top" activeClassName="active"> Top Stories </NavLink> <NavLink to="/new" activeClassName="active"> Latest Stories </NavLink> <NavLink to="/best" activeClassName="active"> Best Stories </NavLink> </div> </React.Fragment> );};export default Header;
In this file, we have added a navigation menu to see the different type of stories. Each link has added a class of active
so when we click on that link, it will be highlighted indicating which page we are on.
Create a new file HomePage.js
inside the components
folder with the following content:
import React from 'react';const HomePage = () => { return <React.Fragment>Home Page</React.Fragment>;};export default HomePage;
Create a new file PageNotFound.js
inside the components
folder with the following content:
import React from 'react';import { Link } from 'react-router-dom';const PageNotFound = () => { return ( <p> Page Not found. Go to <Link to="/">Home</Link> </p> );};export default PageNotFound;
Create a new file AppRouter.js
inside the router
folder with the following content:
import React from 'react';import { BrowserRouter, Route, Switch } from 'react-router-dom';import Header from '../components/Header';import HomePage from '../components/HomePage';import PageNotFound from '../components/PageNotFound';const AppRouter = () => { return ( <BrowserRouter> <div className="container"> <Header /> <Switch> <Route path="/" component={HomePage} exact={true} /> <Route component={PageNotFound} /> </Switch> </div> </BrowserRouter> );};export default AppRouter;
In this file, initially, we have added two routes for the routing, one for the home page and the other for the invalid route.
Now, open src/index.js
file and add the following contents inside it:
import React from 'react';import ReactDOM from 'react-dom';import AppRouter from './router/AppRouter';import 'bootstrap/dist/css/bootstrap.min.css';import './styles.scss';ReactDOM.render(<AppRouter />, document.getElementById('root'));
Now, start the application by running yarn start
command and you will see the following screen
Now, inside the utils
folder create a new file constants.js
with the following content:
export const BASE_API_URL = 'https://hacker-news.firebaseio.com/v0';
Create another file with the name apis.js
inside the utils
folder with the following content:
import axios from 'axios';import { BASE_API_URL } from './constants';const getStory = async (id) => { const story = await axios.get(`${BASE_API_URL}/item/${id}.json`); return story;};export const getStories = async (type) => { const { data: storyIds } = await axios.get( `${BASE_API_URL}/${type}stories.json` ); const stories = await Promise.all( storyIds.slice(0, 25).map((storyId) => getStory(storyId)) ); return stories;};
In this file, for the getStories
function we're passing the type of story we want(top, new, or best) and then we're making an API call the respective .json URL provided at the start of this article.
Note that, we have declared the function as async so we have used the await keyword to call the API.
const { data: storyIds } = await axios.get( `${BASE_API_URL}/${type}stories.json` );
As axios
library always returns the result in the .data
property of the response, we're taking out that property and renaming it to storyIds because the API returns an array of story IDs.
If you're not familiar with this destructuring syntax, then check out my previous article here for an introduction to destructuring.
As we're getting an array of storyIds back, instead of making separate API call for each ID and then waiting for other, we're using Promise.all
method to make API call simultaneously for all the StoryIds.
const stories = await Promise.all( storyIds.slice(0, 25).map((storyId) => getStory(storyId)) );
Here, we're using the Array slice method to take only the first 25 storyIds so the data will load faster.
Then we're using the Array map method to call the getStory
function to make API call to the individual story item by passing the storyId to it.
For the arrow function passed to the Array map method, each element of the array is automatically passed so we can further simplify the code to this:
const stories = await Promise.all(storyIds.slice(0, 25).map(getStory));
Once we have the stories available we're returning that back from the getStories
function.
Create a new file dataFetcher.js
inside the hooks
folder with the following content:
import { useState, useEffect } from 'react';import { getStories } from '../utils/apis';const useDataFetcher = (type) => { const [stories, setStories] = useState([]); const [isLoading, setIsLoading] = useState(false); useEffect(() => { setIsLoading(true); getStories(type).then((stories) => { setStories(stories); setIsLoading(false); }); }, [type]); return { isLoading, stories };};export default useDataFetcher;
In this file, we have declared a custom hook useDataFetcher
that take the type of story as parameter and calls the getStories
function defined in apis.js
file inside the useEffect
hook.
We have added two states here using useState
hook. Before making the API call, we're setting the isLoading
state to true and once we got the complete response, we're setting it to false.
Once the response is received we're setting the stories array with the response from the API and we're returning the isLoading
and stories
from the hook in an object so any component using this hook will be able to get the updated value of these state values.
Also, note that we have added type
as a dependency to the useEffect
hook as a second parameter inside the array so whenever we click on the navigation menu, the type will change and this useEffect
hook will run again to make an API call to get the stories related to that type.
If you remember, inside the apis.js
file the getStories
function is declared as async
so it will always return a promise. So we have added .then
handler to the getStories
function to get the actual data from the response inside the useEffect
hook.
Create a new file ShowStories.js
inside the components
folder with the following content:
import React from 'react';import Story from './Story';import useDataFetcher from '../hooks/dataFetcher';const ShowStories = (props) => { const { type } = props.match.params; const { isLoading, stories } = useDataFetcher(type); return ( <React.Fragment> {isLoading ? ( <p>Loading...</p> ) : ( <React.Fragment> {stories.map(({ data: story }) => ( <Story key={story.id} story={story} /> ))} </React.Fragment> )} </React.Fragment> );};export default ShowStories;
In this file, we're using the useDataFetcher
custom hook inside the component, and based on the isLoading
flag we're either displaying the Loading
message or the list of stories by using the Array map method for each individual story.
Create a new file Story.js
inside the components
folder with the following content:
import React from 'react';const Link = ({ url, title }) => ( <a href={url} target="_blank" rel="noreferrer"> {title} </a>);const Story = ({ story: { id, by, title, kids, time, url } }) => { return ( <div className="story"> <div className="story-title"> <Link url={url} title={title} /> </div> <div className="story-info"> <span> by{' '} <Link url={`https://news.ycombinator.com/user?id=${by}`} title={by} /> </span> | <span> {new Date(time * 1000).toLocaleDateString('en-US', { hour: 'numeric', minute: 'numeric' })} </span> | <span> <Link url={`https://news.ycombinator.com/item?id=${id}`} title={`${kids && kids.length > 0 ? kids.length : 0} comments`} /> </span> </div> </div> );};export default Story;
In this file, we're displaying the individual story. In the API response, we're getting time of the story in seconds so we're multiplying it by 1000 to convert it to milliseconds so we can display the correct date.
Now, open AppRouter.js
file and add another Route for the ShowStories
component before the PageNotFound
Route.
<Switch> <Route path="/" component={HomePage} exact={true} /> <Route path="/:type" component={ShowStories} /> <Route component={PageNotFound} /></Switch>
Now, restart the app by running yarn start
command and verify the application.
As you can see the application is loading the top, latest and best stories from the HackerNews API correctly.
If you remember, we added the HomePage
component so we can display something when the application loads but now we actually don't need the HomePage
component because we can show the top stories page when the application loads.
So open AppRouter.js
file and change the first two routes from the below code:
<Route path="/" component={HomePage} exact={true} /><Route path="/:type" component={ShowStories} />
to this code:
<Route path="/" render={() => <Redirect to="/top" />} exact={true} /><Route path="/:type" render={({ match }) => { const { type } = match.params; if (['top', 'new', 'best'].indexOf(type) === -1) { return <Redirect to="/" />; } return <ShowStories type={type} />; }}/>
In the first Route, when we load the application by visiting http://localhost:3000/
, we're redirecting the user to the /top
route.
<Route path="/" render={() => <Redirect to="/top" />} exact={true} />
Here, we're using the render props pattern so instead of providing a component, we're using a prop with the name render where we can write the component code directly inside the function.
Next, we have added a /:type
route
<Route path="/:type" render={({ match }) => { const { type } = match.params; if (['top', 'new', 'best'].indexOf(type) === -1) { return <Redirect to="/" />; } return <ShowStories type={type} />; }}/>
Here, If the route matches with /top
or /new
or /best
then we're showing the user the ShowStories
component and If the user enters some invalid value for a route like /something
we will redirect the user again to the /top
route which will render the ShowStories
component.
By default, the React router passes some props to each component mentioned in the <Route />
. One of them is match
so props.match.params
will contain the actual passed value for the type.
So when we access http://localhost:3000/top
, props.match.params
will contain the value top
and when we access http://localhost:3000/new
, props.match.params
will contain the value new
and so on.
For the render prop function, we're using destructuring to get the match
property of props object by using the following syntax
render={({ match }) => { }
which is the same as
render={(props) => { const { match } = props; }
Also, don't forget to import the Redirect
component from the react-router-dom
package at the top of the file
import { BrowserRouter, Redirect, Route, Switch } from 'react-router-dom';
Now, open the ShowStories.js
file and change the below code:
const ShowStories = (props) => { const { type } = props.match.params; const { isLoading, stories } = useDataFetcher(type);
to this code:
const ShowStories = ({ type }) => { const { isLoading, stories } = useDataFetcher(type ? type : 'top');
Here, we're taking the type
prop passed from the AppRouter
component to the useDataFetcher
custom hook which will render the correct type of data, based on the type
passed.
Now, we have added redirection code to automatically redirect to the /top
route on application load and the invalid route also redirects to the /top
route.
But while the data is loading we're showing a simple loading message and while the data is loading user can click on another link to make additional requests to the server which is not good.
So let's add the loading message with an overlay to the screen so the user will not be able to click anywhere while the data is loading.
Create a new file Loader.js
inside the components
folder with the following content:
import { useState, useEffect } from 'react';import ReactDOM from 'react-dom';const Loader = (props) => { const [node] = useState(document.createElement('div')); const loader = document.querySelector('#loader'); useEffect(() => { loader.appendChild(node).classList.add('message'); }, [loader, node]); useEffect(() => { if (props.show) { loader.classList.remove('hide'); document.body.classList.add('loader-open'); } else { loader.classList.add('hide'); document.body.classList.remove('loader-open'); } }, [loader, props.show]); return ReactDOM.createPortal(props.children, node);};export default Loader;
Now open public/index.html
file and alongside the div with id root
add another div with id loader
<div id="root"></div><div id="loader"></div>
The ReactDOM.createPortal
method which we have used in Loader.js
will create a loader inside the div with id loader
so it will be outside out React
application DOM hierarchy and hence we can use it to provide an overlay for our entire application. This is the primary reason for using the React Portal
for creating a loader.
So even if we will include the Loader
component in ShowStories.js
file, it will be rendered outside all the divs but inside the div with id loader
.
In the Loader.js
file, we have first created a div where we will add a loader message
const [node] = useState(document.createElement('div'));
Then, we are adding the message
class to that div and finally adding that div to the div added in index.html
document.querySelector('#loader').appendChild(node).classList.add('message');
and based on the show
prop passed from the ShowStories
component, we will add or remove the hide
class, and then finally we will render the Loader
component using
ReactDOM.createPortal(props.children, node);
Then we add or remove the loader-open
class to the body tag of the page which will disable or enable the scrolling of the page
document.body.classList.add('loader-open');document.body.classList.remove('loader-open');
The data we will pass in between the opening and closing Loader
tag inside the ShowStories
component will be available inside props.children
so we can display a simple loading message or we can include an image to be shown as a loader.
Now, lets use this component.
Open ShowStories.js
file and replace its contents with the following content:
import React from 'react';import Story from './Story';import useDataFetcher from '../hooks/dataFetcher';import Loader from './Loader';const ShowStories = (props) => { const { type } = props.match.params; const { isLoading, stories } = useDataFetcher(type); return ( <React.Fragment> <Loader show={isLoading}>Loading...</Loader> <React.Fragment> {stories.map(({ data: story }) => ( <Story key={story.id} story={story} /> ))} </React.Fragment> </React.Fragment> );};export default ShowStories;
Here, we're using the Loader component by passing the show prop to it.
<Loader show={isLoading}>Loading...</Loader>
Now, If you check the application, you will see the loading overlay
Now, we're done with the complete application functionality.
For each story, we're showing the author and the total comments as hyperlinks, and clicking on them takes us to the hackernews website to show the respective details as can be seen in the below gif.
Conclusion
This was the final part of the multi-part series. The purpose of this article was just to make you aware of how we can use the promises, async/await and their Promise methods to create a amazing application.
So I have not included every feature of the hackernews website but just the basic functionality.
You can further improve the application by adding extra functionalities like:
- Add pagination functionality to load the next 25 records for each page
- Display a separate page for displaying the comments using the Hacker News API when clicked on the comments count link instead of redirecting the user to the hackernews website
You can find complete GitHub source code for this application here and live demo of the application here.
Don't forget to subscribe to get my weekly newsletter with amazing tips, tricks and articles directly in your inbox here.
Original Link: https://dev.to/myogeshchavan97/hacker-news-clone-using-react-hooks-1j17
Dev To
An online community for sharing and discovering great ideas, having debates, and making friendsMore About this Source Visit Dev To