Let's add a video content to our YouTube clone. In the last episode we created a static app shell with a basic branding and navigation. It's time to create a more dynamic part - home page with a responsive grid of media items.
We will start with a loading skeleton during data fetching phase. When the data are loaded we will display a beautiful thumbnail with all the needed video and channel metadata.
We will also implement some more advanced things like infinite scroll using TanStack Query and React Intersection Observer, as well as an inline media media player.
Last but not least, we will introduce a bonus section, where we will be doing some improvements, small experiments and bug fixes. This time we will fix a small bug related to the masthead position.
Media Skeleton
Skeleton is a placeholder preview of your content, displayed before the data gets loaded.
In our case it shows the basic layout of the media item with a thumbnail, channel avatar and metadata sections.
web/app/Home/MediaSkeleton/MediaSkeleton.tsx
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Skeleton from "@mui/material/Skeleton";
import Typography from "@mui/material/Typography";
interface Props {
animation?: "wave" | "pulse" | false;
}
export function MediaSkeleton({ animation = false }: Props) {
return (
<Stack spacing={1.5}>
<Skeleton
variant="rectangular"
animation={animation}
sx={{
borderRadius: 3,
height: "inherit",
aspectRatio: "16 / 9",
}}
/>
<Stack direction="row" spacing={1}>
<Box>
<Skeleton
variant="circular"
width={36}
height={36}
animation={animation}
/>
</Box>
<Stack width="100%">
<Typography variant="subtitle1">
<Skeleton variant="text" width="100%" animation={animation} />
</Typography>
<Typography variant="subtitle2">
<Skeleton variant="text" width="50%" animation={animation} />
</Typography>
</Stack>
</Stack>
</Stack>
);
}
web/app/Home/MediaSkeleton/index.ts
export * from "./MediaSkeleton";
Home Page
Let's create a dedicated HomePage
component and use it on our default page.
We will use our MediaSkeleton
to show a data loading state of our home page. Let's create an array of media skeletons and display them. You will notice that we delegated the task of creating grid layout to another component called BrowseGrid
.
web/app/Home/HomePage.tsx
import { BrowseGrid } from "./BrowseGrid";
import { MediaSkeleton } from "./MediaSkeleton";
const PAGE_SIZE = 24;
export function HomePage() {
const skeleton = (
<>
{Array.from({ length: PAGE_SIZE }).map((_, index) => (
<MediaSkeleton key={index} />
))}
</>
);
return <BrowseGrid>{skeleton}</BrowseGrid>;
}
web/app/Home/index.tsx
export * from "./HomePage";
web/app/page.tsx
(diff)
-import Typography from "@mui/material/Typography";
+import { HomePage } from "./Home";
export default function Home() {
- return (
- <Typography component="h2" variant="h4">
- TODO: Home Page
- </Typography>
- );
+ return <HomePage />;
}
Browse Grid
Grid is a responsive layout that adapts to screen size and orientation.
We will use it to display our media skeletons and items and ensure that the number of columns adopts to the screen resolution.
You might have noticed that we are not passing an array of skeletons directly, but rather encapsulated in a single <>
(Fragment) element. It makes it harder to iterate over, but increases our component's flexibility.
To make our lives easier, we will use react-keyed-flatten-children
library
it will flatten nested arrays and React.Fragments into a regular, one-dimensional array while ensuring element and fragment keys are preserved, unique, and stable between renders.
Let's install that library and its dependencies
npm install react-keyed-flatten-children react-is@19
web/app/Home/BrowseGrid/BrowseGrid.tsx
import * as React from "react";
import Grid from "@mui/material/Grid";
import flattenChildren from "react-keyed-flatten-children";
interface Props {
children: React.ReactNode;
}
export function BrowseGrid({ children }: Props) {
return (
<Grid container rowSpacing={4} columnSpacing={2}>
{React.Children.map(flattenChildren(children), (child) => (
<Grid size={{ xs: 12, sm: 6, md: 6, lg: 3, xl: 3, xxl: 3, xxxl: 3 }}>
{child}
</Grid>
))}
</Grid>
);
}
web/app/Home/BrowseGrid/index.ts
export * from "./BrowseGrid";
The final result looks like this
GitHub: feat(home): browse grid, media skeleton (#7)
Data
Before we display the grid of media items, we have to define our data models (video and channels) and create mock data.
Data models
web/app/Home/video.ts
import { Channel } from "./channel";
export interface Video {
id: string;
title: string;
thumbnail: string;
duration: string;
url: string;
publishedAt: string;
channel: Channel;
}
web/app/Home/channel.ts
export interface Channel {
id: string;
avatar: string;
name: string;
}
Mock data
All the mock data are based on the content generated by AI in the Generative AI Videos using Bedrock, Polly, iMovie - FakeTube #2 episode. Because we uploaded all of the generated videos to the YouTube, we can use metadata like id and publishedAt directly from there. Finally we will copy channel avatar, video thumbnails and files to the Next.js public directory.
web/app/Home/channels.data.ts
import { Channel } from "./channel";
export const CHANNELS: Channel[] = [
{
id: "AmazonNovaReel",
avatar: "/channels/AmazonNovaReel/AmazonNovaReel.png",
name: "Amazon Nova Reel",
},
];
web/app/Home/videos.data.ts
import { CHANNELS } from "./channels.data";
import { Video } from "./video";
const channel = CHANNELS[0];
export const VIDEOS: Video[] = [
{
id: "q9Gm7a6Wwjk",
title: "The Amazing World of Octopus!",
thumbnail: "/videos/q9Gm7a6Wwjk/q9Gm7a6Wwjk.png",
duration: "PT0M6.214542S",
url: "/videos/q9Gm7a6Wwjk/q9Gm7a6Wwjk.mp4",
publishedAt: "2025-03-03T15:58:23Z",
channel,
},
{
id: "QYUGZ3ueoHQ",
title: "Magic Wheels: The Future of Cars",
thumbnail: "/videos/QYUGZ3ueoHQ/QYUGZ3ueoHQ.png",
duration: "PT0M6.047708S",
url: "/videos/QYUGZ3ueoHQ/QYUGZ3ueoHQ.mp4",
publishedAt: "2025-03-03T14:22:54Z",
channel,
},
{
id: "SkrDa20qGGo",
title: "Dancing Pets: A Fun Animal Show",
thumbnail: "/videos/SkrDa20qGGo/SkrDa20qGGo.png",
duration: "PT0M6.047708S",
url: "/videos/SkrDa20qGGo/SkrDa20qGGo.mp4",
publishedAt: "2025-03-04T15:13:16Z",
channel,
},
{
id: "snI0xHnk9vw",
title: "Learning with Fun: The Magic of Numbers",
thumbnail: "/videos/snI0xHnk9vw/snI0xHnk9vw.png",
duration: "PT0M6.047708S",
url: "/videos/snI0xHnk9vw/snI0xHnk9vw.mp4",
publishedAt: "2025-03-04T15:53:08Z",
channel,
},
{
id: "tO-6SL2drHA",
title: "Learning with Fun: The Magic of Science",
thumbnail: "/videos/tO-6SL2drHA/tO-6SL2drHA.png",
duration: "PT0M6.047708S",
url: "/videos/tO-6SL2drHA/tO-6SL2drHA.mp4",
publishedAt: "2025-03-04T16:10:52Z",
channel,
},
{
id: "51KK6cQwqdo",
title: "Desert Motorcycle Adventure",
thumbnail: "/videos/51KK6cQwqdo/51KK6cQwqdo.png",
duration: "PT0M6.047708S",
url: "/videos/51KK6cQwqdo/51KK6cQwqdo.mp4",
publishedAt: "2025-03-04T16:37:57Z",
channel,
},
{
id: "6Pz1MlphyvA",
title: "Amazing Soccer Tricks",
thumbnail: "/videos/6Pz1MlphyvA/6Pz1MlphyvA.png",
duration: "PT0M6.047708S",
url: "/videos/6Pz1MlphyvA/6Pz1MlphyvA.mp4",
publishedAt: "2025-03-04T16:54:19Z",
channel,
},
{
id: "rX4LkHQLKSw",
title: "Tennis Magic: The Spin of Champions",
thumbnail: "/videos/rX4LkHQLKSw/rX4LkHQLKSw.png",
duration: "PT0M6.047708S",
url: "/videos/rX4LkHQLKSw/rX4LkHQLKSw.mp4",
publishedAt: "2025-03-04T17:12:16Z",
channel,
},
{
id: "mYiQU9_bvAA",
title: "Mud Monster Truck Adventure",
thumbnail: "/videos/mYiQU9_bvAA/mYiQU9_bvAA.png",
duration: "PT0M6.047708S",
url: "/videos/mYiQU9_bvAA/mYiQU9_bvAA.mp4",
publishedAt: "2025-03-05T15:47:29Z",
channel,
},
{
id: "1ccSDKMvpGA",
title: "Exploring the Magic of Motorhomes",
thumbnail: "/videos/1ccSDKMvpGA/1ccSDKMvpGA.png",
duration: "PT0M6.047708S",
url: "/videos/1ccSDKMvpGA/1ccSDKMvpGA.mp4",
publishedAt: "2025-03-05T16:01:54Z",
channel,
},
{
id: "8ibeIVJKsYQ",
title: "Rocket Science: Blast Off!",
thumbnail: "/videos/8ibeIVJKsYQ/8ibeIVJKsYQ.png",
duration: "PT0M6.047708S",
url: "/videos/8ibeIVJKsYQ/8ibeIVJKsYQ.mp4",
publishedAt: "2025-03-05T16:20:43Z",
channel,
},
{
id: "PcxHBLxkNuA",
title: "The Magic of Magnets",
thumbnail: "/videos/PcxHBLxkNuA/PcxHBLxkNuA.png",
duration: "PT0M6.047708S",
url: "/videos/PcxHBLxkNuA/PcxHBLxkNuA.mp4",
publishedAt: "2025-03-05T16:30:52Z",
channel,
},
{
id: "j5az8g8QEZ4",
title: "Snail's Slow and Steady Adventure",
thumbnail: "/videos/j5az8g8QEZ4/j5az8g8QEZ4.png",
duration: "PT0M6.047708S",
url: "/videos/j5az8g8QEZ4/j5az8g8QEZ4.mp4",
publishedAt: "2025-03-05T16:44:19Z",
channel,
},
{
id: "j4aY8zGqcLQ",
title: "Rattlesnake's Dance: A Wild Adventure",
thumbnail: "/videos/j4aY8zGqcLQ/j4aY8zGqcLQ.png",
duration: "PT0M6.047708S",
url: "/videos/j4aY8zGqcLQ/j4aY8zGqcLQ.mp4",
publishedAt: "2025-03-05T16:56:10Z",
channel,
},
{
id: "ZG5ixKK6ABs",
title: "Classic Golf: The Art of the Swing",
thumbnail: "/videos/ZG5ixKK6ABs/ZG5ixKK6ABs.png",
duration: "PT0M6.047708S",
url: "/videos/ZG5ixKK6ABs/ZG5ixKK6ABs.mp4",
publishedAt: "2025-03-05T17:18:03Z",
channel,
},
{
id: "cJanxpcCwq0",
title: "Retro Hockey: A Blast from the Past",
thumbnail: "/videos/cJanxpcCwq0/cJanxpcCwq0.png",
duration: "PT0M6.047708S",
url: "/videos/cJanxpcCwq0/cJanxpcCwq0.mp4",
publishedAt: "2025-03-05T17:34:12Z",
channel,
},
{
id: "7qgegT-6tq8",
title: "Exploring Magical Festivals",
thumbnail: "/videos/7qgegT-6tq8/7qgegT-6tq8.png",
duration: "PT0M6.047708S",
url: "/videos/7qgegT-6tq8/7qgegT-6tq8.mp4",
publishedAt: "2025-03-06T15:15:29Z",
channel,
},
{
id: "wCYxsFwXVAk",
title: "Adventure Awaits: Mountain Hiking",
thumbnail: "/videos/wCYxsFwXVAk/wCYxsFwXVAk.png",
duration: "PT0M6.047708S",
url: "/videos/wCYxsFwXVAk/wCYxsFwXVAk.mp4",
publishedAt: "2025-03-06T15:30:21Z",
channel,
},
{
id: "aD5_u3q6KUM",
title: "City Adventure: Walking Tour",
thumbnail: "/videos/aD5_u3q6KUM/aD5_u3q6KUM.png",
duration: "PT0M6.047708S",
url: "/videos/aD5_u3q6KUM/aD5_u3q6KUM.mp4",
publishedAt: "2025-03-06T15:52:52Z",
channel,
},
{
id: "aFwvWFVIUww",
title: "Live from the Big Conference!",
thumbnail: "/videos/aFwvWFVIUww/aFwvWFVIUww.png",
duration: "PT0M6.047708S",
url: "/videos/aFwvWFVIUww/aFwvWFVIUww.mp4",
publishedAt: "2025-03-06T16:06:33Z",
channel,
},
{
id: "zGl4juMoUow",
title: "RTS Battle: Strategy in Action",
thumbnail: "/videos/zGl4juMoUow/zGl4juMoUow.png",
duration: "PT0M6.047708S",
url: "/videos/zGl4juMoUow/zGl4juMoUow.mp4",
publishedAt: "2025-03-06T16:20:51Z",
channel,
},
{
id: "cCps60RZP4g",
title: "Retro Platformer Adventure",
thumbnail: "/videos/cCps60RZP4g/cCps60RZP4g.png",
duration: "PT0M6.047708S",
url: "/videos/cCps60RZP4g/cCps60RZP4g.mp4",
publishedAt: "2025-03-06T16:34:34Z",
channel,
},
{
id: "LvQMMXWXOvE",
title: "Chess Showdown: The Ultimate Battle",
thumbnail: "/videos/LvQMMXWXOvE/LvQMMXWXOvE.png",
duration: "PT0M6.047708S",
url: "/videos/LvQMMXWXOvE/LvQMMXWXOvE.mp4",
publishedAt: "2025-03-06T16:54:22Z",
channel,
},
{
id: "z-dBt8MpAnA",
title: "Chess Showdown: The Ultimate Battle",
thumbnail: "/videos/z-dBt8MpAnA/z-dBt8MpAnA.png",
duration: "PT0M6.047708S",
url: "/videos/z-dBt8MpAnA/z-dBt8MpAnA.mp4",
publishedAt: "2025-03-06T17:16:39Z",
channel,
},
{
id: "k1DF_Rcan6M",
title: "Standing Up for Change: Peaceful Road Block",
thumbnail: "/videos/k1DF_Rcan6M/k1DF_Rcan6M.png",
duration: "PT0M6.047708S",
url: "/videos/k1DF_Rcan6M/k1DF_Rcan6M.mp4",
publishedAt: "2025-03-07T14:05:46Z",
channel,
},
{
id: "SJOCLMEuoh0",
title: "Tree Guardians: Protecting Nature's Champions",
thumbnail: "/videos/SJOCLMEuoh0/SJOCLMEuoh0.png",
duration: "PT0M6.047708S",
url: "/videos/SJOCLMEuoh0/SJOCLMEuoh0.mp4",
publishedAt: "2025-03-07T14:20:26Z",
channel,
},
{
id: "M8V1FcKde2g",
title: "Street Volunteers: Collecting for a Cause",
thumbnail: "/videos/M8V1FcKde2g/M8V1FcKde2g.png",
duration: "PT0M6.047708S",
url: "/videos/M8V1FcKde2g/M8V1FcKde2g.mp4",
publishedAt: "2025-03-07T14:36:03Z",
channel,
},
{
id: "saYIayqn6I0",
title: "Charity Run: Running Together for a Cause",
thumbnail: "/videos/saYIayqn6I0/saYIayqn6I0.png",
duration: "PT0M6.047708S",
url: "/videos/saYIayqn6I0/saYIayqn6I0.mp4",
publishedAt: "2025-03-07T14:48:26Z",
channel,
},
{
id: "NzB9W_14tgE",
title: "Hair Magic: A Girl's New Haircut",
thumbnail: "/videos/NzB9W_14tgE/NzB9W_14tgE.png",
duration: "PT0M6.047708S",
url: "/videos/NzB9W_14tgE/NzB9W_14tgE.mp4",
publishedAt: "2025-03-07T15:01:18Z",
channel,
},
{
id: "RQmLNnELeFQ",
title: "Fashion Forward: Catwalk Chic",
thumbnail: "/videos/RQmLNnELeFQ/RQmLNnELeFQ.png",
duration: "PT0M6.047708S",
url: "/videos/RQmLNnELeFQ/RQmLNnELeFQ.mp4",
publishedAt: "2025-03-07T15:13:21Z",
channel,
},
{
id: "8tS7B-c0b_8",
title: "Easy Soup Cooking Fun",
thumbnail: "/videos/8tS7B-c0b_8/8tS7B-c0b_8.png",
duration: "PT0M6.047708S",
url: "/videos/8tS7B-c0b_8/8tS7B-c0b_8.mp4",
publishedAt: "2025-03-07T15:42:59Z",
channel,
},
{
id: "EoptO2hf3tY",
title: "How to Use a Yo Yo: Fun Tricks for Beginners",
thumbnail: "/videos/EoptO2hf3tY/EoptO2hf3tY.png",
duration: "PT0M6.047708S",
url: "/videos/EoptO2hf3tY/EoptO2hf3tY.mp4",
publishedAt: "2025-03-07T16:08:48Z",
channel,
},
];
web
├── public
│ ├── channels
│ │ ├── AmazonNovaReel
│ │ │ ├── AmazonNovaReel.png
│ ├── vidoes
│ │ ├── q9Gm7a6Wwjk
│ │ │ ├── q9Gm7a6Wwjk.png
│ │ │ ├── q9Gm7a6Wwjk.mp4
│ │ ├── QYUGZ3ueoHQ
│ │ │ ├── QYUGZ3ueoHQ.png
│ │ │ ├── QYUGZ3ueoHQ.mp4
| ├── ...
| ...
GitHub: feat(home): mock data (#7)
Media Item
Let's shift our focus to the most important component, which will represent video on the home page, that is Media Item. It will consist of the two main parts: Video Thumbnail and Video Details.
web/app/Home/MediaItem/MediaItem.tsx
import Box from "@mui/material/Box";
import { Video } from "../video";
import { VideoDetails } from "./VideoDetails";
import { VideoThumbnail } from "./VideoThumbnail";
interface Props {
video: Video;
}
export function MediaItem({ video }: Props) {
return (
<Box
sx={{
width: "100%",
mb: 2,
display: "block",
aspectRatio: "16 / 9",
}}
>
<VideoThumbnail video={video} />
<VideoDetails video={video} />
</Box>
);
}
web/app/Home/MediaItem/index.tsx
export * from "./MediaItem";
Video Thumbnail
This component will obviously show an image representation of our video, which is called a thumbnail.
Additionally it should display the duration of the video in the bottom-right corner. We will extract this functionality to a Time Status component.
web/app/Home/MediaItem/VideoThumbnail/VideoThumbnail.tsx
import Box from "@mui/material/Box";
import { Video } from "../../video";
import { TimeStatus } from "../TimeStatus";
interface Props {
video: Video;
}
export function VideoThumbnail({ video }: Props) {
return (
<Box
sx={{
width: "100%",
height: "100%",
position: "relative",
mb: 1.5,
}}
>
<Box
component="img"
alt=""
src={video.thumbnail}
sx={{
borderRadius: 3,
aspectRatio: "16 / 9",
maxWidth: "100%",
display: "block",
objectFit: "cover",
}}
/>
<TimeStatus time={video.duration} />
</Box>
);
}
web/app/Home/MediaItem/VideoThumbnail/index.tsx
export * from "./VideoThumbnail";
Time Status
It's a slightly customized Chip component. We will have to convert ISO_8601 duration format into human readable format like 0:07
. We can easily do that with the Luxon library.
Let's first install needed dependencies
npm install luxon
npm i --save-dev @types/luxon
web/app/Home/MediaItem/TimeStatus/TimeStatus.tsx
"use client";
import * as React from "react";
import Chip from "@mui/material/Chip";
import { Duration } from "luxon";
interface Props {
time: string;
}
export function TimeStatus({ time }: Props) {
const duration: string = React.useMemo(() => {
const seconds = Duration.fromISO(time).as("seconds");
return Duration.fromObject({ seconds: Math.ceil(seconds) }).toFormat(
"m:ss"
);
}, [time]);
return (
<Chip
size="small"
label={duration}
sx={{
position: "absolute",
bottom: (theme) => theme.spacing(1.5),
right: (theme) => theme.spacing(1),
borderRadius: 1,
color: "white",
fontWeight: 600,
backgroundColor: "#00000099",
".MuiChip-label": {
padding: 0.5,
},
}}
/>
);
}
web/app/Home/MediaItem/TimeStatus/index.tsx
export * from "./TimeStatus";
Video Details
The second part of the MediaItem
component is responsible for displaying video and channel metadata like channel avatar and name, video title and publish date. There is some work related to its layout, plus we will have to convert the video publish date to a more convenient relative format like 1 month ago
. The good news is that we can also do that using Luxon.
web/app/Home/MediaItem/VideoDetails/VideoDetails.tsx
"use client";
import * as React from "react";
import Avatar from "@mui/material/Avatar";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography";
import { DateTime } from "luxon";
import { Video } from "../../video";
interface Props {
video: Video;
}
export function VideoDetails({ video }: Props) {
const date: string | null = React.useMemo(
() =>
DateTime.fromISO(video.publishedAt).toRelative({
unit: ["years", "months", "weeks", "days"],
}),
[video.publishedAt]
);
return (
<Box sx={{ display: "flex", alignItems: "flex-start" }}>
<Avatar
src={video.channel.avatar}
alt={video.channel.name}
sx={{
width: (theme) => theme.spacing(4.5),
height: (theme) => theme.spacing(4.5),
marginRight: (theme) => theme.spacing(1),
}}
/>
<Stack>
<Typography
variant="subtitle2"
component="h3"
fontSize={16}
gutterBottom
mb={0.5}
>
{video.title}
</Typography>
<Stack>
<Tooltip
title={video.channel.name}
placement="top"
disableInteractive
>
<Typography
variant="caption"
fontSize={14}
sx={{
color: "#606060",
"&:hover": {
color: (theme) => theme.palette.text.primary,
},
}}
>
{video.channel.name}
</Typography>
</Tooltip>
</Stack>
<Typography
variant="caption"
fontSize={14}
sx={{
color: "#606060",
}}
>
{date}
</Typography>
</Stack>
</Box>
);
}
web/app/Home/MediaItem/VideoDetails/index.tsx
export * from "./VideoDetails";
Home Page
The media item component is ready, so it's time to use it on our home page. When loading is done (for now we will just mock it using loading
variable which is set to false
), we will pass array of MediaItems
based on our mock data to the BrowseGrid
;
web/app/Home/HomePage.tsx
(diff)
import { BrowseGrid } from "./BrowseGrid";
+import { MediaItem } from "./MediaItem";
import { MediaSkeleton } from "./MediaSkeleton";
+import { VIDEOS } from "./videos.data";
const PAGE_SIZE = 24;
export function HomePage() {
+ const loading = false;
const skeleton = (
<>
{Array.from({ length: PAGE_SIZE }).map((_, index) => (
<MediaSkeleton key={index} />
))}
</>
);
- return <BrowseGrid>{skeleton}</BrowseGrid>;
+ return (
+ <BrowseGrid>
+ {loading ? (
+ skeleton
+ ) : (
+ <>
+ {VIDEOS.map((video) => (
+ <MediaItem video={video} key={video.id} />
+ ))}
+ </>
+ )}
+ </BrowseGrid>
+ );
}
web/app/Home/HomePage.tsx
import { BrowseGrid } from "./BrowseGrid";
import { MediaItem } from "./MediaItem";
import { MediaSkeleton } from "./MediaSkeleton";
import { VIDEOS } from "./videos.data";
const PAGE_SIZE = 24;
export function HomePage() {
const loading = false;
const skeleton = (
<>
{Array.from({ length: PAGE_SIZE }).map((_, index) => (
<MediaSkeleton key={index} />
))}
</>
);
return (
<BrowseGrid>
{loading ? (
skeleton
) : (
<>
{VIDEOS.map((video) => (
<MediaItem video={video} key={video.id} />
))}
</>
)}
</BrowseGrid>
);
}
GitHub: feat(home): media item (#8)
Fetching data
So far we just directly rendered our mock data on the home page. In the next episodes we will store data in the database and build an API to fetch them. What we can do right now is to prepare frontend for scenario like that, but still return mock data from web/app/Home/videos.data.ts
. We will use TansStack Query for that. It's asynchronous state management library, which provides very handy abstraction layer for operations like that, including infinite queries.
Setup
TansStack Query provides quite good installation and quick start documentation.
Let's install the needed dependency and configure the query client provider.
npm install @tanstack/react-query
web/app/providers.tsx
(diff)
"use client";
import CssBaseline from "@mui/material/CssBaseline";
import { AppRouterCacheProvider } from "@mui/material-nextjs/v15-appRouter";
import { ThemeProvider } from "@mui/material/styles";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { theme } from "./theme";
+const queryClient = new QueryClient();
export function Providers({
children,
deviceType,
}: Readonly<{
children: React.ReactNode;
deviceType: string;
}>) {
return (
<AppRouterCacheProvider>
+ <QueryClientProvider client={queryClient}>
<ThemeProvider theme={theme(deviceType)}>
<CssBaseline />
{children}
</ThemeProvider>
+ </QueryClientProvider>
</AppRouterCacheProvider>
);
}
web/app/providers.tsx
"use client";
import CssBaseline from "@mui/material/CssBaseline";
import { AppRouterCacheProvider } from "@mui/material-nextjs/v15-appRouter";
import { ThemeProvider } from "@mui/material/styles";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import theme from "./theme";
const queryClient = new QueryClient();
export function Providers({
children,
deviceType,
}: Readonly<{
children: React.ReactNode;
deviceType: string;
}>) {
return (
<AppRouterCacheProvider>
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={theme(deviceType)}>
<CssBaseline />
{children}
</ThemeProvider>
</QueryClientProvider>
</AppRouterCacheProvider>
);
}
Infinite scroll
Next step is to define our own useListVideos
hook, which will be responsible for fetching page of videos, with some simulated delay.
It will use useInfiniteQuery under the hood, which was designed for UI patterns which automatically load more data.
Rendering lists that can additively "load more" data onto an existing set of data or "infinite scroll" is also a very common UI pattern. TanStack Query supports a useful version of useQuery called useInfiniteQuery for querying these types of lists.
We will implement pagination and introduce 1 second delay to simulate fetching data from the backend and show skeleton, which we already implemented.
web/app/Home/useListVideos.tsx
import { useInfiniteQuery } from "@tanstack/react-query";
import { Page } from "./page";
import { Video } from "./video";
import { VIDEOS } from "./videos.data";
export const PAGE_SIZE = 24;
const DELAY_MS = 1000;
const fetch = async (
currentPage: number,
pageSize: number = PAGE_SIZE
): Promise<Page<Video>> => {
await new Promise((resolve) => setTimeout(resolve, DELAY_MS));
const start = currentPage * pageSize;
const end = start + pageSize;
return {
items: VIDEOS.slice(start, end),
currentPage,
hasNextPage: end < VIDEOS.length,
};
};
export const useListVideos = () => {
return useInfiniteQuery({
queryKey: ["listVideos"],
queryFn: ({ pageParam: page }) => fetch(page),
initialPageParam: 0,
getNextPageParam: (lastPage) =>
lastPage.hasNextPage ? lastPage.currentPage + 1 : undefined,
});
};
web/app/Home/page.ts
export interface Page<T> {
items: T[];
currentPage: number;
hasNextPage: boolean;
}
Home Page
It's time to use our useListVideos
hook on the home page. Thanks to TanStack Query it not only returns paginated data, but also some useful statuses of the operation (e.g. pending
) or helper functions (e.g. fetchNextPage
).
We will the show skeleton during the initial page load, but also when we will be fetching the next page. In that case we will also show a small circular progress, like they do on YouTube.
We want to trigger fetching the next page when we scroll down and reach end of our list of media items. Let's use React Intersection Observer for a trigger like that. We will add a Box
just after BrowseGrid
. When that box is visible (in the view), it will change the state of the inView
variable to true, which will fetch the next page as a consequence.
npm install react-intersection-observer
web/app/Home/HomePage.tsx
(diff)
+"use client";
+
+import React from "react";
+import Box from "@mui/material/Box";
+import CircularProgress from "@mui/material/CircularProgress";
+import { useInView } from "react-intersection-observer";
+
import { BrowseGrid } from "./BrowseGrid";
import { MediaItem } from "./MediaItem";
import { MediaSkeleton } from "./MediaSkeleton";
-import { VIDEOS } from "./videos.data";
+import { PAGE_SIZE, useListVideos } from "./useListVideos";
-
-const PAGE_SIZE = 24;
export function HomePage() {
- const loading = false;
+ const { ref, inView } = useInView();
+ const {
+ status,
+ data,
+ isFetching,
+ isFetchingNextPage,
+ fetchNextPage,
+ hasNextPage,
+ } = useListVideos();
+
+ const videos = data?.pages.flatMap((page) => page.items);
+
+ React.useEffect(() => {
+ if (inView) {
+ if (!isFetching && !isFetchingNextPage && hasNextPage) {
+ fetchNextPage();
+ }
+ }
+ }, [inView, isFetching, isFetchingNextPage, hasNextPage, fetchNextPage]);
const skeleton = (
<>
{Array.from({ length: PAGE_SIZE }).map((_, index) => (
<MediaSkeleton key={index} />
))}
</>
);
return (
- <BrowseGrid>
- {loading ? (
- skeleton
- ) : (
- <>
- {VIDEOS.map((video) => (
- <MediaItem video={video} key={video.id} />
- ))}
- </>
- )}
- </BrowseGrid>
+ <>
+ <BrowseGrid>
+ {status === "pending" ? (
+ skeleton
+ ) : (
+ <>
+ {(videos || []).map((video) => (
+ <MediaItem key={video.id} video={video} />
+ ))}
+ {isFetchingNextPage && skeleton}
+ </>
+ )}
+ </BrowseGrid>
+ <Box ref={ref} sx={{ textAlign: "center", height: 2, my: 2 }}>
+ {isFetchingNextPage && <CircularProgress />}
+ </Box>
+ </>
);
}
web/app/Home/HomePage.tsx
"use client";
import React from "react";
import Box from "@mui/material/Box";
import CircularProgress from "@mui/material/CircularProgress";
import { useInView } from "react-intersection-observer";
import { BrowseGrid } from "./BrowseGrid";
import { MediaItem } from "./MediaItem";
import { MediaSkeleton } from "./MediaSkeleton";
import { PAGE_SIZE, useListVideos } from "./useListVideos";
export function HomePage() {
const { ref, inView } = useInView();
const {
status,
data,
isFetching,
isFetchingNextPage,
fetchNextPage,
hasNextPage,
} = useListVideos();
const videos = data?.pages.flatMap((page) => page.items);
React.useEffect(() => {
if (inView) {
if (!isFetching && !isFetchingNextPage && hasNextPage) {
fetchNextPage();
}
}
}, [inView, isFetching, isFetchingNextPage, hasNextPage, fetchNextPage]);
const skeleton = (
<>
{Array.from({ length: PAGE_SIZE }).map((_, index) => (
<MediaSkeleton key={index} />
))}
</>
);
return (
<>
<BrowseGrid>
{status === "pending" ? (
skeleton
) : (
<>
{(videos || []).map((video) => (
<MediaItem key={video.id} video={video} />
))}
{isFetchingNextPage && skeleton}
</>
)}
</BrowseGrid>
<Box ref={ref} sx={{ textAlign: "center", height: 2, my: 2 }}>
{isFetchingNextPage && <CircularProgress />}
</Box>
</>
);
}
GitHub: feat(home): fetching data (#8)
Video player
There are two ways to play a YouTube video. The first one is just to click a media item. It will take you to a watch page, with a video player which will automatically start playback. We don't have this page yet, but we will build it in the coming episodes.
The second one is to hover over a media item. It will replace a video thumbnail with an inline video player and automatically start it. This is what we are going to implement right now.
This inline video player will not have any controls, except for a mute/unmute button. State of audio will be lifted up to the media item in order to preserve it every time you activate an inline video player.
We will also implement a playback progress indicator using a determinate variant of a linear progress component.
TimeStatus
in the bottom-right corner will be more dynamic. It will be updated in the real-time and show a remaining video duration
web/app/Home/MediaItem/VideoPlayer/VideoPlayer.tsx
"use client";
import * as React from "react";
import Box from "@mui/material/Box";
import LinearProgress from "@mui/material/LinearProgress";
import IconButton from "@mui/material/IconButton";
import Tooltip from "@mui/material/Tooltip";
import VolumeOffIcon from "@mui/icons-material/VolumeOff";
import VolumeUpOutlinedIcon from "@mui/icons-material/VolumeUpOutlined";
import { Duration } from "luxon";
import { styled } from "@mui/material/styles";
import { Video } from "../../video";
import { TimeStatus } from "../TimeStatus";
interface Props {
video: Pick<Video, "duration" | "url">;
isMuted: boolean;
toggleMute: () => void;
}
const StyledVideo = styled("video")({
aspectRatio: "16 / 9",
maxWidth: "100%",
display: "block",
objectFit: "cover",
});
export function VideoPlayer({ video, isMuted, toggleMute }: Props) {
const [progress, setProgress] = React.useState(0);
const [remainingDuration, setRemainingDuration] = React.useState(
video.duration
);
const handleTimeUpdate = ({
currentTarget: { currentTime, duration },
}: React.SyntheticEvent<HTMLVideoElement>) => {
updateProgressIndicator(currentTime, duration);
updateRemainingDuration(currentTime);
};
const updateProgressIndicator = (currentTime: number, duration: number) => {
const progressPercentage = (currentTime / duration) * 100;
setProgress(progressPercentage);
};
const updateRemainingDuration = (currentTime: number) => {
const totalDurationInSeconds = Duration.fromISO(video.duration).as(
"seconds"
);
const remainingTimeInSeconds = totalDurationInSeconds - currentTime;
setRemainingDuration(
Duration.fromObject({ seconds: remainingTimeInSeconds }).toISO()
);
};
return (
<Box
sx={{
width: "100%",
height: "100%",
position: "relative",
mb: 1.5,
}}
>
<StyledVideo
src={video.url}
autoPlay
loop
muted={isMuted}
preload="metadata"
controlsList="nodownload"
disablePictureInPicture
disableRemotePlayback
onTimeUpdate={handleTimeUpdate}
/>
<Tooltip
title={isMuted ? "Unmute" : "Mute"}
enterDelay={500}
disableInteractive
>
<IconButton
onClick={toggleMute}
sx={{
position: "absolute",
top: (theme) => theme.spacing(1),
right: (theme) => theme.spacing(1),
backgroundColor: "#00000099",
color: "white",
"&:hover": {
backgroundColor: "#00000099",
},
}}
>
{isMuted ? <VolumeOffIcon /> : <VolumeUpOutlinedIcon />}
</IconButton>
</Tooltip>
<Box
sx={{
position: "absolute",
height: 3,
left: 0,
right: 0,
bottom: 1,
}}
>
<LinearProgress variant="determinate" value={progress} color="error" />
</Box>
<TimeStatus time={remainingDuration} />
</Box>
);
}
web/app/Home/MediaItem/VideoPlayer/index.ts
export * from "./VideoPlayer";
web/app/Home/MediaItem/MediaItem.tsx
(diff)
+"use client";
+
+import * as React from "react";
import Box from "@mui/material/Box";
import { Video } from "../video";
import { VideoDetails } from "./VideoDetails";
+import { VideoPlayer } from "./VideoPlayer";
import { VideoThumbnail } from "./VideoThumbnail";
interface Props {
video: Video;
}
export function MediaItem({ video }: Props) {
+ const [isHovered, setIsHovered] = React.useState(false);
+
+ const [isMuted, setIsMuted] = React.useState(true);
+ const toggleMute = React.useCallback(() => {
+ setIsMuted((prev) => !prev);
+ }, []);
+
return (
<Box
sx={{
width: "100%",
mb: 2,
display: "block",
aspectRatio: "16 / 9",
}}
+ onMouseEnter={() => setIsHovered(true)}
+ onMouseLeave={() => setIsHovered(false)}
>
+ {isHovered ? (
+ <VideoPlayer video={video} isMuted={isMuted} toggleMute={toggleMute} />
+ ) : (
<VideoThumbnail video={video} />
+ )}
<VideoDetails video={video} />
</Box>
);
}
GitHub: feat(home): video player (#8)
Bonus
Next.js page
It seems that the name of our page model conflicts with Netx.js page.
app/Home/page.ts
Type error: Page "app/Home/page.ts" does not match the required types of a Next.js Page.
Let's rename web/app/Home/page.ts
file to web/app/Home/pagination.ts
. It should fix the issue.
GitHub: fix(home): model/nextjs page conflict (#7)
Masthead position bug fix
When we scroll down our home page - the masthead doesn't stay in its position. We didn't notice it before (in the last episode), because we didn't have enough content on the page. It turns out that we should have used sticky
instead of static
position.
web/app/AppShell/Masthead/Masthead.tsx
(diff)
export function Masthead({ onGuideButtonClick, showGuideButton }: Props) {
return (
- <AppBar elevation={0} position="static">
+ <AppBar elevation={0} position="sticky">
<Toolbar>
{showGuideButton && <GuideButton onClick={onGuideButtonClick} />}
<Logo />
</Toolbar>
</AppBar>
);
}
GitHub: fix(masthead): sticky position