영화 목록 페이지는 모듈화한 API와 React Query의 useInfiniteQuery, react-infinite-scroller 라이브러리를 사용해서 무한스크롤로 영화 목록을 구성한다.
// scr/pages/PopularMoviePage.tsx
import { useInfiniteQuery } from "react-query";
import InfiniteScroll from "react-infinite-scroller";
import { getPopularMovie } from "@/api/movieApi";
import { MovieDetail } from "./Home/Home";
const PopularMoviePage = (props: Props) => {
const { isLoading, isError, data, fetchNextPage, hasNextPage } =
useInfiniteQuery(
["popular"],
({ pageParam = 1 }) => getPopularMovie(pageParam),
{
getNextPageParam: (lastPage) => {
let page = lastPage.page;
if (lastPage.total_page === page) {
return false;
}
return page + 1;
},
}
);
if (isLoading) <h1>Loading...</h1>;
if (isError) <h1>Error</h1>;
return (
<Fragment>
<h2
style={{
marginLeft: "25px",
marginBottom: "50px",
textAlign: "center",
}}
>
Popular Movie
</h2>
<InfiniteScroll loadMore={() => fetchNextPage()} hasMore={hasNextPage}>
<MovieList>
{data?.pages?.map((page) => {
return page?.results.map((movie: MovieDetail) => (
<Fragment key={movie.id}>
<MovieCard movieData={movie} />
</Fragment>
));
})}
</MovieList>
</InfiniteScroll>
</Fragment>
);
};
Popular, Top Rated, Upcoming 각각 호출하는 API만 다르고 내용은 같다. 각 영화 목록의 MovieCard는 조금씩 다르다. Top Rated 페이지는 평점(vote_average)을 표시하고, Upcoming 페이지는 영화 개봉 날짜(release_date)를 표시한다.
// src/pages/TopRated/MovieCard.tsx
import { MovieDetail } from "@/pages/Home/Home";
import { Link } from "react-router-dom";
type MovieProps = {
movieData: MovieDetail;
};
const MovieCard = ({ movieData }: MovieProps) => {
const { id, title, poster_path, vote_average } = movieData;
return (
<Card>
<LinkDetail to={`/${id}`}>
<PosterWrapper>
{poster_path ? (
<PosterImg src={`https://image.tmdb.org/t/p/w500/${poster_path}`} />
) : (
<h1>No Image</h1>
)}
</PosterWrapper>
{<h4>{title}</h4>}
{<h4>⭐{vote_average}</h4>}
</LinkDetail>
</Card>
);
};
// src/pages/Upcoming/MovieCard.tsx
import { MovieDetail } from "@/pages/Home/Home";
import { Link } from "react-router-dom";
type MovieProps = {
movieData: MovieDetail;
};
const MovieCard = ({ movieData }: MovieProps) => {
const { id, title, poster_path, release_date } = movieData;
return (
<Card>
<LinkDetail to={`/${id}`}>
<PosterWrapper>
{poster_path ? (
<PosterImg src={`https://image.tmdb.org/t/p/w500/${poster_path}`} />
) : (
<h1>No Image</h1>
)}
</PosterWrapper>
{<h4>{title}</h4>}
{<h4>{release_date}</h4>}
</LinkDetail>
</Card>
);
};
영화 상세 페이지는 영화의 ID로 라우팅한다. API는 Detail, Trailer, Credits를 호출한다.
// src/api/movieDetail.ts
import { instance } from "./index";
export const getMovieDetail = async (id: string) => {
const response = await instance.get(`movie/${id}`);
return response.data;
};
export const getMovieTrailer = async (id: string) => {
const response = await instance.get(`movie/${id}/videos`);
return response.data;
};
export const getMovieCredits = async (id: string) => {
const response = await instance.get(`movie/${id}/credits`);
return response.data;
};
// src/pages/Detail/MovieDetailPage.tsx
import { getMovieDetail } from "@/api/movieDetail";
import { useQuery } from "react-query";
import { useParams } from "react-router-dom";
import TrailerButton from "./components/TrailerButton";
import Cast from "./components/Cast";
const MovieDetailPage = (props: Props) => {
const { id } = useParams() as { id: string };
const { isLoading, data, isError } = useQuery(["detail"], () =>
getMovieDetail(id)
);
if (isLoading) <h1>Loading...</h1>;
if (isError) <h1>Error ㅠㅠ</h1>;
return (
<DetailContainer>
<PosterContainer>
{data?.poster_path ? (
<img src={`https://image.tmdb.org/t/p/w500/${data.poster_path}`} />
) : (
""
)}
<TrailerButton />
</PosterContainer>
<InfoContainer>
<h2>{data?.title}</h2>
<h3>{data?.original_title}</h3>
<p>
{`⭐${data?.vote_average}`} {`🤩${data?.vote_count}`}
</p>
<p>
{data?.genres?.map((item: any) => (
<span key={item.id}>{item.name} </span>
))}
</p>
<p>{data?.overview}</p>
<Cast />
</InfoContainer>
</DetailContainer>
);
};
useParams()의 타입을 정하는데 오류가 발생했다. react-router-dom v6 이상인 경우 useParams()만 쓰더라도 타입이 string | undefined일거라고 예상해준다.

그런데 위와 같은 오류가 발생했다. 구글링해서 찾아본 결과
const { id } = useParams() as { id: string };
이렇게 사용하면 에러가 사라진다.
영화 포스터 밑에 트레일러 보기 버튼을 만들어 버튼을 누르면 화면 중앙에 유튜브로 트레일러가 나오게 했다. 화면 다른 곳을 클릭하면 트레일러 모달창이 닫힌다.
// src/pages/Detail/components/TrailerButton.tsx
import { getMovieTrailer } from "@/api/movieDetail";
import { useQuery } from "react-query";
import { useParams } from "react-router";
import TrailerModal from "./TrailerModal";
import styled from "styled-components";
type Props = {};
const TrailerButton = (props: Props) => {
const { id } = useParams() as { id: string };
const [openModal, setOpenModal] = useState<boolean>(false);
const { isLoading, data, isError } = useQuery(["trailer"], () =>
getMovieTrailer(id)
);
if (isLoading) <h1>Loading...</h1>;
if (isError) <h1>Error ㅠㅠ</h1>;
const trailerKey: string = data?.results[0]?.key;
return (
<ButtonWrapper>
{trailerKey && (
<button onClick={() => setOpenModal(true)}>트레일러 보기</button>
)}
<TrailerModal
trailerKey={trailerKey}
open={openModal && trailerKey}
onClose={() => setOpenModal(false)}
/>
</ButtonWrapper>
);
};
useState를 사용해서 버튼을 클릭하면 true값을 트레일러 모달 컴포넌트로 넘겨준다.
// src/pages/Detail/TrailerModal.tsx
import ReactPlayer from "react-player";
import styled from "styled-components";
type TrailerProps = {
trailerKey: string;
open: string | boolean;
onClose: () => void;
};
const TrailerModal = ({ trailerKey, open, onClose }: TrailerProps) => {
if (!open) return null;
return (
<ModalWrapper onClick={onClose}>
<Player>
<ReactPlayer
url={`https://www.youtube.com/watch?v=${trailerKey}`}
width="100%"
height="100%"
playing
controls
/>
</Player>
</ModalWrapper>
);
};
export default TrailerModal;
const ModalWrapper = styled.div`
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
`;
const Player = styled.div`
position: absolute;
top: 50%;
left: 50%;
height: 800px;
width: 100%;
max-width: 1100px;
transform: translate(-50%, -50%);
`;
open값이 true가 아니면 null값을 반환하게 해서 자동으로 트레일러 모달창이 열리지 않게 한다.
if (!open) return null;
그리고 position css를 이용해 트레일러 모달창이 화면 정중앙 맨 위에 나오게 한다.