Youtube

Youtube

Β·

17 min read

In this blog, we will build our version of Youtube, implementing some important features like video list home page, search videos with suggestions, watch video page, nested comments and live chat. We'll also follow modern style patterns by implementing light and dark themes.

In the UI layer we'll use the React library, TailwindCSS for styling and React Router DOM for routing. In the data layer, all these features will use the publicly available official Youtube live API. We will use Redux Toolkit and Redux store for state management.

Let's quickly build a react application and get going!

React Application Setup

Run the following commands on your terminal:

npx create-react-app youtube
npm i -D tailwindcss
npx tailwindcss init
npm i @reduxjs/toolkit
npm i react-redux
npm i react-router-dom

Setup tailwind.config.js & App.css files:

// tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
  content: ["./src/**/*.{html,js,ts,jsx,tsx}"],
  theme: {
    extend: {
      colors: {
        darkModeBlack: "#181818",
        darkModeDarkGray: "#212121",
        darkModeGray: "#3D3D3D",
        darkModeLightGray: "#AAAAAA",
      },
    },
  },
  plugins: [],
};
/* App.css */
@tailwind base;
@tailwind components;
@tailwind utilities;

As you can see, we have also added some colors for implementing dark theme.

Structure the app

We can structure the app as follows:

We'll have 4 folders inside src:

  1. components: for the UI components. We will have a header and a body. The body component will always have a sidebar and a main component. This main component dynamically changes to watch page with change in routes. If there is any navigation to any invalid route, then we handle that using an Error page.

  2. hooks: for custom hooks.

  3. redux: for slices and redux store related files.

  4. utils: for app constants and helper functions.

// Body.jsx
import React from "react";
import Sidebar from "./sidebar/Sidebar";
import { Outlet } from "react-router-dom";
import { useSelector } from "react-redux";

const Body = () => {
  const darkTheme = useSelector((store) => store.app.darkTheme);
  return (
    <div
      className={
        "flex-grow grid grid-flow-col" +
        (darkTheme ? " bg-darkModeDarkGray text-white" : "")
      }
    >
      <Sidebar />
      <Outlet />
    </div>
  );
};

export default Body;
// Error.jsx
import { useRouteError } from "react-router-dom";

const Error = () => {
  const { status, statusText } = useRouteError();

  return (
    <div className="h-screen p-5">
      <h1 className="font-extrabold text-3xl py-5">Oops!!!</h1>
      <h2>Something went wrong...</h2>
      <h2>{status + " : " + statusText}</h2>
    </div>
  );
};

export default Error;
// Sidebar.jsx
import React from "react";
import SidebarSection from "./sidebar-section/SidebarSection";
import { SECTION_DATA } from "../../../utils/constants";
import { useSelector } from "react-redux";

const Sidebar = () => {
  const isSidebarOpen = useSelector((store) => store.app.isSidebarOpen);
  const darkTheme = useSelector((store) => store.app.darkTheme);

  // Early return pattern
  if (!isSidebarOpen) return null;

  return (
    <div
      className={
        "col-span-1 p-5 shadow-lg" +
        (darkTheme ? " border-r border-r-darkModeGray" : "")
      }
    >
      {SECTION_DATA.length > 0 &&
        SECTION_DATA.map((section, index) => (
          <SidebarSection key={index} {...section} />
        ))}
    </div>
  );
};

export default Sidebar;
// Main.jsx
import React, { useEffect } from "react";
import VideoLabels from "./video-labels/VideoLabels";
import VideoContainer from "./video-container/VideoContainer";
import { useDispatch } from "react-redux";
import { openSidebar } from "../../../redux/appSlice";
import { useSearchParams } from "react-router-dom";

const Main = () => {
  const dispatch = useDispatch();
  const [searchParams] = useSearchParams();
  const searchQuery = searchParams.get("search_query");

  useEffect(() => {
    dispatch(openSidebar());
  }, []);

  return (
    <div className="col-span-11">
      <VideoLabels></VideoLabels>
      <VideoContainer searchQuery={searchQuery}></VideoContainer>
    </div>
  );
};

export default Main;

Video List

We will have to buld two components inside the main container: one for listing videos and the other for video labels.

Video Labels:

// useLabel.jsx
import { useEffect, useState } from "react";
import { YOUTUBE_API } from "../utils/constants";

const useLabel = () => {
  const [labels, setLabels] = useState([]);

  useEffect(() => {
    getLabels();
  }, []);

  const getLabels = async () => {
    const data = await fetch(`${YOUTUBE_API.VideoCategoriesList}`);
    const { items } = await data.json();
    setLabels(items.map((item) => item.snippet.title));
  };

  return labels;
};

export default useLabel;
// VideoLabels.jsx
import React from "react";
import useLabel from "../../../../hooks/useLabel";
import Label from "./video-label/Label";
import { MAX_LABELS } from "../../../../utils/constants";

const VideoLabels = () => {
  const labels = useLabel();
  return (
    <div>
      {labels.length > 0 &&
        labels
          .slice(0, MAX_LABELS)
          .map((label, index) => <Label key={index} name={label} />)}
    </div>
  );
};

export default VideoLabels;
// Label.jsx
import React from "react";
import { useSelector } from "react-redux";

const Label = ({ name }) => {
  const darkTheme = useSelector((store) => store.app.darkTheme);
  return (
    <button
      className={
        "m-2 px-5 py-2 rounded-lg" +
        (darkTheme ? " bg-darkModeGray text-white" : " bg-gray-200")
      }
    >
      {name}
    </button>
  );
};

export default Label;

Listing videos:

// useVideo.jsx
import { useEffect, useState } from "react";
import { YOUTUBE_API } from "../utils/constants";

const useVideo = ({ id, searchQuery }) => {
  const [videos, setVideos] = useState([]);

  useEffect(() => {
    getVideos();
  }, [searchQuery]);

  const getVideos = async () => {
    let api = `${YOUTUBE_API.VideosList}`;
    if (id) {
      api = `${YOUTUBE_API.VideoDetail}&id=${id}`;
    } else if (searchQuery) {
      api = `${YOUTUBE_API.SearchVideosList}&q=${searchQuery}`;
    }
    const data = await fetch(api);
    let { items } = await data.json();
    if (searchQuery) {
      items = items.map((item) => ({ ...item, id: item?.id?.videoId }));
    }
    setVideos(items);
  };

  return videos;
};

export default useVideo;
// VideoContainer.jsx
import React from "react";
import useVideo from "../../../../hooks/useVideo";
import VideoCard from "./video-card/VideoCard";
import { Link } from "react-router-dom";

const VideoContainer = ({ searchQuery }) => {
  const videos = useVideo({ searchQuery: searchQuery });

  if (videos.length === 0) return null;

  return (
    <div className="flex flex-wrap">
      {videos.map((video) => (
        <Link key={video.id} to={`/watch?v=${video.id}`}>
          <VideoCard {...video} />
        </Link>
      ))}
    </div>
  );
};

export default VideoContainer;
// VideoCard.jsx
import React from "react";
import { useSelector } from "react-redux";

const VideoCard = ({ snippet, statistics }) => {
  const { title, channelTitle, thumbnails } = snippet;
  const darkTheme = useSelector((store) => store.app.darkTheme);
  return (
    <div
      className={
        "p-2 m-2 w-72 shadow-lg" +
        (darkTheme ? " border border-darkModeGray" : "")
      }
    >
      <img className="rounded-lg" src={thumbnails.medium.url} alt="thumbnail" />
      <ul>
        <li className="font-bold py-2">{title}</li>
        <li>{channelTitle}</li>
        {statistics && <li>{statistics?.viewCount} views</li>}
      </ul>
    </div>
  );
};

export default VideoCard;

We will have our search bar inside the header component as follows:

// useSearch.jsx
import { useEffect, useState } from "react";
import { YOUTUBE_API } from "../utils/constants";
import { useDispatch, useSelector } from "react-redux";
import { cacheResults } from "../redux/searchSlice";
import { useNavigate } from "react-router-dom";
import fetchJsonp from "fetch-jsonp";

const useSearch = () => {
  const searchCache = useSelector((store) => store.search);
  const dispatch = useDispatch();
  const navigate = useNavigate();
  const [searchQuery, setSearchQuery] = useState("");
  const [searchSuggestions, setSearchSuggestions] = useState([]);
  const [showSuggestions, setShowSuggestions] = useState(false);
  const [selectedSuggestion, setSelectedSuggestion] = useState(-1);

  useEffect(() => {
    // DEBOUNCING
    const timer = setTimeout(() => {
      if (searchQuery !== "") {
        if (searchCache[searchQuery]) {
          setSearchSuggestions(searchCache[searchQuery]);
        } else {
          getSearchSuggestions();
        }
      } else {
        setSearchSuggestions([]);
      }
    }, 200);
    setShowSuggestions(true);
    return () => {
      clearTimeout(timer);
    };
  }, [searchQuery]);

  const getSearchSuggestions = async () => {
    const data = await fetchJsonp(YOUTUBE_API.Search + searchQuery);
    let res = await data.json();
    res = res[1].map((arr) => arr[0]);
    setSearchSuggestions(res);
    dispatch(cacheResults({ [searchQuery]: res }));
  };

  const onSearch = (e, searchQuery) => {
    e.preventDefault();
    setSearchQuery(searchQuery);
    navigate("/results?search_query=" + encodeURIComponent(searchQuery));
  };

  const handleKeyDown = (e) => {
    if (e.key === "ArrowUp" && selectedSuggestion > 0) {
      setSelectedSuggestion((prev) => prev - 1);
    } else if (
      e.key === "ArrowDown" &&
      selectedSuggestion < searchSuggestions.length - 1
    ) {
      setSelectedSuggestion((prev) => prev + 1);
    } else if (e.key === "Enter" && selectedSuggestion >= 0) {
      onSearch(e, searchSuggestions[selectedSuggestion]);
      setTimeout(() => setShowSuggestions(false), 200);
    }
  };

  return {
    searchQuery,
    setSearchQuery,
    searchSuggestions,
    showSuggestions,
    setShowSuggestions,
    onSearch,
    handleKeyDown,
    selectedSuggestion,
    setSelectedSuggestion,
  };
};

export default useSearch;
// Header.jsx
import React from "react";
import { useDispatch, useSelector } from "react-redux";
import { toggleSidebar, toggleDarkTheme } from "../redux/appSlice";
import { Link } from "react-router-dom";
import useSearch from "../hooks/useSearch";

const Header = () => {
  const darkTheme = useSelector((store) => store.app.darkTheme);
  const {
    searchQuery,
    setSearchQuery,
    searchSuggestions,
    showSuggestions,
    setShowSuggestions,
    onSearch,
    handleKeyDown,
    selectedSuggestion,
    setSelectedSuggestion,
  } = useSearch();

  const dispatch = useDispatch();
  const toggleSidebarHandler = () => {
    dispatch(toggleSidebar());
  };
  const darkThemeHandler = () => {
    dispatch(toggleDarkTheme());
  };

  return (
    <div
      className={
        "grid grid-flow-col px-5 py-4 shadow-lg" +
        (darkTheme
          ? " bg-darkModeDarkGray text-white  border-b border-b-darkModeGray"
          : "")
      }
    >
      <div className="flex items-center col-span-1">
        <img
          className="h-8 mx-2 cursor-pointer"
          src={
            darkTheme
              ? "https://cdn2.vectorstock.com/i/1000x1000/33/01/hamburger-like-menu-dark-mode-glyph-ui-icon-vector-43353301.jpg"
              : "https://cdn.iconscout.com/icon/free/png-256/free-hamburger-menu-462145.png?f=webp"
          }
          alt="hamburger menu icon"
          onClick={toggleSidebarHandler}
        />{" "}
        <Link to="/">
          <img
            className={darkTheme ? "h-16 mx-2" : "h-6 mx-2"}
            src={
              darkTheme
                ? "https://cdn.neowin.com/news/images/uploaded/2018/01/1516306436_youtube4_story.jpg"
                : "https://upload.wikimedia.org/wikipedia/commons/thumb/b/b8/YouTube_Logo_2017.svg/2560px-YouTube_Logo_2017.svg.png"
            }
            alt="Youtube Logo"
          />
        </Link>
      </div>
      <div className="col-span-10 px-20">
        <div>
          <input
            className={
              "w-2/3 border border-r-0  border-gray-400 px-5 py-2 text-xl rounded-l-full" +
              (darkTheme ? " bg-darkModeBlack" : "")
            }
            type="search"
            placeholder="Search"
            value={searchQuery}
            onChange={(e) => setSearchQuery(e.target.value)}
            onFocus={() => setShowSuggestions(true)}
            onBlur={() => setTimeout(() => setShowSuggestions(false), 200)}
            onKeyDown={handleKeyDown}
          />
          <button
            className={
              "border border-gray-400 px-7 py-2 text-xl rounded-r-full" +
              (darkTheme ? " bg-darkModeGray" : " bg-gray-100")
            }
            onClick={(e) => onSearch(e, searchQuery)}
            disabled={searchQuery === ""}
          >
            πŸ”
          </button>
        </div>
        {showSuggestions && searchSuggestions.length > 0 && (
          <div
            className={
              "absolute mt-1 py-2 px-5 w-[37.5rem] shadow-lg rounded-lg border" +
              (darkTheme
                ? " bg-darkModeDarkGray border-darkModeGray"
                : " bg-white border-gray-100")
            }
          >
            {searchSuggestions.map((suggestion, index) => (
              <div
                key={index}
                className={
                  "py-2 shadow-sm" +
                  (darkTheme
                    ? " hover:bg-darkModeGray"
                    : " hover:bg-gray-100") +
                  (selectedSuggestion === index
                    ? darkTheme
                      ? " bg-darkModeGray"
                      : " bg-gray-100"
                    : "")
                }
                onClick={(e) => onSearch(e, suggestion)}
                onMouseOver={() => setSelectedSuggestion(-1)}
              >
                πŸ” {suggestion}
              </div>
            ))}
          </div>
        )}
      </div>

      <div className="col-span-1 flex items-center justify-around">
        <button className="text-2xl" onClick={darkThemeHandler}>
          {darkTheme ? "🌞" : "🌚"}
        </button>
        <img
          className="h-10"
          src={
            darkTheme
              ? "https://cdn4.vectorstock.com/i/1000x1000/97/68/account-avatar-dark-mode-glyph-ui-icon-vector-44429768.jpg"
              : "https://www.svgrepo.com/show/350417/user-circle.svg"
          }
          alt="User Icon"
        />
      </div>
    </div>
  );
};

export default Header;

We use youtube search API to build search bar. Now this is not a normal search, it's performant search.

We have used debouncing after every 200ms to minimise the network calls being made from the frontend. We also use caching for the seach query and the search suggestions to further reduce the network calls being made.

For it's search functionality, flipkart has implemented debouncing. So, if I type the query "mobile" on its search bar, it will not be making autosuggest API calls for every character that is typed on the search bar, i.e. let's say the time interval for debouncing is 200ms, then probably the first autosuggest API call will be made once the user presses "m" on the keyboard and the second API call is made after the user quickly types "mobile". This reduces the number of API calls being made to an extent. But if I start pressing backspace, then flipkart will again be making autosuggest API calls for "mobil", "mobi", "mob" and so on upto "m". Now, making these API calls is redundant, since it has already made them previously.

Youtube being the tech giant that it is, has focused on improving user experience by keeping a lower debouncing time interval (way lower than 200ms). This results in users quickly getting different suggestions as they type and the search query changes. But the cherry on top of the cake is that youtube has also implemented caching for their search query and suggestions. So, if I hit backspace on youtube's search bar, then it will not make any further API calls and get me the suggestions stored in it's cache. This is what makes youtube's search functionality truly performant and better than flipkart's or any of it's competitor's.

I've also implemented both debouncing and caching, so my version of youtube search is functionally better than flipkart and as good as the actual youtube! πŸ˜‡

Watch Video

// Watch.jsx
import React, { useEffect } from "react";
import { closeSidebar } from "../../../redux/appSlice";
import { useDispatch } from "react-redux";
import { useSearchParams } from "react-router-dom";
import useVideo from "../../../hooks/useVideo";
import Comments from "./comments/Comments";
import LiveChat from "./chat/LiveChat";

const Watch = () => {
  const dispatch = useDispatch();
  const [searchParams] = useSearchParams();
  const videoId = searchParams.get("v");
  const [video] = useVideo({ id: videoId });

  useEffect(() => {
    dispatch(closeSidebar());
  }, []);

  return (
    <div className="col-span-11 p-5">
      <div className="flex flex-col">
        <div className="px-5 flex">
          <div className="w-[1100px]">
            <iframe
              width="1100"
              height="600"
              title="Watch youtube video"
              src={"https://www.youtube.com/embed/" + videoId}
              frameborder="0"
              allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
              allowFullScreen
            ></iframe>
            {video && (
              <ul className="pt-3">
                <li className="font-bold text-3xl">{video.snippet.title}</li>
                <li>{video.snippet.channelTitle}</li>
                <li>
                  {video.statistics.viewCount} views -{" "}
                  {video.statistics.likeCount} likes -{" "}
                  {video.statistics.commentCount} comments
                </li>
              </ul>
            )}
          </div>
          <div className="flex-grow">
            <LiveChat></LiveChat>
          </div>
        </div>
        <Comments videoId={videoId} />
      </div>
    </div>
  );
};

