useTransition(fetch) 소개
useTransition : 상태 업데이트를 낮은 우선 순위의 트랜지션으로 처리, 비동기 함수도 처리할 수 있도록 기능이 확장
ㄴ 리액트가 더 중요한 작업이 끝나고 여유가 있는 상태에서 처리한다는 의미
useTransition 훅의 기본적인 문법 형태
const [isPending, startTransition] = useTransition()
- isPending
- 실행 중인 트랜지션이 있으면 true, 없으면 false
- startTransition
- 코드를 트랜지션으로 실행할 수 있게 해주는 함수
- == 해당 함수로 작성한 코드는 낮은 우선순위로 처리 (여유가 있을 때 처리)
useTransition + 데이터 패칭 실전
// App.tsx
import axios from "axios";
import { useEffect, useState, useTransition } from "react";
interface Post {
id: number;
title: string;
views: number;
}
export default function App() {
const [posts, setPosts] = useState<Post[]>([]);
const [isPending, startTransition] = useTransition();
useEffect(() => {
startTransition(async () => {
const { data } = await axios.get("http://localhost:3000/posts");
setPosts(data);
});
}, []);
if (isPending) return <h3>loading...</h3>;
return (
<>
<h3>useTransition</h3>
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</>
);
}
^ isPending : true == 데이터 패칭 중
useActionState 소개
기존 React와 같은 클라이언트 기반 라이브러리에서는 form 태그의 action 속성을 직접적으로 사용하는 경우가 거의 X
∵ action은 서버측에서 처리되는 기능이기 때문
ㄴ action 속성을 활용하는 새로운 방식 등장
useActionState : form의 액션 결과를 기반으로 상태를 업데이트 할 수 있도록 도와줌
useActionState 훅의 기본적인 문법 형태
const [state, formAction, isPending] = useActionState(fn, initialState)
- state
- 현재 상태값
- formAction
- 폼 액션 함수
- action 속성에 활용
- isPending
- 액션 처리 중 여부
- fn
- 액션이 발생했을 때 실행할 함수
- 비동기 함수도 가능
- initialState
- 상태의 초기값을 지정
useActionState 실습 - 1
일반적으로 form 태그는 제출 버튼을 누르면, 브라우저가 페이지를 새로고침하면서 데이터를 서버로 전송
useActionState를 사용하여 <form action={formAction}>처럼 action 속성에 자바스크립트 함수를 바로 대입 가능
--> 제출 버튼을 누르면, 브라우저가 새로고침을 일으키지 못하도록 막은 후 주입한 formAction 함수를 비동기적으로 실행해 서버와 데이터를 주고받음
// App.tsx
import { useActionState } from "react";
export default function App() {
const [count, formAction, isPending] = useActionState(async (count) => {
await new Promise((resolve) => setTimeout(resolve, 2000));
return count + 1;
}, 0);
return (
<>
<form action={formAction}>
<h1>Count: {count}</h1>
<button type="submit">증가</button>
{isPending && <p>제출 중...</p>}
</form>
</>
);
}
useActionState 실습 - 2
// App.tsx
import { useActionState } from "react";
export default function App() {
const [count, formAction, isPending] = useActionState(
async (count: number, formData: FormData) => {
await new Promise((resolve) => setTimeout(resolve, 2000));
const amount = Number(formData.get("amount"));
return count + amount;
},
0,
);
return (
<>
<form action={formAction}>
<h1>Count: {count}</h1>
<input type="number" name="amount" />
<button type="submit" disabled={isPending}>
증가
</button>
{isPending && <p>제출 중...</p>}
</form>
</>
);
}
^ disabled={isPending} 트랜지션을 처리 중일 때는 해당 버튼을 비활성화
form action
<form action={async() => {...}}>
...
</form>
위와 같이 액션 속성에 함수만 직접 할당해서 사용하는 방법도 존재
ㄴ useActionState과 달리 자동으로 상태값이나 isPending과 같은 로딩 상태가 제공되지 않음
React 18에서의 form 컨트롤 방법
// App.tsx
import { useState } from "react";
export default function App() {
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const email = formData.get("email");
const pw = formData.get("pw");
// api
setIsLoading(true);
await new Promise((resolve) => setTimeout(resolve, 2000));
setIsLoading(false);
console.log(`Login Success ${email}/${pw}`);
};
return (
<>
<form onSubmit={handleSubmit}>
<input type="email" name="email" autoComplete="off" />
<input type="password" name="pw" />
<button type="submit" disabled={isLoading}>
로그인
</button>
</form>
</>
);
}
React 19 이상에서의 form 컨트롤 방법
// App.tsx
import { useState } from "react";
export default function App() {
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (formData: FormData) => {
const email = formData.get("email");
const pw = formData.get("pw");
// api
setIsLoading(true);
await new Promise((resolve) => setTimeout(resolve, 2000));
setIsLoading(false);
console.log(`Login Success ${email}/${pw}`);
};
return (
<>
<form action={handleSubmit}>
<input type="email" name="email" autoComplete="off" />
<input type="password" name="pw" />
<button type="submit" disabled={isLoading}>
로그인
</button>
</form>
</>
);
}
^ action 속성에 함수 직접 할당 가능
* useActionState 훅 방식 : 상태 및 진행 상태까지 관리하고 싶을 때 사용,
action 속성에 함수 할당 방식 : 단순한 작업을 빠르게 처리하고 싶을 때 사용
useFormStatus
useFormStatus : 컴포넌트가 분리되어 있는 상황에서도 상위 form에 대한 상태 정보를 가져올 수 있는 리액트 훅
useFormStatus 훅의 기본적인 문법 형태
const { pending, data, method, action } = useFormStatus()
- pending
- 액션 처리 중 여부
- data
- formData 객체 참조
- method
- GET, POST
- action
- 현재 실행 중인 액션 함수에 대한 참조값


// App.tsx
import Button from "./components/html/Button";
import Input from "./components/html/Input";
export default function App() {
const formAction = async (formData: FormData) => {
await new Promise((resolve) => setTimeout(resolve, 2000));
console.log(formData.get("email"));
console.log(formData.get("pw"));
};
return (
<>
<form action={formAction}>
<Input type="text" name="email" autoComplete="off" />
<Input type="password" name="pw" />
<Button type="submit">로그인</Button>
</form>
</>
);
}
// src/components/html/Input.tsx
type InputProps = React.ComponentPropsWithoutRef<"input">;
export default function Input(props: InputProps) {
return (
<>
<input {...props} />
</>
);
}
// src/components/html/Input.tsx
import { useFormStatus } from "react-dom";
type ButtonProps = React.ComponentPropsWithoutRef<"button">;
export default function Button({ children, ...props }: ButtonProps) {
const { pending, data, method, action } = useFormStatus();
console.log(pending);
console.log(data);
console.log(method);
console.log(action);
return (
<>
<button {...props} disabled={pending}>
{children}
</button>
{data && <p>Logging in with ID: {String(data.get("email"))}</p>}
</>
);
}
useOptimistic
낙관적 업데이트 : 서버의 응답을 기다리지 않고 먼저 사용자 인터페이스(UI)를 업데이트하는 기법
ㄴ 서버 요청이 성공할 것이라고 가정하고 UI를 먼저 변경 --> 서버에서 요청이 실패되면 UI를 다시 원래 상태로 되돌리는 Rollback 로직이 필요
useOptimistic : 낙관적 업데이트를 쉽게 구현할 수 있도록 설계된 훅
useOptimistic 훅의 기본적인 문법 형태
const [optimisticState, addOptimistic] = useOptimistic(state, (currentState, optimistic) => { ... })
- optimisticState
- 현재의 낙관적 상태값
- addOptimistic
- 낙관적 업데이트를 실행할 때 호출하는 함수
- state
- 서버 응답을 기다리기 전 기본 상태값
- (currentState, optimistic) => { ... }
- 낙관적 업데이트 시 상태를 어떻게 바꿀지 정의하는 함수
useOptimistic 실전 - 1
useOptimistic 미사용 코드
// App.tsx
import axios from "axios";
import { Heart } from "lucide-react";
import { useEffect, useState } from "react";
interface Posts {
id: number;
isLike: boolean;
}
export default function App() {
const [posts, setPosts] = useState<Posts[]>([]);
const [isLoading, setIsLoading] = useState(false);
const updateLike = async (id: number, isLike: boolean) => {
const { data } = await axios.patch(`http://localhost:3000/posts/${id}`, {
isLike: !isLike,
});
setPosts((posts) =>
posts.map((post) => (post.id === data.id ? data : post)),
);
console.log(data);
};
useEffect(() => {
const fetchPosts = async () => {
try {
setIsLoading(true);
const { data } = await axios.get("http://localhost:3000/posts");
setPosts(data);
} catch (e) {
console.error("에러 발생" + e);
} finally {
setIsLoading(false);
}
};
fetchPosts();
}, []);
if (isLoading) return <p>Loading...</p>;
return (
<>
{/* fill: 'none', stroke: 'currentColor' */}
{/* fill: 'rgb(255,0,0)', stroke: 'rgb(255,0,0)' */}
{posts.map((post) => (
<Heart
key={post.id}
fill={post.isLike ? "rgb(255, 0, 0)" : "none"}
stroke={post.isLike ? "rgb(255, 0, 0)" : "currentColor"}
onClick={() => updateLike(post.id, post.isLike)}
/>
))}
</>
);
}
useOptimistic 실전 - 2
useOptimistic 사용 코드
// App.tsx
import axios from "axios";
import { Heart } from "lucide-react";
import {
startTransition,
useEffect,
useOptimistic,
useRef,
useState,
} from "react";
interface Posts {
id: number;
isLike: boolean;
}
export default function App() {
const controller = useRef<AbortController | null>(null);
const [posts, setPosts] = useState<Posts[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [optimisticPosts, addOptimisticPosts] = useOptimistic(
posts,
(statePosts, id) => {
return statePosts.map((statePost) =>
statePost.id === id
? { ...statePost, isLike: !statePost.isLike }
: statePost,
);
},
);
const updateLike = (id: number, isLike: boolean) => {
controller.current?.abort();
controller.current = new AbortController();
startTransition(async () => {
addOptimisticPosts(id);
try {
const { data } = await axios.patch(
`http://localhost:3000/posts/${id}`,
{
isLike: !isLike,
},
{
signal: controller.current?.signal,
},
);
setPosts((posts) =>
posts.map((post) => (post.id === data.id ? data : post)),
);
} catch (e) {
console.error("에러:", e);
}
});
};
useEffect(() => {
const fetchPosts = async () => {
try {
setIsLoading(true);
const { data } = await axios.get("http://localhost:3000/posts");
setPosts(data);
} catch (e) {
console.error("에러 발생" + e);
} finally {
setIsLoading(false);
}
};
fetchPosts();
}, []);
if (isLoading) return <p>Loading...</p>;
return (
<>
{/* fill: 'none', stroke: 'currentColor' */}
{/* fill: 'rgb(255,0,0)', stroke: 'rgb(255,0,0)' */}
{optimisticPosts.map((post) => (
<Heart
key={post.id}
fill={post.isLike ? "rgb(255, 0, 0)" : "none"}
stroke={post.isLike ? "rgb(255, 0, 0)" : "currentColor"}
onClick={() => updateLike(post.id, post.isLike)}
/>
))}
</>
);
}
use + suspense
use 훅 : Promise 객체를 동기 함수처럼 사용할 수 있게 하여 데이터 통신을 보다 간결하게 처리할 수 있도록 하는 기능
ㄴ Suspense 컴포넌트와 함께 사용
Suspense 컴포넌트 : 컴포넌트에서 비동기 처리가 완료될 때까지 대기하게 하는 동안, fallback 속성에 지정된 UI를 표시하는 역할
일반적인 코드 구조
import { Suspense } from "react";
import ChildComponent from "./components/ChildComponent"
export default function App() {
return (
<>
<Suspense fallback={...}>
<ChildComponent promise={...} />
</Suspense>
</>
);
}
^ fallback 속성을 사용해 로딩 UI를 지정 / promise 객체를 하위 컴포넌트에 전달
export default function ChildComponent({
promise,
}: {
promise: Promise<unknown>;
}) {
const data = use(promise);
return (...);
}
^ promise 객체를 활용하여 use 훅으로 비동기가 처리될 때까지 대기
use + suspense 실전
// App.tsx
import { Suspense } from "react";
import { axiosInstance } from "./api/axios";
import Posts from "./components/Posts";
// Promise 객체는 상태 업데이트를 직접적으로 활용하지 않기 때문에 컴포넌트 내부에 정의할 필요 X
async function fetchPosts() {
const { data } = await axiosInstance.get("/posts");
return data; // Promise 객체
}
export default function App() {
return (
<>
<Suspense fallback={<p>Loading...</p>}>
<Posts promise={fetchPosts()} />
</Suspense>
</>
);
}
// src/components/Posts.tsx
import { use } from "react";
export default function Posts({
promise,
}: {
promise: Promise<{ id: number; title: string; views: number }[]>;
}) {
const posts = use(promise);
return (
<>
<pre>{JSON.stringify(posts, null, 2)}</pre>
</>
);
}
error boundary
use 훅과 Suspense를 조합해서 사용할 때, 데이터 요청에 실패하는 경우 앱 전체가 종료되는 crash 현상이 발생
--> 에러 핸들링을 위해 error boundary 사용
error boundary : 자바스크립트 오류를 처리하기 위한 컴포넌트
npm install react-error-boundary 명령어를 사용하여 다운로드
// App.tsx
import { Suspense } from "react";
import { axiosInstance } from "./api/axios";
import Posts from "./components/Posts";
import { ErrorBoundary } from "react-error-boundary";
async function fetchPosts() {
const { data } = await axiosInstance.get("/posts222");
return data; // Promise 객체
}
export default function App() {
return (
<>
<ErrorBoundary fallback={<p>Error!</p>}>
<Suspense fallback={<p>Loading..</p>}>
<Posts promise={fetchPosts()} />
</Suspense>
</ErrorBoundary>
</>
);
}
^ 에러가 생기면 crash 현상이 발생하는 게 아니라 fallback 속성의 JSX 요소를 통해 UI 렌더링
JSX 요소가 아닌 컴포넌트 자체를 렌더링하고 싶을 때에는 FallbackComponent 속성을 사용
tip
// App.tsx
import { Suspense } from "react";
import { axiosInstance } from "./api/axios";
import Posts from "./components/Posts";
import { ErrorBoundary } from "react-error-boundary";
import Loading from "./components/Loading";
async function fetchPosts() {
const { data } = await axiosInstance.get("/posts");
return data; // Promise 객체
}
export default function App() {
return (
<>
<ErrorBoundary fallback={<p>Error!</p>}>
<Suspense fallback={<Loading />}>
<Posts promise={fetchPosts()} />
</Suspense>
</ErrorBoundary>
</>
);
}
// src/components/Loading.tsx
import { useEffect, useState } from "react";
export default function Loading() {
const [isShow, setIsShow] = useState(false);
useEffect(() => {
const timer = setTimeout(() => {
setIsShow(true);
}, 500); // 0.5초 뒤에 setIsShow를 true로 변경
return () => clearTimeout(timer);
}, []);
return <>{isShow && <p>Loading...</p>}</>;
}
^ npm run server로 서버 구동 시 (0.5초 미만) 로딩 화면 렌더링 X / npm run delay로 서버 구동 시 (0.5초 이상) 로딩 화면 렌더링 O
tmdb 회원가입
TMDB : 무료로 사용할 수 있는 영화 API
영화 목록 렌더링 - 1
// src/types/movie.d.ts
type MovieType = {
adult: boolean;
backdrop_path: string;
genre_ids: number[];
id: number;
original_language: string;
original_title: string;
overview: string;
popularity: number;
poster_path: string;
release_date: string;
title: string;
video: boolean;
vote_average: number;
vote_count: number;
};
// App.tsx
import Movie from "./components/movies/Movie";
export default function App() {
return (
<>
<Movie />
</>
);
}
// src/components/movies/Movie.tsx
import { useEffect, useState } from "react";
import MovieHeader from "./MovieHeader";
import MovieList from "./MovieList";
import MovieMain from "./MovieMain";
import axios from "axios";
import { axiosInstance } from "../../api/axios";
export default function Movie() {
const [nowData, setNowData] = useState<MovieType[]>([]); // Now Playing API를 호출한 결과(results) 배열을 할당
const [nowLoading, setNowLoading] = useState(false);
const [nowError, setNowError] = useState<Error | null>(null);
useEffect(() => {
const controller = new AbortController();
const { signal } = controller;
const fetchCategory = async (
endPoint: string,
setData: React.Dispatch<React.SetStateAction<MovieType[]>>,
setLoading: React.Dispatch<React.SetStateAction<boolean>>,
setError: React.Dispatch<React.SetStateAction<Error | null>>,
) => {
setLoading(true);
setError(null);
try {
const {
data: { results },
} = await axiosInstance.get(`/${endPoint}`, {
signal,
});
setData(results);
console.log(results);
} catch (e) {
console.log(e);
if (e instanceof Error && e.name !== "CanceledError") setError(e);
} finally {
if (!controller.signal.aborted) setLoading(false);
}
};
fetchCategory("now_playing", setNowData, setNowLoading, setNowError);
return () => controller.abort();
}, []);
return (
<>
<MovieHeader />
<MovieMain />
<MovieList
title="Now Playing"
movies={nowData}
loading={nowLoading}
error={nowError}
/>
</>
);
}
// src/components/movies/MovieList.tsx
import MovieListItem from "./MovieListItem";
export default function MovieList({
title,
movies,
loading,
error,
}: {
title: string;
movies: MovieType[];
loading: boolean;
error: Error | null;
}) {
return (
<>
<article className="bg-black px-4 pt-4 xs:px-0">
<section className="container mx-auto py-8 text-white">
<span className="text-yellow-600">ONLINE STREAMING</span>
<h2 className="text-[36px] font-bold mb-8">{title}</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-6 sm:px-0">
{/* 아이템 1개 */}
{movies &&
movies.map((movie) => (
<MovieListItem key={movie.id} {...movie} />
))}
</div>
</section>
</article>
</>
);
}
// src/components/movies/MovieListItem.tsx
import { star } from "../../assets/movies/assets";
export default function MovieListItem({
title,
vote_average,
release_date,
poster_path,
}: MovieType) {
return (
<>
<div>
<img
src={`https://image.tmdb.org/t/p/w500${poster_path}`}
alt=""
className="rounded-md w-full"
/>
<div className="flex justify-between items-center font-bold mt-4 mb-2 text-lg">
<h4 className="line-clamp-1">{title}</h4>
</div>
<div className="flex justify-between items-center text-sm text-gray-200">
<div className="flex items-center gap-2 font-bold">
<img
src={star}
alt="star"
width={18}
height={18}
className="object-contain"
/>
<span className="text-yellow-500">{vote_average.toFixed(1)}</span>
</div>
<span className="text-yellow-500 font-bold">{release_date}</span>
</div>
</div>
</>
);
}
영화 목록 렌더링 - 2
로딩, 에러 UI 처리
// src/components/movies/Movie.tsx
import { useEffect, useState } from "react";
import MovieHeader from "./MovieHeader";
import MovieList from "./MovieList";
import MovieMain from "./MovieMain";
import { axiosInstance } from "../../api/axios";
export default function Movie() {
const [nowData, setNowData] = useState<MovieType[]>([]); // Now Playing API를 호출한 결과(results) 배열을 할당
const [nowLoading, setNowLoading] = useState(false);
const [nowError, setNowError] = useState<Error | null>(null);
const [popData, setPopData] = useState<MovieType[]>([]); // Popular API를 호출한 결과(results) 배열을 할당
const [popLoading, setPopLoading] = useState(false);
const [popError, setPopError] = useState<Error | null>(null);
const [topData, setTopData] = useState<MovieType[]>([]); // Top Rated API를 호출한 결과(results) 배열을 할당
const [topLoading, setTopLoading] = useState(false);
const [topError, setTopError] = useState<Error | null>(null);
useEffect(() => {
const controller = new AbortController();
const { signal } = controller;
const fetchCategory = async (
endPoint: string,
setData: React.Dispatch<React.SetStateAction<MovieType[]>>,
setLoading: React.Dispatch<React.SetStateAction<boolean>>,
setError: React.Dispatch<React.SetStateAction<Error | null>>,
) => {
setLoading(true);
setError(null);
await new Promise((resolve) =>
setTimeout(
resolve,
[3000, 4000, 5000, 6000, 7000][Math.floor(Math.random() * 5)],
),
);
try {
const {
data: { results },
} = await axiosInstance.get(`/${endPoint}`, {
signal,
});
setData(results);
console.log(results);
} catch (e) {
console.log(e);
if (e instanceof Error && e.name !== "CanceledError") setError(e);
} finally {
if (!controller.signal.aborted) setLoading(false);
}
};
fetchCategory("now_playing", setNowData, setNowLoading, setNowError);
fetchCategory("popular", setPopData, setPopLoading, setPopError);
fetchCategory("top_rated", setTopData, setTopLoading, setTopError);
return () => controller.abort();
}, []);
return (
<>
<MovieHeader />
<MovieMain />
<MovieList
title="Now Playing"
movies={nowData}
loading={nowLoading}
error={nowError}
/>
<MovieList
title="Popular"
movies={popData}
loading={popLoading}
error={popError}
/>
<MovieList
title="Top Rated"
movies={topData}
loading={topLoading}
error={topError}
/>
</>
);
}
// src/components/movies/MovieList.tsx
import MovieError from "./MovieError";
import MovieListItem from "./MovieListItem";
import MovieLoader from "./MovieLoader";
export default function MovieList({
title,
movies,
loading,
error,
}: {
title: string;
movies: MovieType[];
loading: boolean;
error: Error | null;
}) {
return (
<>
<article className="bg-black px-4 pt-4 xs:px-0">
<section className="container mx-auto py-8 text-white">
<span className="text-yellow-600">ONLINE STREAMING</span>
<h2 className="text-[36px] font-bold mb-8">{title}</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-6 sm:px-0">
{/* 아이템 1개 */}
{movies &&
movies.map((movie) => (
<MovieListItem key={movie.id} {...movie} />
))}
{/* loading... */}
{loading && <MovieLoader />}
{/* error... */}
{error && <MovieError error={error} />}
</div>
</section>
</article>
</>
);
}
무한 스크롤링
무한 스크롤링 : 스크롤이 바닥에 닿았을 때 다음 데이터를 불러와서 추가
--> 스크롤이 바닥에 닿았는지 확인하는 방법이 필요
ㄴ react-intersection-observer 라이브러리 활용
npm i react-intersection-observer 명령어를 사용하여 다운로드
// src/components/movies/Movie.tsx
import { useEffect, useState } from "react";
import MovieHeader from "./MovieHeader";
import MovieList from "./MovieList";
import MovieMain from "./MovieMain";
import { axiosInstance } from "../../api/axios";
import { useInView } from "react-intersection-observer";
export default function Movie() {
const [nowData, setNowData] = useState<MovieType[]>([]);
const [nowLoading, setNowLoading] = useState(false);
const [nowError, setNowError] = useState<Error | null>(null);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const { ref } = useInView({
threshold: 0.5, // 지정한 div 태그가 화면에 50% 이상 드러나면 바닥에 도달했다고 판단
rootMargin: "200px", // 스크롤이 맨 밑바닥에 닿기 200px 직전에 미리 감지
onChange: (inView: boolean) => {
if (inView && !nowLoading && hasMore) {
setPage((page) => page + 1); // 다음 페이지
}
},
});
useEffect(() => {
const controller = new AbortController();
const { signal } = controller;
const fetchCategory = async (
endpoint: string,
setData: React.Dispatch<React.SetStateAction<MovieType[]>>,
setLoading: React.Dispatch<React.SetStateAction<boolean>>,
setError: React.Dispatch<React.SetStateAction<Error | null>>,
) => {
setLoading(true);
setError(null);
await new Promise((resolve) =>
setTimeout(
resolve,
[3000, 4000, 5000, 6000, 7000][Math.floor(Math.random() * 5)],
),
);
try {
const {
data: { results, total_pages },
} = await axiosInstance.get(`/${endpoint}?page=${page}`, {
signal,
});
setHasMore(page < total_pages);
if (page === 1) setData(results);
else setData((data) => [...data, ...results]);
} catch (e) {
if (e instanceof Error && e.name !== "CanceledError") setError(e);
} finally {
if (!controller.signal.aborted) setLoading(false);
}
};
fetchCategory("now_playing", setNowData, setNowLoading, setNowError);
return () => controller.abort();
}, [page]);
return (
<>
<MovieHeader />
<MovieMain />
<MovieList
title="Now Playing"
movies={nowData}
loading={nowLoading}
error={nowError}
/>
<div ref={ref}></div>
</>
);
}
Suspense + Error Boundary + use
// src/store/movieStore.ts
import { create } from "zustand";
type MovieStore = {
page: number;
setPage: (page: number) => void;
};
export const useMovieStore = create<MovieStore>()((set) => ({
page: 1,
setPage: (page: number) => set({ page }),
}));
// App.tsx
import Movie from "./components/movies/Movie";
export default function App() {
return (
<>
<Movie />
</>
);
}
// src/components/movies/Movie.tsx
import { Suspense, useMemo } from "react";
import { axiosInstance } from "../../api/axios";
import MovieHeader from "./MovieHeader";
import MovieList from "./MovieList";
import MovieMain from "./MovieMain";
import MovieLoader from "./MovieLoader";
import { ErrorBoundary } from "react-error-boundary";
import MovieError from "./MovieError";
import { useMovieStore } from "../../store/movieStore";
async function fetchCategory(endPoint: string, page: number) {
await new Promise((resolve) =>
setTimeout(
resolve,
[3000, 4000, 5000, 6000, 7000][Math.floor(Math.random() * 5)],
),
);
const { data } = await axiosInstance.get(`${endPoint}?page=${page}`);
return data;
}
export default function Movie() {
const page = useMovieStore((state) => state.page);
const fetchCategoryMemo = useMemo(
() => fetchCategory("now_playing", page),
[page],
);
return (
<>
<MovieHeader />
<MovieMain />
<ErrorBoundary fallback={<MovieError title="Now Playing" />}>
<Suspense fallback={<MovieLoader title="Now Playing" />}>
<MovieList title="Now Playing" promise={fetchCategoryMemo} />
</Suspense>
</ErrorBoundary>
</>
);
}
// src/components/movies/MovieList.tsx
import { use } from "react";
import MovieListItem from "./MovieListItem";
import { useMovieStore } from "../../store/movieStore";
export default function MovieList({
title,
promise,
}: {
title: string;
promise: Promise<{ results: MovieType[]; total_pages: number }>;
}) {
const { results: movies, total_pages } = use(promise);
const page = useMovieStore((state) => state.page);
const setPage = useMovieStore((state) => state.setPage);
const pageUp = () => {
const currentPage = Math.min(page + 1, total_pages);
setPage(currentPage);
};
const pageDonw = () => {
const currentPage = Math.max(1, page - 1);
setPage(currentPage);
};
return (
<>
<article className="bg-black px-4 pt-4 xs:px-0">
<section className="container mx-auto py-8 text-white">
<span className="text-yellow-600">ONLINE STREAMING</span>
<h2 className="text-[36px] font-bold mb-8">{title}</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-6 sm:px-0">
{/* 아이템 1개 */}
{movies &&
movies.map((movie) => (
<MovieListItem key={movie.id} {...movie} />
))}
</div>
{/* 이전, 다음 버튼 */}
<div className="grid grid-cols-2 gap-4 my-8">
<button
className="flex items-center justify-center gap-2 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
onClick={pageDonw}
>
Prev
</button>
<button
className="flex items-center justify-center gap-2 px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors"
onClick={pageUp}
>
Next
</button>
</div>
</section>
</article>
</>
);
}
// src/components/movies/MovieListItem.tsx
import { star } from "../../assets/movies/assets";
export default function MovieListItem({
poster_path,
release_date,
title,
vote_average,
}: MovieType) {
return (
<>
<div>
<img
src={`https://image.tmdb.org/t/p/w500${poster_path}`}
alt=""
className="rounded-md w-full"
/>
<div className="flex justify-between items-center font-bold mt-4 mb-2 text-lg">
<h4 className="line-clamp-1">{title}</h4>
</div>
<div className="flex justify-between items-center text-sm text-gray-200">
<div className="flex items-center gap-2 font-bold">
<img
src={star}
alt="star"
width={18}
height={18}
className="object-contain"
/>
<span className="text-yellow-500">{vote_average.toFixed(1)}</span>
</div>
<span className="text-yellow-500 font-bold">{release_date}</span>
</div>
</div>
</>
);
}'React > 타입스크립트로 배우는 리액트(React.js) : 기초부터 최신 기술까지' 카테고리의 다른 글
| 섹션 18. React Router v7 (0) | 2026.07.12 |
|---|---|
| 섹션 15. 전역 상태 관리 - Zustand (0) | 2026.06.12 |
| 섹션 16. 데이터 통신 (1) | 2026.06.11 |
| 섹션 13. 전역 상태 관리 - Context API (0) | 2026.06.09 |
| 섹션 12. 사이드 이펙트와 컴포넌트 최적화 (0) | 2026.05.22 |