1. Home
  2. Uncategorized
  3. Hacker News Clone with ChakraUI and React Query
Uncategorized

Hacker News Clone with ChakraUI and React Query

by Oladini Abayomi October 29, 2022 223 0 Comment 34 min read

This tutorial aims to show how to build a hacker news clone which is the YCombinator news website.

The tools we plan on using are Typescript, React, Axios, Chakra UI and React Query. Chakra UI is a simple, modular and accessible component library that gives the building blocks you need to build your React applications. First of all, let’s discuss what a component library is.  A component library is a collection of component that serve a unified purpose or helps in creating a user interface. 

Examples of Component Libraries are

  1. Material Components Web 
  2. Polymer Elements
  3. Vaddin Web Components 
  4. Wired Elements
  5. Elix
  6. Time Elements
  7. UI5-web components
  8. Patternfly
  9. Web components org
  10. Rebass
  11. Mantime
  12. NextUI

React Query is a server-side state management library for React which makes fetching, caching, synchronizing, and updating server state in your applications a breeze. It solves challenges that deal with server-side such as caching which is the process of storing data in a temporary location so that when the data is needed in the future it can be served faster. It also dedupes multiple requests for the same data into a single request. Data Deduplication is when you eliminate redundant data copies and reduces storage overhead.

Other benefits of React Query are

  • Knowing when data is “out of date”.
  • Reflecting updates to date as quickly as possible.
  • Performance optimizations like pagination and lazy loading data.
  • Managing memory and garbage collection of server state.
  • Memoizing query results with structural sharing 

You can read more about React Query on their official documentation https://tanstack.com/query/v4/docs/overview

We begin by installing Nextjs which is a framework that helps to build server-side applications in react.

Steps to take when building hacker news clone.

  • Install Nextjs.
  • Install Chakra UI.
  • Set up the Chakra UI Root Provider.
  • Install React Query.
  • Change the path and directory.
  • Configure tsconfig.json file.
  • Create the App Layout
  • Add the App.css file to style the pages.
  • Import the App.css file into the pages file.
  • Create the headline component.
  • Implement the hook to get the paginated data.
  • Add the dynamic file to get the news categories.
  • Implement the footer component.

Install Nextjs

yarn create next-app --typescript

Install Chakra UI

yarn add @chakra-ui/react @emotion/react @emotion/styled framer-motion

Set up the Chakra UI Root provider

import "../styles/globals.css";
import type { AppProps } from "next/app";
import { ChakraProvider } from "@chakra-ui/react";

function MyApp({ Component, pageProps }: AppProps) {
  return (
    <ChakraProvider>
      <QueryClientProvider client={queryClient.current}>
        <Component {...pageProps} />
      </QueryClientProvider>
    </ChakraProvider>
  );
}

export default MyApp;

Install React Query

yarn add @tanstack/react-query

Set up the React Query Provider in the _app.tsx file at the src/pages directory

import "../styles/App.css";
import type { AppProps } from "next/app";
import { ChakraProvider } from "@chakra-ui/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useRef } from "react";

function MyApp({ Component, pageProps }: AppProps) {
  const queryClient = useRef(
    new QueryClient({
      defaultOptions: {
        queries: {
          retry: false,
          useErrorBoundary: false,
          refetchOnWindowFocus: false
        }
      }
    })
  );

  return (
    <ChakraProvider>
      <QueryClientProvider client={queryClient.current}>
        <Component {...pageProps} />
      </QueryClientProvider>
    </ChakraProvider>
  );
}

export default MyApp;


Change the path and directory

Create an src folder and move the pages and styles directory into the src folder.

src description

Configure your tsconfig.json to use src as baseUrl

Add the src as baseUrl "baseUrl": "./src" and add "@/public/*": ["public/*"], "@/components/*": ["components/*"], "@/styles/*": ["styles/*"], "@/utils/*": ["utils/*"], "@/types/*": ["types/*"], "@/hooks/*": ["hooks/*"] to the path property