export default Watch;

We have followed a good coding practice called seperation of concerns. We have seperated the logic regarding the UI layer and Data layer into components and custom hooks respectively. So, all the API calls and state changes and being handles inside custom hooks, whereas, component files are only concerned with UI rendering and styling.

High Order Component

A higher-order component (HOC) is an advanced technique in React for reusing component logic. HOCs are not part of the React API, per se. They are a pattern that emerges from React’s compositional nature.

Concretely, a higher-order component is a function that takes a component and returns a new component.

const EnhancedComponent = higherOrderComponent(WrappedComponent);

Whereas a component transforms props into UI, a higher-order component transforms a component into another component.

We have used the concept of higher-order component to build N-level nested comments.

N-level Nested Comments

This idea of n-level nesting is inspired from Reddit, although the actual Youtube platform only supports 2-level nested comments, i.e. a user can post a comment to any video. For that comment, we can have a reply by any user, but we can't have replies to any reply on Youtube, unlike reddit. For our version of youtube though, we have implemented n-level nesting, so based on the data provided, the UI layer can render comments upto a depth of n-levels.

// useComment.jsx
import { useEffect, useState } from "react";
import { YOUTUBE_API } from "../utils/constants";

const useComment = (videoId) => {
  const [comments, setComments] = useState([]);

  useEffect(() => {
    getComments();
  }, []);

  const getComments = async () => {
    let api = `${YOUTUBE_API.Comments}&videoId=${videoId}`;
    const data = await fetch(api);
    let { items } = await data.json();
    // TODO: Mapping needs to be automated for n-level nested comments, it's fine for 2-level nesting though!
    items = items?.map((item) => ({
      name: item?.snippet?.topLevelComment?.snippet?.authorDisplayName,
      text: item?.snippet?.topLevelComment?.snippet?.textOriginal,
      replies: item?.replies
        ? item?.replies?.comments.map((reply) => ({
            name: reply?.snippet?.authorDisplayName,
            text: reply?.snippet?.textOriginal,
            replies: [],
          }))
        : [],
    }));
    setComments(items);
  };

  return comments;
};

export default useComment;
// Comments.jsx
import React from "react";
import useComment from "../../../../hooks/useComment";
import Comment from "./comment/Comment";

const CommentsList = ({ comments }) => {
  if (comments.length === 0) return null;
  return comments.map((comment, index) => {
    const { replies } = comment;
    return (
      <div>
        <Comment key={index} {...comment} />
        {replies.length > 0 && (
          <div className="ml-5 pl-5 border border-l-black">
            <CommentsList comments={replies} />
          </div>
        )}
      </div>
    );
  });
};

const Comments = ({ videoId }) => {
  // N-Level Nested Comments
  const comments = useComment(videoId);
  if (!comments) return null;
  return (
    <div className="m-5 p-2 w-[1100px]">
      <h1 className="text-2xl font-bold ">Comments:</h1>
      <CommentsList comments={comments} />
    </div>
  );
};

export default Comments;
// Comment.jsx
import React from "react";
import { useSelector } from "react-redux";

const Comment = ({ name, text }) => {
  const darkTheme = useSelector((store) => store.app.darkTheme);
  return (
    <div
      className={
        "flex p-2 my-2 shadow-sm rounded-lg" +
        (darkTheme ? " bg-darkModeGray" : " bg-gray-100")
      }
    >
      <img
        className="w-12 h-12"
        src="https://www.svgrepo.com/show/350417/user-circle.svg"
        alt="user"
      />
      <div className="px-3">
        <p className="font-bold">{name}</p>
        <p>{text}</p>
      </div>
    </div>
  );
};

export default Comment;

We have used recursiion inside components to achieve n-level nesting as shown in the above code for CommentsList component.

Web Sockets vs Long Polling

WebSockets

A WebSocket is a long lived persistent TCP connection (often utilizing TLS) between a client and a server which provides a real-time full-duplex communication channel. These are often seen in chat applications and real-time dashboards.

Long Polling

