loading...
Cover image for Hacker News Clone using React Hooks

Hacker News Clone using React Hooks

myogeshchavan97 profile image Yogesh Chavan ・Updated on ・12 min read

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
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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'));
Enter fullscreen mode Exit fullscreen mode

Now, start the application by running yarn start command and you will see the following screen

Initial 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';
Enter fullscreen mode Exit fullscreen mode

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) => {
  try {
    const story = await axios.get(`${BASE_API_URL}/item/${id}.json`);
    return story;
  } catch (error) {
    throw new Error('Error while loading data. Try again later.');
  }
};

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, 25).map(getStory));
    return stories;
  } catch (error) {
    throw new Error('Error while loading data. Try again later.');
  }
};
Enter fullscreen mode Exit fullscreen mode

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`
  );
Enter fullscreen mode Exit fullscreen mode

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))
  );
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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 [errorMsg, setErrorMsg] = useState('');
  const [isLoading, setIsLoading] = useState(false);

  useEffect(() => {
    setIsLoading(true);
    getStories(type)
      .then((stories) => {
        setStories(stories);
        setErrorMsg('');
        setIsLoading(false);
      })
      .catch((error) => {
        setErrorMsg(error.message);
        setIsLoading(false);
      });
  }, [type]);

  return { isLoading, errorMsg, stories };
};

export default useDataFetcher;
Enter fullscreen mode Exit fullscreen mode

In this file, we have declared a custom hook useDataFetcher that takes the type of story as parameter and calls the getStories function defined in apis.js file inside the useEffect hook.

We have added three 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.

If there is any error while making API call, we're setting the error message state inside the .catch handler and clearing it, if there is no error message inside the .then handler.

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, errorMsg, 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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

Now, restart the app by running yarn start command and verify the application.

Loading News

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} />
Enter fullscreen mode Exit fullscreen mode

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} />;
  }}
/>
Enter fullscreen mode Exit fullscreen mode

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} />
Enter fullscreen mode Exit fullscreen mode

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} />;
  }}
/>
Enter fullscreen mode Exit fullscreen mode

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 }) => {
  }
Enter fullscreen mode Exit fullscreen mode

which is the same as

   render={(props) => {
    const { match } = props;
   }
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

Now, open the ShowStories.js file and change the below code:

const ShowStories = (props) => {
  const { type } = props.match.params;
  const { isLoading, errorMsg, stories } = useDataFetcher(type);
Enter fullscreen mode Exit fullscreen mode

to this code:

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

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;
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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'));
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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, let’s 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, errorMsg, 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;
Enter fullscreen mode Exit fullscreen mode

Here, we're using the Loader component by passing the show prop to it.

<Loader show={isLoading}>Loading...</Loader>
Enter fullscreen mode Exit fullscreen mode

Now, we have added the Loader, let's display the error message if there is an error while making an API call.

import React from 'react';
import Story from './Story';
import useDataFetcher from '../hooks/dataFetcher';
import Loader from './Loader';

const ShowStories = ({ type }) => {
  const { isLoading, errorMsg, stories } = useDataFetcher(type ? type : 'top');

  return (
    <React.Fragment>
      <Loader show={isLoading}>Loading...</Loader>
      <React.Fragment>
        {errorMsg ? (
          <p className="errorMsg">{errorMsg}</p>
        ) : (
          stories.map(({ data: story }) => (
            <Story key={story.id} story={story} />
          ))
        )}
      </React.Fragment>
    </React.Fragment>
  );
};

export default ShowStories;
Enter fullscreen mode Exit fullscreen mode

Here, If there is an error, we're displaying it otherwise we're displaying the list of stories.

Now, If you check the application, you will see the loading overlay

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.

Working hyperlinks

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.

Discussion

pic
Editor guide
Collapse
nomoredeps profile image
NoMoreDeps

It seems to have a 404 on part 1

Collapse
myogeshchavan97 profile image
Yogesh Chavan Author

Thanks for pointing that out. I have updated the link. Please check it now

Collapse
nomoredeps profile image
NoMoreDeps

Back to normal. Thanks

Collapse
pbteja1998 profile image
Bhanu Teja Pachipulusu

Even I made a HackerNews clone very recently. I used Next.js + Tailwind + React Query.

Here is the corresponding blog post:
blog.bhanuteja.dev/how-i-gave-a-mo...

Collapse
myogeshchavan97 profile image
Yogesh Chavan Author

Amazing UI πŸ‘ŒπŸ‘Œ Loved the scrolling effect on pagination πŸ‘

Collapse
armujahid profile image
Abdul Rauf

I also developed HN clone using React 2 years ago. github.com/armujahid/hnreact that is live at hn.armujahid.me/ . Its next version will be using Next.js

Collapse
myogeshchavan97 profile image
Yogesh Chavan Author

It really looks amazing 😍

Some comments have been hidden by the post's author - find out more