In this tutorial, we will build a mini Hacker News clone in React.

We will be using React Hooks syntax for building this application. So if you're new to React Hooks, check out my Introduction to React Hooks article to learn the basics of Hooks.

So let's get started.

Introduction to the API

We will be using the Hackernews API from this url.

API to get top stories, use this URL: https://hacker-news.firebaseio.com/v0/topstories.json?print=pretty

API to get new stories, use this URL: https://hacker-news.firebaseio.com/v0/newstories.json?print=pretty

API to get best stories, use this URL: https://hacker-news.firebaseio.com/v0/beststories.json?print=pretty

Each of the above stories 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, use this URL: https://hacker-news.firebaseio.com/v0/item/story_id.json?print=pretty

For example: https://hacker-news.firebaseio.com/v0/item/26061935.json?print=pretty

How to Set Up the Project

Create a new project using create-react-app:

npx create-react-app hackernews-clone-react-app

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 like this:

yarn add axios@0.21.0 bootstrap@4.6.0 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.

We'll use SCSS syntax to write CSS. So if you're new to SCSS, check out my article here for an introduction to it.

How to Create the 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 types of stories. Each link has added a class of active. So when we click on that link it will be highlighted, indicating which route 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 invalid routes.

If you're new to React Router, check out my free Introduction to React Router course.

Now, open the 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 the yarn start command and you will see the following screen:

Initial Screen

API Integration

Now, inside the utils folder create a new file called 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) => {
  try {
    const story = await axios.get(`${BASE_API_URL}/item/${id}.json`);
    return story;
  } catch (error) {
    console.log('Error while getting a story.');
  }
};

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.');
  }
};

In this file, for the getStories function we pass the type of story we want (top, new or best). Then we make an API call to the respective .json URL provided at the start of this article.

Note that we have declared the function as async so we can use the await keyword to call the API and wait for the response to come.

const { data: storyIds } = await axios.get(
  `${BASE_API_URL}/${type}stories.json`
);

As the axios library always returns the result in the .data property of the response, we take out that property and rename it to storyIds because the API returns an array of story IDs.

Here, we use the ES6 destructuring syntax for renaming the data property to storyIds. This makes it easy to understand what storyIds contains rather than naming it data.

Note that the above code is the same as the below code:

const response = await axios.get(
  `${BASE_API_URL}/${type}stories.json`
);
const storyIds = response.data;

Since we get an array of story IDs back, instead of making separate API calls for each id and then waiting for the previous one to finish, we use the Promise.all method to make API calls simultaneously for all the story ids.

const stories = await Promise.all(
  storyIds.slice(0, 30).map((storyId) => getStory(storyId))
);

Here, we use the Array slice method to take only the first 30 story ids so the data will load faster.

Then we use the Array map method to call the getStory function to make an API call to the individual story item by passing the storyId to it.

As in the map function, we just take the storyId and pass it to the getStory function. We can simplify it to the following code:

const stories = await Promise.all(storyIds.slice(0, 30).map(getStory));

So the storyId will be automatically passed to the getStory function.

Inside the getStory function, we use ES6 template literal syntax to create a dynamic URL based on the passed id for making an API call.

And once we have the stories available, we return that back from the getStories function.

How to Create the Data Fetcher

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);
      })
      .catch(() => {
        setIsLoading(false);
      });
  }, [type]);

  return { isLoading, stories };
};

export default useDataFetcher;

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

We have added two state variables here using the useState hook, namely stories and isLoading. Before making the API call, we set the isLoading state to true. Once we get the complete response, we set it to false.

We also set the isLoading state to false inside the catch block so if there is an error, the loader will be hidden.

Once the response is received, we set the stories array with the response from the API and we return the isLoading and stories from the hook in an object. This means that 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 (for top, latest or best stories), 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. Therefore, we have added the .then handler to the getStories function to get the actual data from the response inside the useEffect hook inside the dataFetcher.js file like this:

getStories(type)
      .then((stories) => {
      ...

How to Display Data in the UI

Now, create a new file called 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 className="loading">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 use the useDataFetcher custom hook inside the component. Based on the isLoading flag, we either display 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 display the individual story.

For defining the Link component, we use the ES6 arrow function shorthand syntax of implicit return.

So the below code:

const Link = ({ url, title }) => (
  <a href={url} target="_blank" rel="noreferrer">
    {title}
  </a>
);

is the same as this code:

const Link = ({ url, title }) => {
  return (
    <a href={url} target="_blank" rel="noreferrer">
     {title}
    </a>
  );
}

In an arrow function, if there is a single line statement then we can skip the curly brackets and return keyword.

So the below code:

const add = (a,b) => a + b;

is the same as this code:

const add = (a,b) => {
  return a + b;
}

But to make the JSX look neat and like a single line statement, we add the extra round brackets while defining the Link component.

Next, for the Story component, we have defined it like this:

const Story = ({ story: { id, by, title, kids, time, url } }) => {
  // some code
}

Here, we use ES6 destructuring syntax to get the properties of the story object which was passed from the ShowStories component.

So the above code is the same as the below code:

const Story = (props) => {
  const { id, by, title, kids, time, url } = props.story;
  // some code
}

which is the same as the below code:

const Story = ({ story }) => {
  const { id, by, title, kids, time, url } = story;
  // some code
}

In the API response, we get the time of the story in seconds. So in the Story component, we multiply it by 1000 to convert it to milliseconds so we can display the correct date in proper format using JavaScript's toLocaleDateString method:

{new Date(time * 1000).toLocaleDateString('en-US', {
  hour: 'numeric',
  minute: 'numeric'
})}

Now, open the 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>

Also, add an import for the ShowStories component at the top:

import ShowStories from '../components/ShowStories';

Now, restart the app by running the 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.

How to Handle Dynamic Redirection

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 the 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'].includes(type)) {
       return <Redirect to="/" />;
    }
    return <ShowStories type={type} />;
  }}
/>

In the first Route, when we load the application by visiting http://localhost:3000/, we redirect the user to the /top route.

<Route path="/" render={() => <Redirect to="/top" />} exact={true} />

Here, we use the render props pattern. So instead of providing a component, we use a prop with the name render where we can write the component code directly inside the function.

To know why we use render instead of component prop and what problem it solves, check out my free Introduction to React Router course.

Next, we have added a /:type route:

<Route
  path="/:type"
  render={({ match }) => {
    const { type } = match.params;
    if (!['top', 'new', 'best'].includes(type)) {
      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. 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 with top stories..

We use the ES7 Array includes method in the above code inside the if condition.

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.

Therefore, when we access http://localhost:3000/top, props.match.params will contain the value top. When we access http://localhost:3000/new, props.match.params will contain the value new and so on.

For the render prop function, we use destructuring to get the match property of the 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 AppRouter.js 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 passing the type prop passed from the AppRouter component to the useDataFetcher custom hook. This will render the correct type of data, based on the type passed.

How to Add a Loading Overlay

Now, we have added redirection code to automatically redirect to the /top route on application load. The invalid route also redirects to the /top route.

But when the data is loading, we show a simple loading message. While the data is loading, the 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, like this:

<div id="root"></div>
<div id="loader"></div>

The ReactDOM.createPortal method which we have used in Loader.js will insert the loader inside the div with id loader so it will be outside our React application DOM hierarchy. This means that 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 include the Loader component in the 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 add the message class to that div and finally add that div to the loader 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. Then finally we will render the Loader component using this:

ReactDOM.createPortal(props.children, node);

Then we're adding or removing the loader-open class from 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 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, 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 use 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:

Loading Overlay

So now the user cannot click on any link while the data is loading, which is a nice improvement.

For each story, we're showing the author and the total comments as hyperlinks. Clicking on them takes us to the Hackernews website to show the respective details as you can see in the below gif.

Working hyperlinks

Closing points

We're done building out the functionality of the App.

You can find the complete GitHub source code here, and a live demo here.

To take your skills further, you can improve the application by adding extra functionalities like:

  • Add pagination functionality to load the next 30 records for each page
  • Create a separate page in the application for displaying the comments using the Hacker News API. When clicked on, the comments count the link instead of redirecting the user to the Hackernews website

Thanks for reading!

Want to build more amazing projects? Check them out here.

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

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.

Don't forget to subscribe to my weekly newsletter to get amazing tips, tricks, articles and discount deals directly in your inbox.