Long Polling is a near-real-time data access pattern that predates WebSockets. A client initiates a TCP connection (usually an HTTP request) with a maximum duration (ie. 20 seconds). If the server has data to return, it returns the data immediately, usually in batch up to a specified limit. If not, the server pauses the request thread until data becomes available at which point it returns the data to the client.

What should we use for Live Chat?

WebSockets are Full-Duplex meaning both the client and the server can send and receive messages across the channel. Long Polling is Half-Duplex meaning that a new request-response cycle is required each time the client wants to communicate something to the server.

Long Polling usually produces slightly higher average latency and significantly higher latency variability compared to WebSockets.

WebSockets do support compression, but usually per-message. Long Polling typically operates in batch which can significantly improve message compression efficiency.

For this reason, we'll use long polling for our live chat, with a chat polling time interval of 2s to get fresh live chat data.

Live Chat

Here is our live chat with multiple people chatting:

We also have a provision to put in out thoughts into the live chat by typing inside the input box and hitting enter/send button.

As you can see, user "Ruban" with comment "Nice video" comes up in the live chat. The chat was moving up so fast that before I could take the screenshot few others had already commented as shown below:

// useChat.jsx
import { useEffect, useState } from "react";
import { CHAT_POLL_INTERVAL } from "../utils/constants";
import { useDispatch, useSelector } from "react-redux";
import { addMessage } from "../redux/chatSlice";
import { generateRandomName, generateRandomComment } from "../utils/helper";

const useChat = () => {
  const [liveMessage, setLiveMessage] = useState("");

  const dispatch = useDispatch();
  const chatMessages = useSelector((store) => store.chat.messages);

  const sendChat = (e) => {
    e.preventDefault();
    dispatch(addMessage({ name: "Ruban", message: liveMessage }));
    setLiveMessage("");
  };

  useEffect(() => {
    const chatPoll = setInterval(() => {
      // API POLLING
      dispatch(
        addMessage({
          name: generateRandomName(),
          message: generateRandomComment().slice(0, 20),
        })
      );
    }, CHAT_POLL_INTERVAL);
    return () => clearInterval(chatPoll);
  }, []);

  return { chatMessages, sendChat, liveMessage, setLiveMessage };
};

export default useChat;
// LiveChat.jsx
import React from "react";
import ChatMessage from "./message/ChatMessage";
import useChat from "../../../../hooks/useChat";
import { useSelector } from "react-redux";

const LiveChat = () => {
  const { chatMessages, sendChat, liveMessage, setLiveMessage } = useChat();
  const darkTheme = useSelector((store) => store.app.darkTheme);
  return (
    <div className="w-full">
      <div
        className={
          "h-[560px] ml-2 p-2 border border-b-0 rounded-t-lg overflow-y-scroll flex flex-col-reverse" +
          (darkTheme
            ? " bg-darkModeGray text-darkModeLightGray border-darkModeLightGray"
            : " bg-slate-100 border-black")
        }
      >
        {chatMessages.map((chat, index) => (
          <ChatMessage key={index} {...chat} />
        ))}
      </div>
      <form
        className={
          "h-[40px] p-2 ml-2 border border-t-0 flex rounded-b-lg" +
          (darkTheme ? " border-darkModeLightGray" : " border-black")
        }
        onSubmit={sendChat}
      >
        <input
          type="text"
          className={
            "px-2 w-60" +
            (darkTheme
              ? " bg-darkModeGray text-darkModeLightGray"
              : " bg-slate-100")
          }
          value={liveMessage}
          onChange={(e) => setLiveMessage(e.target.value)}
        />
        <button
          className={
            "px-2 mx-2" +
            (darkTheme ? " bg-green-300 text-black" : " bg-green-100")
          }
        >
          Send
        </button>
      </form>
    </div>
  );
};

export default LiveChat;
// ChatMessage.jsx
import React from "react";

const ChatMessage = ({ name, message }) => {
  return (
    <div className="flex items-center shadow-sm p-2">
      <img
        className="h-8"
        src="https://www.svgrepo.com/show/350417/user-circle.svg"
        alt="user"
      />
      <span className="font-bold px-2">{name}</span>
      <span>{message}</span>
    </div>
  );
};

export default ChatMessage;

Using Redux for state management

// store.js
import { configureStore } from "@reduxjs/toolkit";
import appSliceReducer from "./appSlice";
import searchSliceReducer from "./searchSlice";
import chatSliceReducer from "./chatSlice";

const store = configureStore({
  reducer: {
    app: appSliceReducer,
    search: searchSliceReducer,
    chat: chatSliceReducer,
  },
});

export default store;

We have kept global properties like toggle sidebar and dark theme inside the appSlice.

// appSlice.js
import { createSlice } from "@reduxjs/toolkit";

const appSlice = createSlice({
  name: "app",
  initialState: {
    isSidebarOpen: true,
    darkTheme: false,
  },
  reducers: {
    toggleSidebar: (state) => {
      state.isSidebarOpen = !state.isSidebarOpen;
    },
    openSidebar: (state) => {
      state.isSidebarOpen = true;
    },
    closeSidebar: (state) => {
      state.isSidebarOpen = false;
    },
    toggleDarkTheme: (state) => {
      state.darkTheme = !state.darkTheme;
    },
  },
});

export const { toggleSidebar, openSidebar, closeSidebar, toggleDarkTheme } =
  appSlice.actions;

export default appSlice.reducer;

Upto 20 live chat messages are stored inside chatSlice. Once this offset of 20 is exceeded, we start to delete chat messages from the top and add new chat messages from the bottom, one message at a time. This offset limit for maximum number of chat messages can be configured, but should be a constant. If we keep on adding chat messages and don't set any limit, then after a time our react application will start hanging up due to exhausting all the memory allocated to it by the browser.

// chatSlice.js
import { createSlice } from "@reduxjs/toolkit";
import { OFFSET_LIVE_CHAT } from "../utils/constants";

const chatSlice = createSlice({
  name: "chat",
  initialState: {
    messages: [],
  },
  reducers: {
    addMessage: (state, actions) => {
      state.messages.splice(OFFSET_LIVE_CHAT, 1);
      state.messages.unshift(actions.payload);
    },
  },
});

export const { addMessage } = chatSlice.actions;

export default chatSlice.reducer;

It is in the searchSlice, where we store and maintain the cache for search queries and suggestions.

// searchSlice.js
import { createSlice } from "@reduxjs/toolkit";

const searchSlice = createSlice({
  name: "search",
  initialState: {},
  reducers: {
    cacheResults: (state, actions) => {
      state = Object.assign(state, actions.payload);
    },
  },
});

export const { cacheResults } = searchSlice.actions;

export default searchSlice.reducer;

Light and dark themes

We have implemented light and dark themes by coditionally using different shadows/borders, text colors and background colors for depending on which theme is selected:

Here's how dark theme looks like:

Beautiful, right ?

Continuous Deployment

We have implemented continuous deployment by setting up a github workflow. Once we push any code in the main branch, our workflow runs a few actions that create a build and then push the code into our server on hostinger.

# .github/workflows/deployHPanel.yml
name: πŸš€ Deploy website to Hostinger on push
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  workflow_dispatch:
jobs:
  web-deploy:
    name: πŸŽ‰ Deploy
    runs-on: ubuntu-latest
    steps:
      - name: 🚚 Get latest code
        uses: actions/checkout@v4
        with:
          fetch-depth: 2
      - name: Use Node.js 20
        uses: actions/setup-node@v4
        with:
          node-version: 20
      - name: πŸ”¨ Build Project
        run: |
          npm install
          CI=false REACT_APP_GOOGLE_API_KEY=${{ secrets.GOOGLE_API_KEY }} npm run build
      - name: List output files
        run: find build/ -print
      - name: πŸ“‚ Sync files
        uses: SamKirkland/FTP-Deploy-Action@v4.3.4
        with:
          server: ${{ secrets.FTP_SERVER }}
          username: ${{ secrets.FTP_USERNAME }}
          password: ${{ secrets.FTP_PASSWORD }}
          local-dir: build/
          server-dir: youtube/

You can access this project on: Github and Live.

Optional Reading

Let's explore a few theoretical concepts as well, while we're here:

useMemo

useMemo is a React Hook that lets you cache the result of a calculation between re-renders.

const cachedValue = useMemo(calculateValue, dependencies)

useCallback

useCallback is a React Hook that lets you cache a function definition between re-renders.

const cachedFn = useCallback(fn, dependencies)

useRef

useRef is a React Hook that lets you reference a value that’s not needed for rendering.

const ref = useRef(initialValue)

That's all for now folks. See you in the next blog!

Β