{
  "compilerOptions": {
    "target": "es5",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "incremental": true,
    "baseUrl": "./src",
    "paths": {
      "@/public/*": ["public/*"],
      "@/components/*": ["components/*"],
      "@/styles/*": ["styles/*"],
      "@/utils/*": ["utils/*"],
      "@/types/*": ["types/*"],
      "@/hooks/*": ["hooks/*"]
    }
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
  "exclude": ["node_modules"]
}
 

Create the layout that holds the app header, a section containing the lists and footer together.

Create a layout class in src/pages/layout to hold the layout of the application.

Create a folder named layout, then import the box element from chakra and react element. The box element in chakra is similar to the div element. Typescript adds types to javascript which allows us to understand the data being passed around.

import { Box } from "@chakra-ui/react";
import { ReactElement } from "react";
type ILayout = {
  children?: ReactElement;
  isLoading: boolean;
  newsItems: INewsItem[];
  isFetchingNextPage: boolean;
  fetchNextPage: () => void;
};
const AppLayout = ({
  children,
  isLoading,
  newsItems,
  isFetchingNextPage,
  fetchNextPage
}: ILayout) => {
  return <Box></Box>;
};
export default AppLayout;

The AppLayout file contains some props which are

  • isLoading,
  • children,
  • newsItem,
  • isFetchingNextPage
  • fetchNextPage.

The isLoading props determines if we should show a loading state to let the user know when we are loading data from an API, the children props takes a react Element, newsItem props respresents the stories which we are going to pass the AppLayout, the isFetchingNextPage props determines when we are to show the paginated data and the fetchNextPage props gets the next paginated data.

You should see an error in the newsItems property you can solve that error by adding the newsItem types to the types folder.

Add the newsItem to the types folder

Create the NewsItem type

export interface INewsItem {
  id: number;
  title: string;
  by: string;
  descendants: number;
  kids: number[];
  time: number;
  score: number;
  type: string;
  url: string;
}

Replace what is in src/pages/index.tsx with this. We are importing the AppLayout and we passed the default value as props.

import type { NextPage } from "next";
import AppLayout from "../layout";
const Home: NextPage = () => {
  return (
    <AppLayout
      isLoading={false}
      newsItems={[]}
      isFetchingNextPage={false}
      fetchNextPage={() => {}}
    />
  );
};
export default Home;

Add app.css to the styles folder and the app.css file should contain this

.container{
    /* max-width: 1050px; */
    width: 80%;
    margin: 5px auto 0px;
    background-color: black;
  }
  
  .header{
    height: 25px;
    width: 100%;
    background-color: #ff6600;
    display: flex;
    justify-content: space-between;
    padding: 0px;
  }
  
  .header-section{
    display: flex;
    justify-content: space-between;
    /* border: 1px solid;
    border-color: black; */
  }
  
  .header-text-section{
    display: flex;
    margin: 1px;
  }
  
  .header-image{
    color: hsla(16, 100%, 50%, 0.973);
    margin: 2px 5px 0px 2px;
    border: 1px solid;
    border-color:white;
  }
  
  .header-text-section{
    cursor: pointer;
  }
  
  .header-text{
    font-weight: 900;
    font-size: 14px;
    margin: 1px 0px 0px 0px;
  }
  
  .header-menu{
    margin: 1px 0px 0px 10px;
  }
  
  .menu-item{
    display: inline;
    font-size: 14px;
    font-weight: 450;
    margin: 0px 10px 0px;
    cursor: pointer;
  }
  
  .login-section p{
    font-size: 14px;
    font-weight: 400;
    margin: 3px 10px 0px 4px;
  }
  
  
  .lists-of-headline{
  
  }
  
  .headline{
    display: flex;
    align-items: baseline;
    margin: 0px 0px 0px 5px;
  }
  
  .headline-no{
    display: flex;
    align-items: center;
    /* border: 1px solid;
    border-color: black; */
  }
  
  
  .headline-no p{
    margin: 5px 0px 0px 0px;
    color: #828282;
    font-size: 13px;
    font-weight: 550;
  }
  
  
  .grayarrow{
    width: 10px;
    height: 10px;
    border: 0px;
    margin: 5px 0px 0px 2px;
    background: url("../grayarrow.gif")
  }
  
  .headline-section{
    display: flex;
    margin: 0px 0px 0px 0px;
    align-items: baseline;
    /* border: 1px solid;
    border-color: black; */ 
  }
  
  .headline-content{
    margin: 0px 0px 0px 2px;
  }
  
  .main-headline{
    margin: 5px 0px 0px 0px;
    font-size: 14.5px;
    font-weight: 430;
  }
  
  .headline-website{
    margin: 0px 0px 0px 3px;
    font-size: 12px;
    color: #a38f87;
  }
  
  .headline-comments{
    margin: -8px 0px 0px 0px;
   /* display: inline-block;
     border: 1px solid;
    border-color: black; */
  }
  
  
  
  .headline-comments p{
    margin: 0px 5px 0px 0px;
    font-size: 0.69rem;
    color: #a38f87;
    display: inline-block;
    /* border: 1px solid;
    border-color: #b3918271; */
  }
  
  
  .link-style{
    text-decoration: none;
    color: #090402;
  }
  
  @media screen and (max-width: 700px){
    
    .header{
      height: 50px;
    }
  
    .header-text-section{
      display: block;
    }
    
    .header-image{
      margin-top: 10px;
    }
  
  
    .header-menu{
      margin: 0px 0px 0px 0px;
    }
  
    .login-section p{
      margin-top: 10px;
    }
  
  }
  
  

For CSS file to work in nextjs you have to import the app.css file in _app.tsx file.

import "./../styles/globals.css";
import "../styles/App.css";
import type { AppProps } from "next/app";
import { ChakraProvider } from "@chakra-ui/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useRef } from "react";
function MyApp({ Component, pageProps }: AppProps) {
  const queryClient = useRef(
    new QueryClient({
      defaultOptions: {
        queries: {
          retry: false,
          useErrorBoundary: false,
          refetchOnWindowFocus: false
        }
      }
    })
  );
  return (
    <ChakraProvider>
      <QueryClientProvider client={queryClient.current}>
        <Component {...pageProps} />
      </QueryClientProvider>
    </ChakraProvider>
  );
}
export default MyApp;

Create the app header in the App Layout.

Add the header section for hacker news and it should contain the hacker news image, the title, and the categories showing the header section.

import { Box, Image, Text } from "@chakra-ui/react";
import { useRouter } from "next/router";
import { ReactElement } from "react";
import { INewsItem } from "../types";
type ILayout = {
  children?: ReactElement;
  isLoading: boolean;
  newsItems: INewsItem[];
  isFetchingNextPage: boolean;
  fetchNextPage: () => void;
};
const AppLayout = ({
  children,
  isLoading,
  newsItems,
  isFetchingNextPage,
  fetchNextPage
}: ILayout) => {
  const menuHeader = ["news", "ask", "show", "jobs"];
  const router = useRouter();
  function goToPage(page: string) {
    router.push(`${page}`);
  }
  return (
    <Box>
      <Box className="App">
        <Box className="container">
          <Box className="header">
            <Box className="header-section">
              <Box className="header-image-section">
                <Image alt={"gif"} className={"header-image"} src={"y18.gif"} />
              </Box>
              <Box className="header-text-section">
                <Box
                  className="header-text-content"
                  onClick={() => goToPage("/")}
                >
                  <Text className="header-text">Hackers News</Text>
                </Box>
                <Box display={"flex"} className="header-menu">
                  {menuHeader.map((item: string, index: number) => (
                    <Box key={index} display={"flex"} alignItems={"center"}>
                      <Text
                        className="menu-item"
                        onClick={() => goToPage(item)}
                      >
                        {item}
                      </Text>
                      <Text m={"0px 0px 2px 0px"}>|</Text>
                    </Box>
                  ))}
                </Box>
              </Box>
            </Box>
          </Box>
        </Box>
      </Box>
    </Box>
  );
};
export default AppLayout;

Your page should look like this with the header component.

The app view now

Create the headline Component.

Implement the headline component to display the individual list of items.

Create the headline component and your headline component should contain the codes below.

import { Box } from "@chakra-ui/react";
import { INewsItem } from "../../types";
type props = {
  newsId: number;
  headline: INewsItem;
};
const Headline = ({ newsId, headline }: props) => {
  let headlineNo = 1;
  const getWebsiteDomain = (url: string) => {
    if (url) {
      let domain = url.split("/");
      return `(${domain[2]})`;
    }
    return "";
  };
  return (
    <Box className="headline pointer">
      {}
      <Box className="headline-no">
        <p>{newsId}.</p>
        <Box className="grayarrow" title="upvote"></Box>
      </Box>
      <Box className="headline-content">
        <Box className="headline-section">
          <a
            href={headline.url}
            className="main-headline link-style"
            target="_blank"
            rel="noreferrer"
          >
            {headline.title}
          </a>
          <p className="headline-website">{getWebsiteDomain(headline.url)}</p>
        </Box>
        <Box className="headline-comments">
          <p>{headline.score} points</p>
          <p>by {headline.by} </p>
          <p>58 minutes ago</p>
          <p>| hide</p>
          <p>| {headline.descendants} comments</p>
        </Box>
      </Box>
    </Box>
  );
};
export default Headline;

Implement the pagination section.

Create the paginated folder

Implement the custom method to get paginated data.

import api from "@/utils/api";
import { _errorHandler } from "@/utils/helpers";
import { useInfiniteQuery, QueryKey } from "@tanstack/react-query";

const onError = (err: any) => {
  _errorHandler(err);
};
export const useGetCustomStories = (
  url: string,
  queryName: string | QueryKey
) => {
  let customStoriesResponse: any[] = [];
  const getCustomStories = async (step: number) => {
    const storiesresponse = await api.get(`${url}.json?print=pretty`);
    let storiesresponseID = storiesresponse.data;
    for (let i = 0; i < step; i++) {
      const response = await api.get(
        `/item/${storiesresponseID[i]}.json?print=pretty`
      );
      customStoriesResponse[i] = response.data;
    }
    return customStoriesResponse;
  };

  const { data, isLoading, fetchNextPage, isFetchingNextPage, hasNextPage } =
    useInfiniteQuery(
      [queryName],
      ({ pageParam = 30 }) => getCustomStories(pageParam),
      {
        onError,
        getNextPageParam: (lastPage, pages) => {
          return lastPage ? pages.length + 30 : null;
        }
      }
    );

  let customStories: any[] = [];
  data?.pages.map((page) => page.map((item: any) => customStories.push(item)));
  return {
    customStories,
    isLoading,
    fetchNextPage,
    isFetchingNextPage,
    hasNextPage
  };
};

Add the utils folder

Create an helper method in the utils folder

This utils folder is meant to hold the helper method to handle error.

import { AxiosError } from "axios";
import toast from "react-hot-toast";

export const _errorHandler = (err: unknown) => {
  let error: any = err as AxiosError;
  let message = !navigator.onLine
    ? "Please check your internet connection"
    : error?.response.status == 400
    ? "Something went wrong"
    : "";
  return toast.error(message);
};

const onError = (err: any) => {
  _errorHandler(err);
};

Install the necessary dependencies

We need the axios library to make HTTP requests from the browser to access the API.

yarn add axios react-hot-toast

Create a folder that holds the base API

Add this to the src/utils/api/index.ts

import axios from "axios";
export const api = axios.create({
  baseURL: `https://hacker-news.firebaseio.com/v0/`
});
export default api;

Add a dynamic file to show the category path

Create a dynamic route to navigate to the pages of the categories in the AppLayout

import { useGetCustomStories } from "@/hooks/index";
import { getQueryPath } from "@/utils/helpers";
import { Box, Text } from "@chakra-ui/react";
import AppLayout from "layout";
import { useRouter } from "next/router";
const Index = () => {
  const route = useRouter();
  const category_path = route.query.category_path;
  const { url, queryKey } = getQueryPath(category_path)!!;
  const { isLoading, customStories, fetchNextPage, isFetchingNextPage } =
    useGetCustomStories(url, queryKey);
  return (
    <AppLayout
      isLoading={isLoading}
      newsItems={customStories}
      isFetchingNextPage={isFetchingNextPage}
      fetchNextPage={fetchNextPage}
    />
  );
};
export default Index;

Add this to the src/utils/helpers.tsx

export const getQueryPath = (path: string | string[] | undefined) => {
  switch (path) {
    case "news": {
      return setUrlAndQueryKey("topstories", "news");
    }
    case "ask": {
      return setUrlAndQueryKey("askstories", "ask");
    }
    case "show": {
      return setUrlAndQueryKey("showstories", "show");
    }
    case "jobs": {
      return setUrlAndQueryKey("jobstories", "job");
    }
    default:
      return setUrlAndQueryKey("topstories", "news");
      break;
  }
};
export function setUrlAndQueryKey(url: string, queryKey: QueryKey | string) {
  return { url, queryKey };
}

The site should look like this

Implement the paginated section to the layout section

The isFetchingNextPage props provide the condition to show the loading text or the more text to fetch more data to display to the user.

  {isFetchingNextPage ? (
            <Text margin={"15px 0px 15px 0px"} textAlign={"center"}>
              isLoading...
            </Text>
          ) : (
            <Text
              m={"10px 0px 10px 40px"}
              onClick={increaseStep}
              cursor={"pointer"}
              fontSize={"14px"}
              color={"#828282"}
            >
              More
            </Text>
          )}

Add type of the props passed to the footer to the types file.

export interface IFooterData {
  text: string;
  link: string;
}

Implement the footer section.

We then need to create a footer data variable to hold the name and url to each footer section.

import { IFooterData } from "../types";
export const footerData: IFooterData[] = [
    {
        text: "Guidelines",
        link: "https://news.ycombinator.com/newsguidelines.html"
    },
    {
        text: "FAQ",
        link: "https://news.ycombinator.com/newsfaq.html"
    },
    {
        text: "Lists",
        link: "https://news.ycombinator.com/lists"
    }, 
    {
        text: "API",
        link: "https://github.com/HackerNews/API"
    },
    {
        text:  "Security",
        link: "https://news.ycombinator.com/security.html"
    },
    {
        text: "Legal",
        link: "https://www.ycombinator.com/legal/"
    },
    {
        text:  "Apply to YC",
        link: "http://www.ycombinator.com/apply/"
    }
]

Create the footer component

import { IFooterData } from "@/types/index";
import { Input, Box, Text } from "@chakra-ui/react";
import Link from "next/link";

interface props {
  footerLink: IFooterData[];
}

function Footer({ footerLink: props }: props) {
  return (
    <Box>
      <Box borderTop={"2px solid #ff6600"} />
      <Box maxWidth={"469px"} margin={"10px auto 0px"} pb={"20px"}>
        <Link href={"https://www.ycombinator.com/apply/"}>
          <Text
            cursor={"pointer"}
            fontSize={"15px"}
            textAlign={"center"}
            fontWeight={"400"}
          >
            Applications are open for YC Winter 2023
          </Text>
        </Link>

        <Box display={"flex"} margin={"auto"} alignItems={"center"}>
          {props.map((item: IFooterData, index: number) => (
            <Box
              display={"flex"}
              alignItems={"center"}
              key={index}
              // border={"1px solid"}
            >
              <Link href={item.link}>
                <Text cursor={"pointer"} fontSize={"11px"} margin={"10px"}>
                  {item.text}
                </Text>
              </Link>

              <Box borderRight={"1px solid"} height={"13px"} />
            </Box>
          ))}
          <Link href={"hn@ycombinator.com"}>
            <Text margin={"10px"} cursor={"pointer"} fontSize={"11px"}>
              Contact
            </Text>
          </Link>
        </Box>
        <Box
          maxWidth={"200px"}
          margin={"5px auto 0px"}
          display={"flex"}
          alignItems={"center"}
        >
          <Text fontSize={"15px"} color={"#828282"} fontWeight={"500"}>
            Search:{" "}
          </Text>
          <Input
            type={"name"}
            height={"21.5px"}
            width={"148px"}
            ml={"5px"}
            background={"white"}
            borderRadius={"1px"}
            border={"1px solid black"}
            borderColor={"black"}
          />
        </Box>
      </Box>
    </Box>
  );
}

export default Footer;

Add the footer component to app layout after the paginated component.

 <Footer footerLink={footerData} />

We are going to add the custom stories and import the app layout to the pages index file

import { useGetCustomStories } from "../hooks";
import AppLayout from "../layout";
const Home = () => {
  const { isLoading, customStories, fetchNextPage, isFetchingNextPage } =
    useGetCustomStories("topstories", "news");
  return (
    <AppLayout
      isLoading={isLoading}
      newsItems={customStories}
      isFetchingNextPage={isFetchingNextPage}
      fetchNextPage={fetchNextPage}
    />
  );
};
export default Home;

The final application should look like this

ABOVE THE FOLD OF HACKER NEWS CLONE
BELOW THE FOLD OF HACKER NEWS CLONE

Share This:

Previous post

Oladini Abayomi (Website)

administrator

I am a Front-End Developer developing user-facing applications with React.js and Vue.js. I have three years of experience developing applications for startups and multinationals. I have also worked remotely and with teams.

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *