React/타입스크립트로 배우는 리액트(React.js) : 기초부터 최신 기술까지

섹션 17. 데이터 통신 심화

favor 2026. 6. 22. 14:53

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>
    </>
  );
}