[React-Router] 이벤트 앱 만들기 연습: data fetching

summereuna🐥·2023년 6월 14일
0

React JS

목록 보기
63/69

이벤트 페이지에서 이벤트 데이터를 fetch 해오자.

fetch하면, 이벤트 페이지에 도달한 경우에만 즉각적으로 http 요청을 전송하기 시작한다.
이는 요청이 전송되기 전, 이벤트 페이지 컴포넌트 전체가 렌더링되어야 한다는 것을 의미한다.
보통은 아래 순서로 작동한다.

  • 컴포넌트 렌더링하기 시작 > 데이터 가져옴 > 가져온 데이터로 다시 컴포넌트 렌더링

효율적으로 하기 위해 useState로 loading 상태에 따라 가져온 데이터 없이 먼저 컴포넌트를 렌더링하지 않게하자.

  • 로딩 폴백 사용 > 데이터 가져옴 > 로딩 끝! 가져온 데이터로 컴포넌트 렌더링
import { useEffect, useState } from "react";

import EventsList from "../components/EventsList";

function EventsPage() {
  const [isLoading, setIsLoading] = useState(false);
  const [fetchedEvents, setFetchedEvents] = useState();
  const [error, setError] = useState();

  useEffect(() => {
    async function fetchEvents() {
      setIsLoading(true);
      const response = await fetch("http://localhost:8080/events");

      if (!response.ok) {
        setError("Fetching events failed.");
      } else {
        const resData = await response.json();
        setFetchedEvents(resData.events);
      }
      setIsLoading(false);
    }

    fetchEvents();
  }, []);
  return (
    <>
      <div style={{ textAlign: "center" }}>
        {isLoading && <p>Loading...</p>}
        {error && <p>{error}</p>}
      </div>
      {!isLoading && fetchedEvents && <EventsList events={fetchedEvents} />}
    </>
  );
}

export default EventsPage;

✅ loader() 프로퍼티로 데이터 가져오기


리액트 라우터 v.6 이상을 사용 중이라면 데이터를 가져오고 다양한 상태들을 처리하는 위의 코드를 사실은 작성하지 않아도 된다! 🤭 리액트 라우터가 다 도와주기 때문이다.

1. loader 함수에서 fetch하여 데이터 가져오기

  • { loader: () => {} }
    loader 프로퍼티는 함수를 값으로 취하는 프로퍼티로, 일반 함수나 오류 함수 모두 값으로 취할 수 있따.
    loader 프로퍼티를 작성해 두면, 해당 라우트를 방문하기 직전에 리액트 라우터는 항상 loader 함수를 먼저 트리거하여 실행한다.

따라서 loader()함수 안에서 데이터를 로딩하고 가져오면 된다.

데이터 가져온 후에 컴포넌트 렌더링하기 위해 데이터를 패치하는 라우트인 EventsPage에 loader 프로퍼티를 추가하고 함수를 작성한다.

📍 /src/App.js

const router = createBrowserRouter([
  {
    path: "/",
    element: <RootLayout />,
    errorElement: <ErrorPage />,
    children: [
      { index: true, element: <HomePage /> },
      {
        path: "events",
        element: <EventsRootLayout />,
        children: [
          {
            index: true,
            element: <EventsPage />,
            //응답 데이터를 loader함수에서 받기
            loader: async () => {
              const response = await fetch("http://localhost:8080/events");

              if (!response.ok) {
                //나중에 올바르지 않은 응답상태 처리하기
              } else {
                const resData = await response.json();
                
                //리턴값
                return resData.events;
              }
            },
          },
          { path: "new", element: <NewEventPage /> },
          { path: ":id", element: <EventDetailPage /> },
          { path: ":id/edit", element: <EditEventPage /> },
        ],
      },
    ],
  },
]);

function App() {
  return <RouterProvider router={router} />;
}

export default App;

2. loader 함수의 리턴 값

http 요청으로 받은 응답 데이터를 컴포넌트에서 사용해야 하는데 어떻게 사용할 수 있을까?

리액트 라우터는 loader 함수에서 리턴하는 모든 값을 자동으로 취하고, 그 값을 렌더링하는 라우트의 컴포넌트 페이지 뿐만 아니라 다른 모든 컴포넌트에도 제공한다.

  • return resData.events;
    따라서 응답데이터를 리턴해주자.
    • (참고) 백엔드에서 events 로 넘겼기 때문에 적어준거임

✅ loader() 데이터를 라우트 컴포넌트에서 사용하기

📝 loader()가 리턴한 데이터에 액세스 하는 방법: useLodaerData()


1. 데이터를 사용하려는 컴포넌트인 EventsPage에서 useLodaerData()로 loader의 리턴값을 받아온다.

📍 /src/pages/EventsPage.js

import { useLoaderData } from "react-router-dom";
import EventsList from "../components/EventsList";

function EventsPage() {
  //"가장 가까운" loader 데이터에 액세스하기 위해 실행할 수 있는 특수 훅
  const events = useLoaderData();
  
  return <EventsList />;
}

export default EventsPage;

events는 loader가 리턴한 데이터이다.

  • async/await을 사용했기 때문에 loader()함수는 정확히는 Promise를 리턴한다.
    따라서 그 함수에서 리턴된 모든 데이터는 Promise에 의해 감싸진다.
    하지만 리액트 라우터는 실제로 Promise가 리턴되었는지 확인하고, 자동으로 그 Promise로 부터 리졸빙(resolving)된 데이터를 받는다.
  • 따라서 우리는 Promise를 리턴하는지 아닌지 신경 쓰지 않아도 된다.
    우리는 항상 useLoaderData의 도움을 받아 Promise가 산출한 최종 데이터를 받는다.

2. 그 값을 EventList 컴포넌트에 events 프롭으로 넣어 보낸다.

return <EventsList events={events} />;

이렇게 이벤트 어레이인 events 객체를 값으로 전달할 수 있다.


📚 참고: loader() 데이터의 다양한 활용법:useLodaerData() 훅 사용할 수 있는 다른 곳


useLodaerData() 훅을 EventsPage 페이지에 사용하면 loader에 추가한 라우트에 의해 렌더링 된다.
다른 곳에서도 useLodaerData() 훅을 사용할 수 있다.

1. EventList 컴포넌트

import { useLoaderData } from "react-router-dom";
import classes from "./EventsList.module.css";

function EventsList() {
  //비록 EventsList 컴포넌트가 페이지 컴포넌트는 아니지만 기술적으로는 페이지 컴포넌트와 다른 컴포넌트 사이에 차이는 없다!
  //따라서 여기서도 이 훅을 사용할 수 있다.
  const events = useLoaderData();

  return (
    <div className={classes.events}>
      <h1>All Events</h1>
      <ul className={classes.list}>
        {events.map((event) => (
          <li key={event.id} className={classes.item}>
            <a href="...">
              <img src={event.image} alt={event.title} />
              <div className={classes.content}>
                <h2>{event.title}</h2>
                <time>{event.date}</time>
              </div>
            </a>
          </li>
        ))}
      </ul>
    </div>
  );
}

export default EventsList;

2. 같은 수준이나 더 낮은 수준에 있는 컴포넌트

loader를 추가한 라우트보다 높은 수준의 라우트 (예. RootLayout)에서는 useLoaderData의 도움을 받아서 loader 데이터에 액세스 할 수 없다!
같은 수준이나 더 낮은 수준에 있는 컴포넌트에서만 loader 데이터에 접근할 수 있다.

That means: You can use useLoaderData() in the element that's assigned to a route AND in all components that might be used inside that elements.
즉, 경로에 할당된 요소와, 해당 요소 내에서 사용할 수 있는 모든 컴포넌트에서 useLoaderData()를 사용할 수 있다.

따라서 데이터를 가져오는 수준보다 더 높은 수준에서 useLoaderData로 loader데이터에 접근할 수는 없다!


📝 loader 코드를 저장해야 하는 위치


  • 비록 라우터에 loader 프로퍼티를 추가하여 컴포넌트를 개선했지만, App.js 파일은 그만큼 더 커져버린것 같다. 다른 라우터에도 loader를 추가한다면 App.js파일은 더 많은 일을 처리해야 한다.
  • 그리고 사실 데이터를 가져오는 이 로직은 App.js가 아닌 EventsPage에 속한다고 생각할 수 있다.

일반적인 패턴, 혹은 권장사항은 실제로 loader 코드를 데이터가 필요한 그 컴포넌트의 파일에 넣는 것을 권장한다.

  • 따라서 EventsPage 컴포넌트에 loader 코드를 넣어야 한다.

1. loader 프로퍼티에 작성한 함수를 EventsPage로 옮기기

import { useLoaderData } from "react-router-dom";
import EventsList from "../components/EventsList";

function EventsPage() {
  const events = useLoaderData();

  return <EventsList events={events} />;
}

export default EventsPage;

// ✅ loader 함수는 사용할 파일에 따로 작성한 후 내보낸다.
export const loader = async () => {
  const response = await fetch("http://localhost:8080/events");

  if (!response.ok) {
    //나중에 올바르지 않은 응답상태 처리하기
  } else {
    const resData = await response.json();
    return resData.events;
  }
};

2. 라우트에 loader 임포트하기

//...
//loader를 가져와서
import EventsPage, { loader as eventsLoader } from "./pages/EventsPage";

const router = createBrowserRouter([
  {
    path: "/",
    element: <RootLayout />,
    errorElement: <ErrorPage />,
    children: [
      { index: true, element: <HomePage /> },
      {
        path: "events",
        element: <EventsRootLayout />,
        children: [
          {
            index: true,
            element: <EventsPage />,
            //loader 프로퍼티에 포인터해주면 됨
            loader: eventsLoader,
          },
          { path: "new", element: <NewEventPage /> },
          { path: ":id", element: <EventDetailPage /> },
          { path: ":id/edit", element: <EditEventPage /> },
        ],
      },
    ],
  },
]);

function App() {
  return <RouterProvider router={router} />;
}

export default App;

이렇게 하면 App.js가 다시 간소해진다.
또한 실제로 데이터가 필요한 EventsPage 컴포넌트 가까이, 별도의 함수에 아웃소싱하여 loader 코드를 작성했기 때문에 EventsPage 컴포넌트도 간단해진다.

논란의 여지가 있지만 이는 양쪽의 장점을 모두 취한 것이라고 하니, 일반적으로 이렇게 구조를 만들면 된다.


📚 loader 함수가 실행되는 시기


앞서 간단히 설명했지만 loader 함수가 언제 정확히 실행되는지 살펴보자.

어떤 페이지에 대한 loader는 우리가 그 페이지로 이동하기 시작할 때, 즉 실제로 가기 전에 호출된다.

  • 아래 코드는 데이터를 프론트엔드로 리턴하는 역할을 하는 백엔드 코드이다.

  • const events 라인과 res.json 라인 사이에 setTimeout을 추가한후 res.json를 setTimeout에 넣고 시간을 지연시킨 후 백엔드 서버를 다시 실행해 보자.
    그러고 나서 홈에서 이벤트 페이지를 눌렀을 때, 지연된 시간 뒤에 화면이 렌더링 되는 것을 확인할 수 있다.

    • 이는 라우트 전환을 하자마자 데이터를 가져오기 시작하기 때문이다. 기본값으로 리액트 라우터는 데이터를 가져올 때 까지, 즉 loader가 작업을 완료할 때 까지 화면을 렌더링하지 않고 대기한다. 데이터를 모두 가져온 후, 페이지를 렌더링한다.
  • 장점
    EventsPage 컴포넌트가 렌더링되고 있을 때, 데이터가 거기 있다는 것을 확신할 수 있다는 점이다. 따라서 데이터가 있는지 없는지 걱정할 필요가 없다.

  • 단점
    데이터를 가져오기 까지 지연이 있고, 사용자가 보기에는 아무 일도 일어나지 않는 것처럼 보인다는 점이다.
    따라서 사용자 경험을 개선하기 위해 조치를 취해야 한다.


따라서 사용자 경험을 개선하기 위해 데이터를 가져오고 있다는 표시를 해주자.

💅🏻 현재 네비게이션 상태 UI에 반영하기: useNavigation()으로 현재 라우트 전환 상태 확인하기

useNavigation()를 사용하면 데이터가 도착하길 기다리는 중인지, 이미 데이터가 도착했는지 확인할 수 있다.

const navigation = useNavigation();

navigation.state 
  • idle
    라우트 전환 일어나지 않고 있는 상태
  • loading
    라우트 전환이 이루어지고 데이터를 로딩해오고 있는 상태
  • submitting
    데이터를 제출한 상태

이를 이용하여 현재 네비게이션 상태에 따라 UI를 변경하여 사용자 경험을 개선할 수 있다.

import { Outlet, useNavigation } from "react-router-dom";
import MainNavigation from "../components/MainNavigation";

const RootLayout = () => {
  //useNavigation()을 호출하여 navigation 객체를 얻는다.
  const navigation = useNavigation();
  return (
    <>
      <MainNavigation />
      <main>
        {navigation.state === "loading" && <p>Loading...</p>}
        <Outlet />
      </main>
    </>
  );
};

export default RootLayout;

이 방법은 현재 데이터를 기다리는 중인지 알아내고 로딩 인디케이터를 불러 올 수 있는 한 가지 방법이다.

  • 여기서 로딩 인디케이터는 전환할 목적지인 페이지에 추가되는 것이 아니라, 전환이 시작되었을 때 이미 화면에 표시되어 있는 페이지 컴포넌트에 추가된 다는 점을 기억해 두자!
    이점이 바로 처음에 useEffect와 useState를 사용하여 로딩 상태를 사용한 것과 다른 점이다.

다른 솔루션도 있으므로 이건 알고만 지나가자.


📝 loader()에서 응답 리턴하기


loader에서 모든 종류의 데이터를 리턴할 수 있다는 점을 꼭 이해해야 한다.

여기서는 응답 데이터의 events 프로퍼티 (resData.events)를 리턴하고 있다.

응답 객체도 리턴할 수 있다.

export const loader = async () => {
  const response = await fetch("http://localhost:8080/events");

  if (!response.ok) {
    //나중에 올바르지 않은 응답상태 처리하기
  } else {
    const resData = await response.json();
    //return resData.events;
    //응답 객체 만들어 리턴하기
    const res = new Response("any data", { status: 201 });
    return res;
  }
};
  • const res = new Response();
    브라우저에 내장된 Response() 생성자 함수를 인스턴스화 해서 새로운 응답 객체를 생성하여 그 응답 객체도 리턴하여 자신만의 응답을 구축할 수도 있다.

loader 코드는 서버에서 실행되지 않고 브라우저에서 실행된다.

컴포넌트는 아니지만 브라우저에 있으므로 여전히 클라이언트 측 코드이다.
이 점이 아주 중요하다.

  • 그럼에도 불구하고 여기서 응답을 생성할 수 있는데, 브라우저가 생성자와 응답 객체를 지원하기 때문이다.
  • const res = new Response("any data", { status: 201 });
    • 첫 번째 인자로서 우리가 원하는 어떤 데이터도 받을 수 있다.
    • 두 번째 인자로 추가 객체를 이용하여 응답 상태 코드 등, 더욱 자세하게 설정할 수 있다.

loader에서 이런 응답을 리턴할 때 마다 리액트 라우터 패키지는 useLoaderData를 사용할 때, 내 응답에서 자동으로 데이터를 추출한다.
따라서 useLoaderData가 리턴하는 데이터는 내가 loader에서 리턴한 응답의 일부인 응답 데이터가 된다.

  • 여기서는 이 사실이 별로 유용하지 않을 수 있지만, resData.events 라고 데이터를 리턴한다면 별도의 응답 객체를 생성할 수도 있고, 그게 더 짧다.
export const loader = async () => {
  const response = await fetch("http://localhost:8080/events");

  if (!response.ok) {
    //나중에 올바르지 않은 응답상태 처리하기
  } else {
    const resData = await response.json();
    return resData.events;
  }
};
  • 어쨌든 이 기능이 존재하는 이유는 loader 함수에서 브라우저에 내장된 fetch 함수로 백엔드에 도달하는 방식을 자주 사용하기 때문이다.

  • fetch함수는 실제로 Response로 resolving 되는 Promise를 리턴한다.
    리액트 라우터는 이런 응답 객체를 지원하고 자동으로 데이터를 추출하기 때문에 결국, 간단히 말해 여기서 받은 response, 즉 이 응답 객체를 취하여 내 loader에 바로 리턴할 수 있다.

즉, 수작업으로 response에서 데이터를 추출할 필요가 없다는 말이다.

export const loader = async () => {
  const response = await fetch("http://localhost:8080/events");

  if (!response.ok) {
    //나중에 올바르지 않은 응답상태 처리하기
  } else {
    //수작업으로 추출하지 않아도 됨
    //const resData = await response.json();
    //return resData.events;
    return response;
  }
};

따라서 이렇게 events를 추출하면 된다.

import { useLoaderData } from "react-router-dom";
import EventsList from "../components/EventsList";

function EventsPage() {
  //🔥 그러면 useLoaderData는 response의 일부인 데이터를 자동으로 준다.
  const data = useLoaderData();
  const events = data.events;

  return <EventsList events={events} />;
}

export default EventsPage;

export const loader = async () => {
  const response = await fetch("http://localhost:8080/events");
  
  const events = response.events;

  if (!response.ok) {
    //나중에 올바르지 않은 응답상태 처리하기
  } else {
    return response;
  }
};

이렇게 하면 loader 코드를 줄일 수 있고, 내장된 응답 객체에 대한 지원을 활용할 수 있다.
리액트 라우터에서는 이렇게 특수한 종류의 리턴 객체와 loader 함수를 지원한다.


📚 loader() 함수에 넣을 수 있는 코드의 종류


❗️ 다시 한번 강조!
loader 안에서 정의된 코드는 서버가 아닌 브라우저에서 실행된다.

1. loader 함수 내부에서는 어떤 브라우저 API도 사용할 수 있다.

  • 예) 로컬 스토리지 액세스, 쿠키 액세스, 다른 JS 코드에서 할 수 있는 모든 것 할 수 있음

2. 따라서 loader 함수 내부에서 useState 등의 리액트 훅은 사용할 수 없다.

  • 리액트 훅은 리액트 컴포넌트에서만 사용할 수 있다.

📝 loader에서 커스텀 오류를 이용한 오류 처리


앞에서는 오류를 처리할 때 useEffect 기반 솔루션을 사용하여 수작업으로 오류를 처리했었다.

loader()를 사용하고 있는 이 솔루션에서는 어떻게 오류를 처리할 수 있을까?

1. 간단히 오류 있다는 것을 표시하는 데이터 리턴하여 컴포넌트에서 표시하는 방법

import { useLoaderData } from "react-router-dom";
import EventsList from "../components/EventsList";

function EventsPage() {
  const data = useLoaderData();
 
  //2. 컴포넌트의 코드에서 체크할 수 있음
  if (data.isError) {
    return <p>{data.message}</p>;
  }

  const events = data.events;

  return <EventsList events={events} />;
}

export default EventsPage;

export const loader = async () => {
  const response = await fetch("http://localhost:8080/events");

  if (!response.ok) {
    // 응답이 ok가 아닐 때 400/500 상태 코드 나옴
    //1. 그런 경우 response를 리턴하거나 에러 메시지를 담은 데이터 패키지인 객체를 리턴
    return { isError: true, message: "이벤트를 가져올 수 없습니다." };
  } else {
    return response;
  }
};

fetch url을 잠깐 이상하게 바꿔보면 이렇게 오류 메시지가 표시된다.

하지만 다른 방법도 있다.

2. 내장된 오류 생성자 이용하여 새로운 오류 객체 생성하여 throw 하거나, 객체를 오류로 throw 하기

  • 내장된 오류 생성자 이용하여 새로운 오류 객체 생성하여 throw 하기
    throw new Error("이벤트를 가져올 수 없습니다.");
  • 또는 객체를 오류로 throw 하기
    throw { message: "이벤트를 가져올 수 없습니다." };
import { useLoaderData } from "react-router-dom";
import EventsList from "../components/EventsList";

function EventsPage() {
  const data = useLoaderData();

  // //컴포넌트의 코드에서 체크할 수 있음
  // if (data.isError) {
  //   return <p>{data.message}</p>;
  // }

  const events = data.events;

  return <EventsList events={events} />;
}

export default EventsPage;

export const loader = async () => {
  const response = await fetch("http://localhost:8080/eventsasdf");

  if (!response.ok) {
    //내장된 오류 생성자 이용하여 새로운 오류 객체 생성하여 throw 하기
    //throw new Error("이벤트를 가져올 수 없습니다.");
    //또는 객체를 오류로 throw 하기
    throw { message: "이벤트를 가져올 수 없습니다." };
  } else {
    return response;
  }
};

3. Response에 메시지와 status 담아 throw하기

Response를 생성하여 에러메시지를 보낼 수도 있다.

  • status 포함해서 에러를 던지려면 일반적인 객체가 아닌 Response를 던져야한다.
    • Response의 두 번째 인자에 status를 객체로 추가하여 보내면 된다.
      (500은 백엔드 문제임을 표시하기 위함)
  • 이렇게 하면 errorElement로 렌더링되는 컴포넌트 안에서 오류로 내보내지는 데이터를 잡을 수 있다.
throw new Response(
      JSON.stringify({ message: "이벤트를 가져올 수 없습니다." }),
      { status: 500 }
    );

📚 라우트에서 설정한 errorElement가 표시 되는 경우

  1. 유효하지 않은 라우트 경로인 경우에 폴백 페이지 표시
  2. loader도 포함! 어떤 라우트 관련 코드에 오류가 발생할 때도 폴백 페이지 표시

에러페이지를 설정해 두면 라우트의 어디서나 어떤 종류의 오류가 발생하더라도 에러페이지가 표시된다.
중첩된 라우트인 EventsPages의 loader에서도 오류를 내도 에러가 bubble up 되어서 에러페이지에 오류가 출력된다.

  • 다른 에러페이지를 출력하고 싶다면 해당하는 EventsPages 라우트에 errorElement를 따로 추가해도 된다.
  • 하지만 보통 오류를 보낼 때 status를 함께 보내어 status에 따라 오류를 처리한다.

여튼, loader에서 오류가 던져지면 리액트 라우터는 가장 근접한 오류 엘리먼트를 렌더링한다!


📝 오류 데이터 추출하고 응답 내보내기: useRouteError()


1. useRouteError()를 이용하여 loader에서 Response로 보낸 에러 데이터 추출하기

📍/src/pages/ErrorPage.js

import { useRouteError } from "react-router-dom";
import MainNavigation from "../components/MainNavigation";
import PageContent from "../components/PageContent";

function ErrorPage() {
  const error = useRouteError();

  //기본 설정
  let title = "에러 발생!";
  let message = "뭔가 잘못 되었습니다!";

  //이벤트 페치 실패 시
  if (error.status === 500) {
    message = JSON.parse(error.data).message;
    //JSON형식을 파싱한 후 message에 액세스
  }

  //지원하지 않는 경로 접근 시
  if (error.status === 404) {
    title = "404 Not Found!";
    message = "리소스 또는 페이지를 찾을 수 없습니다.";
  }

  return (
    <>
      <MainNavigation />
      <PageContent title={title}>
        <p>{message}</p>
      </PageContent>
    </>
  );
}

export default ErrorPage;
  • useRouteError()로 라우트에 발생한 에러를 잡아서 콘솔에 찍어보자.

  • JSON.parse(error.data).message;
    따라서 JSON형식을 파싱한 후에 message에 접근할 수 있다.

status에 따라 조건을 주어 에러 메시지를 출력하면 아래 처럼 에러 화면이 표시된다.

  • status === 500

  • status === 404

이렇게 같은 오류 페이지에서 조건에 따라 오류를 잡을 수 있다.


📚 json() 유틸리티 함수


리액트 라우터를 사용할 때 종종 Response를 생성한다. 특히 오류를 throw할 때 말이다.
하지만 이렇게 수작업으로 Response를 생성하는 것은 조금 귀찮은 일이다.

리액트 라우터는 헬퍼 유틸리티를 제공하는데, Response를 만들고 throw하는 대신 json() 호출 결과를 throw하여 동일한 효과를 가질 수 있다.

  • json()은 react-router-dom에서 임포트하는 함수로, json 형식의 데이터가 포함된 Response객체를 생성하는 함수이다.

📍/src/pages/EventsPage.js

import { json, useLoaderData } from "react-router-dom";
import EventsList from "../components/EventsList";

function EventsPage() {
  const data = useLoaderData();

  const events = data.events;

  return <EventsList events={events} />;
}

export default EventsPage;

export const loader = async () => {
  const response = await fetch("http://localhost:8080/events");

  if (!response.ok) {
    // throw new Response(
    //   JSON.stringify({ message: "이벤트를 가져올 수 없습니다." }),
    //   { status: 500 }
    // );
    throw json({ message: "이벤트를 가져올 수 없습니다." }, { status: 500 });
  } else {
    return response;
  }
};

이렇게 하면 코드를 줄일 수 있고, Response 데이터를 쓰는 곳에서 수동으로 JSON형식을 파싱할 필요도 없어진다.

📍/src/pages/ErrorPage.js

  if (error.status === 500) {
    message = error.data.message;
  }

📝 이벤트 세부 정보 로딩하기: 동적 라우트와 loader()


먼저 이벤트 리스트에 Link를 추가하여 각 이벤트 id 별로 url이 형성되게 해준다.

이제 이벤트 디테일 페이지를 만들어 보자.
이벤트 디테일 페이지에서는 <EventItem /> 컴포넌트를 출력하자.

세부 정보를 얻어와야 컴포넌트로 props으로 넘길 수 있는데, 이벤트 세부 정보는 어떻게 얻을 수 있을까?

  1. params으로 useEffect사용하여 http 요청 전송해도 되지만 그렇게 하지 말고 로더 함수로 하자.
  2. loader 함수로 이벤트 세부 정보 얻어 오기

1. loader()로 이벤트 세부 정보 얻기

📍/src/pages/EventDetailPage.js

import { json, useParams } from "react-router-dom";
import EventItem from "../components/EventItem";

const EventDetailPage = () => {
  const params = useParams();
  const id = params.id;

  
  return (
    <>
      <EventItem event="dd" />
      <h1>Event Detail Page</h1>
      <p>Event ID: {id}</p>
    </>
  );
};

export default EventDetailPage;

//이벤트 세부정보에 대한 loader 함수 
//❗️request, params은 객체 디스트럭쳐링으로 가져오기
export const loader = async ({request, params}) => {
  //params 객체로 접근하여 라우트의 파라미터 가져오기
  const id = params.id;

  //fetch()로 싱글 이벤트에 관한 데이터 가져와 응답 받기
  const response = await fetch(`http://localhost:8080/events/${id}`);

  if (!response.ok) {
    throw json(
      { message: "선택된 이벤트의 세부 정보를 가져올 수 없습니다." },
      { status: 500 }
    );
  } else {
    return response;
  }
};

EventDetailPage에서 받은 params을 loader에 바로 사용할 수는 없다.
하지만 loader()함수를 호출하는 리액트 라우터가 EventDetailPage를 실행할 때 객체를 loader()함수에 전달하기 때문에 필요한 라우트 파라미터에 접근할 수 있다.

그 객체는 중요한 데이터 2가지가 들어있다.

  1. 요청 객체 담은 request 프로퍼티
    따라서 loader 안의 request 객체를 사용하여 url에 접근할 수 있다.
    예를 들면 쿼리 파라미터를 추출하는 등의 모든 작업을 할 수 있다.

  2. 모든 라우트 파라미터가 담긴 params 프로퍼티

    • params 객체에 든 파라미터
      • new 파라미터
      • :id 라우트 파라미터
      • :id/edit 파라미터

2. 라우트 정의에 loader 등록하기

📍/src/App.js

import EventDetailPage, 
  { loader as eventDetailLoader } from "./pages/EventDetailPage";


//...

{
  path: ":id",
  element: <EventDetailPage />,
  loader: eventDetailLoader,
},

로더를 라우트에 등록하면, EventDetailPage에 방문하려 할 때 마다 loader가 먼저 호출되고 데이터를 받아 올 수 있다.

3. useLoaderData()로 이벤트의 data 얻기

데이터 받아와서 EventItem 컴포넌트의 props으로 데이터를 보내면 된다.

📍/src/pages/EventDetailPage.js

import { json, useLoaderData } from "react-router-dom";
import EventItem from "../components/EventItem";

const EventDetailPage = () => {
  const data = useLoaderData();
  
  return (
    <>
      <EventItem event={data.event} />
    </>
  );
};
  • 이벤트 상세 페이지

이제 이벤트 상세페이지에서 edit 버튼을 클릭하면 form으로 이벤트 내용을 수정할 수 있게 해보자.

📝 부모 라우트의 데이터에 접근하기: 상위 라우트에 로더 작성 후 useRouteLoaderData("id값")로 데이터 가져오기


edit 버튼을 누르면 수정할 수 있는 form이 뜨는데, 이 때 form의 기본 값에 데이터를 미리 채워두자. 그러기 위해서는 부모 라우트의 데이터를 동일하게 사용해야 한다.

상위 라우트를 만들고, 공통된 로더로 하위 라우트에도 데이터를 받아 오게 해보자.

loader가 추가된 라우트 보다 같거나 낮은 수준의 컴포넌트에서 loader 데이터에 접근 가능하다.
따라서 상위 라우트에 작성한 로더는 하위 라우트를 방문할때도 실행된다.

1. 공통된 로더를 사용하기 위해 엘리먼트가 없는 라우트를 만들고 children에 하위 라우트를 모두 넣어준다.

중첩된 라우트로 url 구성하는 방법

  • 부모 라우트 URL(:id)
    • 자녀 라우트 URL(:id, :id/edit)

2. eventDetailLoader를 래퍼 라우트로 옮겨 자녀 라우트에서 공통으로 loader 사용 가능하게 하자.

📍/src/App.js

{
  path: ":id",
    loader: eventDetailLoader,
      id: "event-detail",
        children: [
          {
            index: true,
            element: <EventDetailPage />,
          },
          { path: "edit", element: <EditEventPage /> },
        ],
},

3. 이때 상위 라우트에 id 프로퍼티를 설정해야 한다.

값은 원하는대로 설정하면 된다.

id를 설정하지 않고 useLoaderData()로 데이터를 받아올 경우, data를 제대로 받아오지 못하는 오류가 발생한다.

  • 예) editEventPage에서..useLoaderData()를 사용하여 data를 받아오는 경우
    useLoaderData() 훅은 기본 값으로 가장 가까운 loader 데이터를 검색한다. 그러면 데이터를 검색하는 가장 높은 수준이 그 컴포넌트가 로딩된 라우트의 라우트 정의가 되기 때문에, 가장 높은 수준이 EditEventPage, 즉 edit 라우트가 되어 버린다. 그러면 데이터를 받아올 수가 없기 때문에 오류가 뜬다.

따라서 부모 라우트의 데이터를 사용하기 위해 라우트에 id 프로퍼티 추가하고, useRouteLoaderData("id값") 훅을 이용해 데이터를 받아오자.

4. 로더의 데이터를 받고 싶은 모든 하부 컴포넌트에서 useRouteLoaderData("id값")훅으로 data 받기

  1. useRouteLoaderData()훅은 라우트의 id 프로퍼티의 값을 인자로 받는다.

  2. 데이터를 사용할 컴포넌트로 props으로 데이터를 보내자.

📍/src/pages/EventDetailPage.js

import { json, useRouteLoaderData } from "react-router-dom";
import EventItem from "../components/EventItem";

const EventDetailPage = () => {
  //라우트의 id 프로퍼티를 인자로 받음
  const data = useRouteLoaderData("event-detail");

  return <EventItem event={data.event} />;
};

export default EventDetailPage;

//이벤트 세부정보에 대한 loader 함수
//객체디스트럭처링 {} 으로 가져오기
export const loader = async ({ request, params }) => {
  const id = params.id;

  //fetch()로 싱글 이벤트에 관한 데이터 가져와 응답 받기
  const response = await fetch(`http://localhost:8080/events/${id}`);

  if (!response.ok) {
    throw json(
      { message: "선택된 이벤트의 세부 정보를 가져올 수 없습니다." },
      { status: 500 }
    );
  } else {
    return response;
  }
};

📍/src/pages/EditEventPage.js

import { useRouteLoaderData } from "react-router-dom";
import EventForm from "../components/EventForm";

const EditEventPage = () => {
  const data = useRouteLoaderData("event-detail");
  return (
    <>
      <EventForm event={data.event} />
    </>
  );
};

export default EditEventPage;

5. props으로 받아온 데이터 사용하기

각 인풋의 기본 값 defaultValue을 설정한다.

📍/src/components/EventForm.js

function EventForm({ method, event }) {
//..
//각 인풋의 기본 값 설정
  defaultValue={event ? event.title : ""}
  • EventForm의 인풋 값에 기본 값을 설정하면 edit을 클릭했을 때 기본 값이 미리 채워져 있다.

이렇게 loader가 없는 라우트에서 더 높은 수준의 상위 라우트 loader에 접근하여 loader를 재사용할 수 있다.



✅ 데이터 백엔드로 전송하기

새로운 이벤트를 만들어 백엔드로 데이터를 보내보자.

백엔드 api로 데이터 전송하는 방법

  1. 양방향 바인딩, Refs 등으로 폼에서 데이터 추출하여 수동으로 HTTP 요청 전송 및 로딩/오류 상태 관리하고, 전송 완료 시 프로그램적으로(useNavigate) 페이지에서 나가기

  2. ✅ 리액트 라우터의 action으로 데이터 전송하기
    loader를 사용하여 데이터를 로딩한 것 처럼, action을 사용하여 데이터를 전송 할 수 있다.

📝 백엔드 API로 데이터 요청 전송: action()


1. 리액트 라우터 돔의 Form 컴포넌트 사용하기

지금 전송하려는 데이터는 form으로 제출된 데이터이다.
리액트 라우터는 form 제출 시 데이터를 추출하는 것을 쉽게 도와줄 수 있는 <Form /> 컴포넌트를 제공한다.

1. form 태그를 react-router-dom이 제공하는 Form 컴포넌트로 대체하자.

  • 기본 form 태그와 다르게 <Form />은 백엔드 요청을 전송하는 브라우저 기본값을 생략할 수 있기 때문에 submitHandler에서 event.preventDefault()를 사용하지 않아도 된다.
  • 또한 전송되었을 때 요청을 받아서 액션에 넘겨준다. 그리고 그 요청에는 폼으로 제출된 모든 데이터가 포함된다.
    따라서 아에 submitHandler를 사용할 필요 없이 리액트 라우터 action()함수를 사용하면 된다.

2. POST 요청을 보내기 위해 폼에 method="POST"를 추가한다.

  • 중요한 점은 <Form /> 컴포넌트를 사용하면 이 폼 데이터를 포함한 요청은 자동으로 백엔드로 전송되는 것이 아니라 액션으로 전송된다.

3. 모든 input에 name을 설정하여 데이터를 추출 시 name을 사용할 수 있다.

📍/src/components/EventForms.js

import { Form, useNavigate } from "react-router-dom";

import classes from "./EventForm.module.css";

function EventForm({ method, event }) {
  const navigate = useNavigate();
  function cancelHandler() {
    navigate("..");
  }

  return (
    <Form className={classes.form} method={method}>
      <p>
        <label htmlFor="title">Title</label>
        <input
          id="title"
          type="text"
          name="title"
          required
          defaultValue={event ? event.title : ""}
        />
      </p>
      //...

2. new 라우트에 action 프로퍼티 추가하기

📍/src/App.js

import NewEventPage, { action as newEventAction } from "./pages/NewEventPage";

//...

{ path: "new", element: <NewEventPage />, action: newEventAction },

3. 백엔드 api로 데이터 전송하기: action()함수 작성

loader 코드와 마찬가지로 action 코드도 사용하려는 라우트에 작성하면 되므로 NewEventPage에 작성하여 export 하자.

Form에서 제출된 요청 데이터는, 리액트 라우트가 잡아 액션으로 요청을 포워딩한다고 했다. 따라서 action()함수를 사용하여 요청을 잡아야 한다.

1. action() 함수에 request 객체 받기

  • loader()함수 처럼 action()함수도 리액트 라우터에 의해 실행되고 유용한 프로퍼티들이 포함된 객체{ request, params }를 받는다.
    새 이벤트를 생성할 때는 폼 데이터가 들어가 있는 request 객체가 필요하다.

2. formData() 메서드로 request 객체에 접근하여 폼 데이터 접근하기

  • const data = await request.formData();
    폼의 데이터를 잡기 위해 formData() 메서드를 request객체에 호출한다.

3. get() 메서드로 제출된 입력 필드 값에 접근하기

  • const title = data.get("title");
    data 객체에 get()메서드를 호출하면 제출된 다양한 입력 필드 값에 접근할 수 있다.
  • 겟 메서드를 사용하여 새로운 이벤트 데이터 객체를 만들자.
const newEventData = {
  title: data.get("title"),
  image: data.get("image"),
  date: data.get("date"),
  description: data.get("description"),
};
  • 이렇게 리액트 라우터가 action()함수에 포워딩한 요청을 이용하여 제출된 폼 데이터를 추출할 수 있다.

4. fetch()로 백엔드 api에 POST 요청 보내기

  • loader를 쓸 때와 마찬가지로 이 action코드는 브라우저에서 실행되는 코드이다. 따라서 어떤 브라우저 API에도 액세스 가능하다.
  • 따라서 fetch()를 사용하여 백엔드에 요청을 보내자.
fetch(`http://localhost:8080/events`, {
  method: "POST",
  header: { "Content-Type": "application/json" },
  body: JSON.stringify(newEventData),
  //newEventData 백엔드로 전송하기 위해 json으로 감싸기
});

5. redirect()를 리턴하여 폼 제출 후 다른 페이지로 이동하기

json()과 마찬가지로 redirect()react-router-dom이 제공하는 특수 함수이다.

  • json()과 마찬가지로 redirect()응답 객체를 생성한다.
  • return redirect("/events")
    redirect를 리턴하면 폼 제출 후 다른 페이지로 이동할 수 있다.

📍/src/pages/NewEventPage.js

import { json, redirect } from "react-router-dom";
import EventForm from "../components/EventForm";

const NewEventPage = () => {
  //이제 이거도 필요 없음 ㅇㅇ!
  //const submitHandler = (event) => {};
  return (
    <>
      <EventForm method="POST" />
    </>
  );
};

export default NewEventPage;

//action 작성하기
export const action = async ({ request, params }) => {
  //폼의 데이터를 잡기 위해 formData() 메서드를 request객체에 호출한다.
  const data = await request.formData();

  //data 객체에 get() 메서드를 호출하면 제출된 다양한 입력 필드 값에 접근할 수 있다.
  //새로운 이벤트 데이터 객체를 만들어 백엔드에 보내자.
  const newEventData = {
    title: data.get("title"),
    image: data.get("image"),
    date: data.get("date"),
    description: data.get("description"),
  };

  const response = await fetch(`http://localhost:8080/events`, {
    method: "POST",
    header: { "Content-Type": "application/json" },
    body: JSON.stringify(newEventData),
    //newEventData 백엔드로 전송하기 위해 json으로 감싸기
  });

  if (!response.ok) {
    throw json(
      { message: "새로운 이벤트를 저장할 수 없습니다!" },
      { status: 500 }
    );
  }
  
  return redirect("/events");
};
  • 이제 새로운 이벤트를 추가할 수 있다.

📚 action()함수를 트리거하는 방법


1. Form 컴포넌트로 action 트리거하기

1. 가장 일반적이고 표준인 방법은 위의 방법 처럼, 리액트 라우터가 제공하는 Form 컴포넌트를 이용하는 것이다.

<Form method="post">
  //...
  • Form은 현재 활성인 라우트(/new)의 action() 함수를 자동으로 트리거한다.
    이 라우트는 폼이 로딩된 라우트이다.

2. 직접 경로 설정하기: Form 컴포넌트에 action 프로퍼티 추가하여 직접 경로 설정

  • Form 컴포넌트에 action 프로퍼티를 추가하고 다른 경로를 설정하면 다른 라우트로도 요청을 전송할 수 있다.
<Form action="다른 경로 A" method="post">
  //...
  • 다른 경로 A 라우트에 action 속성이 있다면, Form의 action 프로퍼티 값을 action을 트리거하는 다른 경로 A 라우트의 경로로 설정하면 된다.
  • 이 경우에는 다른 경로 A 라우트 정의 객체의 경로가 트리거 된다.

따라서 현재 활성인 라우트의 action을 트리거하려면 action 프로퍼티를 굳이 쓰지 않아도 된다.

2. useSubmit()훅 사용하여 프로그램적으로 action 트리거하기

Form 컴포넌트를 사용할수 없다면 useSubmit()훅을 사용하여 프로그램적으로 액션을 트리거할 수 있다.

  • 보통 데이터 삭제 시, 데이터를 정말로 삭제할 지 묻는 브라우저 내장 메서드인 confirm을 사용하게 되는데, 이 경우에는 Form 컴포넌트를 사용하여 액션을 바로 트리거할 수가 없다.
    따라서 useSubmit()훅을 사용하여 프로그램적으로 데이터를 보내는 액션을 트리거할 수 있다.

📝 2. 프로그램적으로 action 트리거하여 데이터 제출하기: 보통 데이터 삭제시 이용


이벤트 상세 페이지의 EventItem 컴포넌트에 있는 삭제 버튼을 클릭했을 때 이벤트 삭제 액션을 트리거할 수 있게 해보자.

1. 라우트에 action 추가

EventItem 컴포넌트는 EventDetailPage 라우트에 대해 로딩되는 EventDetailPage의 일부로 렌더링 되기 때문에 EventDetailPage 라우트에 이벤트 삭제 하는 액션을 추가해야 한다.

📍/src/App.js

import EventDetailPage, {
  loader as eventDetailLoader,
  action as deleteEventAction,
} from "./pages/EventDetailPage";
//...

{
  path: ":id",
    id: "event-detail",
      loader: eventDetailLoader,
        children: [
          {
            index: true,
            element: <EventDetailPage />,
            //액션 추가
            action: deleteEventAction,
          },
          { path: "edit", element: <EditEventPage /> },
        ],
},

2. 삭제 action 작성하기

📍/src/pages/EventDetailPage.js

import { json, redirect, useRouteLoaderData } from "react-router-dom";
import EventItem from "../components/EventItem";

const EventDetailPage = () => {
  //...
};
export default EventDetailPage;

//이벤트 세부정보에 대한 loader 함수
export const loader = async ({ request, params }) => {
  //...
};

//이벤트 삭제 action 함수
export const action = async ({ request, params }) => {
  const id = params.id;

  const response = await fetch(`http://localhost:8080/events/${id}`, {
    // method: "DELETE", 라고 직접 작성해도 되고 request 객체로 부터 추출해도 된다.
    method: request.method,
  });

  if (!response.ok) {
    throw json({ message: "이벤트를 삭제할 수 없습니다!" }, { status: 500 });
  }

  return redirect("/events");
};

3. useSubmit()으로 이벤트 삭제 액션 트리거하기

삭제 버튼을 Form으로 감싸버리면 쉽게 액션이 트리거 되지만 여기서는 confirm도 실행하고 싶기 때문에 Form 컴포넌트를 사용할 수 없다.

따라서 따로 액션을 트리거하려면 프로그램적으로 약간의 데이터를 추가해야 한다.
이럴 때 useSubmit()훅을 사용하면 된다.

useSubmit()훅의 두 가지 인자

  1. 첫 번째 인자: 제출하려는 데이터
  • 이 데이터는 자동으로 formData 객체로 감싸져 request 객체로 보내진다.
  • 나중에 request객체를 formData()로 추출하여 데이터를 사용하면 된다.
  • 근데 여기선 사용할 필요가 없으므로 null로 설정하자.
  1. 두 번째 인자: 폼에 설정할 수 있는 것과 기본적으로 같은 값들 설정할 수 있다.
  • method를 설정하여 request객체에 담아 보낼 수 있다.
  • action의 키를 다른 경로로 설정하면 해당하는 경로의 action을 트리거할 수 있다.
    액션 키를 따로 설정하지 않으면 이 컴포넌트가 렌더링 되는 라우트와 같은 라우트에서 정의되는 액션을 트리거한다.
  • 여기서는 비워두면 된다.

📍/src/components/EventItem.js

import { Link, useSubmit } from "react-router-dom";
import classes from "./EventItem.module.css";

function EventItem({ event }) {
  
  //useSubmit() 훅으로 프로그램적으로 액션 트리거하기
  const submit = useSubmit();

  //삭제하기
  const startDeleteHandler = () => {
    //1. 이벤트 삭제할건지 묻기
    const proceed = window.confirm("이벤트를 정말 삭제 하시겠습니까?");
    //브라우저 내장 함수인 confirm()은 boolean 값 리턴함

    if (proceed) {
      //2. 이벤트 삭제 액션 트리거
      //메소드를 설정하면, 액션 함수에서 request객체를 받을 때 method를 받을 수 있다.
      submit(null, { method: "delete" });
    } else {
      return;
    }
  };

  return (
    <article className={classes.event}>
      <img src={event.image} alt={event.title} />
      <h1>{event.title}</h1>
      <time>{event.date}</time>
      <p>{event.description}</p>
      <menu className={classes.actions}>
        <Link to="edit">Edit</Link>
        <button onClick={startDeleteHandler}>Delete</button>
      </menu>
    </article>
  );
}

export default EventItem;

✅ 백엔드: 제출 상태 이용하여 UI 상태 업데이트


백엔드 코드를 잠시 지연 시킨 후 새로운 이벤트 등록 시 사용자에게 피드백을 주는 코드를 작성해 보자.

백엔드 코드에서 post 지연 시키기

프론트엔드 코드 작성 후 확인한 뒤 이 코드는 다시 원상복구 시키면 된다.

  //검증 성공 시 데이터 전송
  //잠시 지연시켜서 프론트 엔드 코드 설정 잘되었는지 확인
  try {
    await add(data);
    //setTimeout 1.5초 지연 시키기
    setTimeout(() => {
      res.status(201).json({ message: "Event saved.", event: data });
    }, 1500);
  } catch (error) {
    next(error);
  }

프론트엔드 코드: EventForm에서 폼 제출 시, 액션 제출 중인 경우 UI에 표시하기

useNavigation() 훅으로 navigation객체의 state 값 활용하여 UI에 적용해보자.

  • const navigate = useNavigate();
    navigation 객체 사용하여 현재 상태가 전환 상태인지, 오류 상태인지 등 알 수 있다.

  • const isSubmitting = navigation.state === "submitting";
    상태가 submitting이면 데이터를 제출하고 있기 때문에 액션이 여전히 활성 상태이다.

isSubmitting인지에 따라, 즉 제출 중인 경우 save/cancel 버튼 작동하지 않게 해보자.

import { Form, useNavigate, useNavigation } from "react-router-dom";

import classes from "./EventForm.module.css";

function EventForm({ method, event }) {
  const navigate = useNavigate();
  function cancelHandler() {
    navigate("..");
  }
  
  const navigation = useNavigation();
  const isSubmitting = navigation.state === "submitting";
  
  return (
    <Form className={classes.form} method={method}>
      //...
      <div className={classes.actions}>
        <button type="button" onClick={cancelHandler} disabled={isSubmitting}>
          취소
        </button>
        <button disabled={isSubmitting}>
          {isSubmitting ? "제출 중..." : "저장"}
        </button>
      </div>
    </Form>
  );
}

export default EventForm;

📝 사용자 입력 검증 및 검증 오류 출력


현재 백엔드 코드에서는 사용자의 입력에 대해 검증하고 있기 때문에 입력이 잘못된 경우, 데이터베이스(혹은 파일)에 저장되지 않게 하고 있다.

프론트엔드에서도 사용자 검증을 통해 오류가 있다면 알려줘야 한다.
기본적으로 Form의 input 값의 html 속성인 required를 통해 빈 값을 제출하지 못하게 막고 있다.

하지만 클라이언트 측 검증에만 의존하지 말고 서버 측 검증도 해야 한다. 왜냐하면 클라이언트 측 검증은 조작가능 하기 때문이다. 즉, 브라우저 개발툴로 비활성화 시켜버릴 수 있다.

좋은 사용자 경험을 위해서는 클라이언트 측과 서버 측 모두에 검증하는 코드를 작성해야 한다. 따라서 검증 오류가 탐지되면 백엔드에서 수집한 검증 오류를 사용자에게 보여주자.

  • 현재 검증 오류가 발견되면 백엔드에서 status: 422로 오류 응답을 회신하고 있는데, 이를 활용하여 프론트 엔드에서 제출된 상태코드에 따른 오류에 대응해 보자.

1. 새로운 이벤트 제출 시 잠재적인 백엔드 검증 오류에 대응하기

검증 오류인 경우 기본 오류 페이지를 표시하는 대신 현 페이지에서 폼 바로 위에 검증 오류 표시 해보자.
액션에서 출력하려는 데이터를 폼 위에 리턴하기 위해서는

📍/src/pages/NewEventPage.js

//..
export const action = async ({ request, params }) => {
  const data = await request.formData();

  const newEventData = {
    title: data.get("title"),
    image: data.get("image"),
    date: data.get("date"),
    description: data.get("description"),
  };

  const response = await fetch(`http://localhost:8080/events`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(newEventData),
  });

  //백엔드 검증에서 걸린 경우
  if (response.status === 422) {
    return response;
    //응답 객체 리턴하여 폼 위에 표시하자
  }

  if (!response.ok) {
    throw json(
      { message: "새로운 이벤트를 저장할 수 없습니다!" },
      { status: 500 }
    );
  }

  return redirect("/events");
};

2. useActionData()으로 액션의 리턴 값 받아오기

loader 안에서 response를 리턴하여 컴포넌트 페이지에서 response 데이터를 사용할 수 있는 것처럼, 리턴한 action 데이터도 페이지와 컴포넌트에서 사용할 수 있다.
즉, action에서 response를 리턴하면 이 response는 loader에서 처럼 리액트 라우터에 의해 자동으로 파싱된다.

  1. const data = useActionData();
    useActionData()훅은 가장 가까운 action이 리턴한 데이터에 접근할 수 있게 해준다.
    비록 이 컴포넌트가 페이지 컴포넌트는 아니지만, 페이지 컴포넌트에 렌더링 되는 일부이기 때문에 사용할 수 있다.
  • 이 데이터는 검증 오류가 있을 경우 백엔드에서 리턴하는 데이터로 백엔드를 살펴보면 errors 객체의 프로퍼티 별로 메시지가 다 다른 것을 확인 할 수 있다.

  • 따라서 액션이 리턴한 response 객체인 `data에 접근하여 사용하면 된다.

  1. 먼저 검증 실패시 액션에서 리턴한 data가 있는지 확인하고 에러객체가 있다면 JS 내장 함수인 Object.values()로 errors 객체 안의 모든 키 반복하여 data에 매핑한다.
  • required 속성을 없애고 빈 값으로 폼을 제출해 보면 아래와 같이 화면에 출력되는 것을 확인할 수 있다.

/src/components/EventForm.js

import {
  useActionData,
  //...
} from "react-router-dom";

import classes from "./EventForm.module.css";

function EventForm({ method, event }) {
  //...

  //백엔드에서 유효성 검사 실패로 액션에서 반환된 response 데이터
  const data = useActionData();
  
  return (
    <Form className={classes.form} method={method}>
      {data && data.errors && (
        <ul>
          {Object.values(data.errors).map((err) => (
            <li key={err}>{err}</li>
          ))}
        </ul>
      )}
      <p>
        <label htmlFor="title">제목</label>
        <input
          id="title"
          type="text"
          name="title"
          // required
          defaultValue={event ? event.title : ""}
        />
      </p>

📝 request() 메서드로 액션 재사용하기


이벤트를 수정하는 기능을 추가해보자.
EditEventPage에서 사용하려는 action의 기능은 NewEventPage에서 사용하는 action과 method만 다를 뿐 거의 동일하다. 왜냐하면 같은 데이터를 가진 같은 폼이기 때문이다.
액션을 재사용하기 위해 다른 부분에 대해서는 동적으로 데이터가 적용될 수 있게 코드를 수정하면 된다.

  • 다른 부분
    • 다른 요청 method
    • 다른 URL로 보내기

1. NewEventPage에 작성한 action 코드를 EventForm으로 옮기기

  • 먼저 NewEventPages와 EditEventPage, 두 페이지 모두에서 액션 코드를 사용하기 위해 두 페이지에서 동일하게 출력하고 있는 EventForm 컴포넌트로 action 코드를 옮긴다.
  • 혹은 utiliy 파일을 생성하여 액션 코드를 따로 작성한 후 아웃소싱해도 된다.

2. method 동적으로 사용하여 재사용성 높이기

  1. 동적으로 사용하기 위해 각 페이지에서 EventForm 컴포넌트를 반환할 때 method 프롭으로 POST와 PATCH를 각각 보낸다.
//📍 NewEventPage: EventForm에서 이벤트 생성
return <EventForm method="POST" />;

//📍 EditEventPage: EventForm에서 이벤트 편집/수정
return <EventForm method="PATCH" event={data.event} />;
  1. EventForm에서 method를 프롭으로 받아와서 Form의 method로 동적으로 적용한다.
// 📍 EventForm
function EventForm({ method, event }) {
  //..
  
  return (
    <Form className={classes.form} method={method}>
    //method 동적으로 받기
  • Form에 추가된 method 프로퍼티는 리액트 라우터가 생성하고 action에 전달된 클라이언트 측 요청에 method를 설정하기 위해서만 사용된다.
    정확히 이 클라이언트 측 request로 말이다.
  1. action 함수에서 reqest.method 받아서 적용
 export const action = async ({ request, params }) => {
  //🔥리액트 라우터가 생성한 request의 method 받기
  const method = request.method;

   //...
  
   const response = await fetch("url도 수정해야함", {
    //🔥 하드 코딩하지 말고 리액트 라우터가 생성한 request의 method로 설정
    method,
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(newEventData),
  });

  //...

3. 다른 url 동적으로 사용하여 재사용성 높이기

  • 새로운 이벤트 생성 시 url
    http://localhost:8080/events

  • 기존 이벤트 편집/수정 시 url
    http://localhost:8080/events/:id

따라서 method 별로 조건을 걸어 url을 설정하자.

// 📍 EventForm/ action 함수

  export const action = async ({ request, params }) => {
  //🔥리액트 라우터가 생성한 request의 method 받기
  const method = request.method;

  const data = await request.formData();

  const newEventData = {
    title: data.get("title"),
    image: data.get("image"),
    date: data.get("date"),
    description: data.get("description"),
  };

  //✅ url 동적으로 설정하기 위해 기본 url 변수로 설정
  let url = `http://localhost:8080/events`;

  //✅ 편집/수정 시에만 url에 id 추가하기
  if (method === "PATCH") {
    const id = params.id;
    url = `http://localhost:8080/events/${id}`;
  }

    //✅ url 동적으로 설정
  const response = await fetch(url, {
    //🔥 하드 코딩하지 말고 리액트 라우터가 생성한 request의 method로 설정
    method,
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(newEventData),
  });

  //...

4. 사용하고자 하는 모든 라우터에 해당 action 설정하기

import { action as manipulateEventAction } from "./components/EventForm";
//...

// new 라우트
{
  path: "new",
    element: <NewEventPage />,
      action: manipulateEventAction,
},
  
// :id/edit 라우트
  {
    path: "edit",
      element: <EditEventPage />,
        action: manipulateEventAction,
  },

이렇게 폼이 제출된 방법에 따라 약간 다른 일을 하는 action을 작성하여 동일한 액션을 다른 라우트에 재사용할 수 있다.


📝 라우트 전환 없이 액션/로더 하기: useFetcher()로 배후 작업


뉴스레터 페이지 컴포넌트에 작성해둔 뉴스레터구독액션이 있다고 하자.
그런데 뉴스레터 구독액션을 필요로하는 뉴스레터 구독 폼이 네비게이션에도 있고 뉴스레터 페이지에도 있다면 액션은 어떻게 트리거할 수 있을까?

  • 만약 뉴스레터 페이지에만 구독 폼이 있다면 Form 컴포넌트 이용하여 액션을 자동으로 트리거 되게 하면 된다.

  • 하지만 메인네비게이션의 일부로 구독 폼이 있다면 구독폼은 모든 라우트 포함되어 버린다.
    그러면 이 액션을 모든 라우트에 추가해야 하는데 그러면 다른 액션이랑 충돌이 발생할 수도 있다.

이렇게 여러 곳에서 newsletterAction을 트리거해야 하지만 라우트 전환은 필요하지 않을 때 useFetcher()를 사용할 수 잇다.

  • useFetcher()은 액션이나 로더와 상호작용은 하지만 페이지 전환은 하지 않고 싶을 때 사용할 수 있다.
    즉, 라우트 변경을 트리거하지 않은 채로 배후에서 request를 전송할 때 사용할 수 있다.

useFetcher()가 반환하는 객체

  • const fetcher = useFetcher();
    fetcher는 객체로 많은 메서드를 포함하고 있다.
    fetcher.Form은 액션을 트리거하지만 라우트를 전환하지는 않는다.
    따라서 액션은 트리거 하더라도. 액션이 속한 페이지로 이동하지 않을 때 사용할 수 있다.
    fetcher.load 도 마찬가지!

fetcher.Form에 action 속성 추가하여 해당하는 라우트의 액션만 트리거하기

<fetcher.Form
      method="POST"
      action="/newsletter"
  //...
  • 폼에 action 속성을 추가하여 /newsletter라고 지시하면 newsletter 라우트의 액션을 트리거한다. 중요한 점은 그 라우트의 엘리먼트인 컴포넌트는 로딩하지 않은 채 액션만 사용할 수 있다는 점이다.

fetcher.data, fetcher.state로 ui 업데이트 하기

  • const { data, state } = fetcher;
    fetcher 객체에는 트리거한 액션이나 로더가 성공했는지 알 수 있게 도와주는 프로퍼티가 많이 포함되어 있다.
    따라서 액션이나 로더가 반환한 데이터에도 접근할 수 있다.

  • useNavigation 훅으로도 state를 알수 있는데 이 훅은 라우트 변경이 이루어 지는 경우에 사용해야한다.

  • fetcher의 state는, 트리거된 액션이나 로더를 배후의 fetcher가 완료했는지 알려준다.

이를 통해 ui를 업데이트할 수 있다

data와 state가 변경될 때 마다, 즉 두 값중 하나가 변경될 때 함수 트리거하기

import { useFetcher } from "react-router-dom";
import classes from "./NewsletterSignup.module.css";
import { useEffect } from "react";

function NewsletterSignup() {
  const fetcher = useFetcher();
 
  const { data, state } = fetcher;

  useEffect(() => {
    if (state === "idle" && data && data.message) {
      //더 이상 액션이나 로더를 실행하지 않고 && data를 받았고 && data에 message(신청 완료!) 프로퍼티가 있다면
      window.alert(data.message);
    }
  }, [data, state]);

  return (
    <fetcher.Form
      method="POST"
      action="/newsletter"
      className={classes.newsletter}
    >
      <input
        type="email"
        placeholder="뉴스레터 구독하기"
        aria-label="뉴스레터 구독"
      />
      <button>구독</button>
    </fetcher.Form>
  );
}

export default NewsletterSignup;

📝 defer() 함수로 데이터 가져오기 연기하는 방법


데이터가 로딩되는(렌더링되는) 때를 연기하는 기능

백엔드의 get라우트에서 setTimeout으로 1.5초 늦게 응답을 받아보자.
그러면 이벤트가 나올때까지 1.5초 간 빈화면이 뜬다.

여기서 defer()를 사용하여 로딩을 연기하고, 비록 데이터가 다 도착하지 않았더라도 컴포넌트를 미리 렌더링하라고 리액트 라우터에게 알릴 수 있다.

먼저 로더 안에서 Promise를 기다리기 원하지 않으므로 밖에서 함수를 만들자.
로더 안에서는 react-router-dom의 defer(객체)를 사용한다.

1. 이벤트 로딩을 연기하기 위해 http 요청 코드는 별도의 함수에 작성

const loadEvents = async () => {
  const response = await fetch("http://localhost:8080/events");

  if (!response.ok) {
    throw json({ message: "이벤트를 가져올 수 없습니다." }, { status: 500 });
  } else {
    //❌ return response;
    //response는 useLoaderData()로 직접 받은 값일 경우에는 작동하지만 defer 단계에 있으면 작동하지 않음
    //따라서 수동으로 파싱해야 함
    const resData = await response.json();
    return resData.events;
  }
};

2. loader 안에서 defer() 반환

export const loader = () => {
  return defer({
    events: loadEvents(),
  });
};

defer()에는 객체{}를 넣을 수 있다.
객체에 이 페이지에서 오갈 수 있는 모든 HTTP 요청을 넣어주면 된다.

  • 여기에서는 요청이 loadEvents 한 개만 있다.

  • defer({ events: loadEvents() });
    키 이름을 events로 하고 값으로 http요청건인 loadEvents를 넣어준다.
    단순히 포인트(지시)만 하는 것이 아니라 loadEvents 함수를 실행해 주자.
    그러면 loadEvents 함수가 실행되고 리턴한 값이 events 키에 저장된다.
    async 함수이기 때문에 반환한 값을 Promise이다.

    • 만약 Promise가 없다면 연기할게 아무것도 없어진다.
      왜냐하면 defer라는 개념은 결국에는 다른 값으로 resolving 될 어떤 값이 있다는 것을 가정한다. 그게 바로 Promise의 정의이다.

여튼 비록 그 미래 값인 Promise가 거기에 없어도 우리는 컴포넌트를 로딩하고 컴포넌트를 렌더링하려고 한다.

  • Promise를 리턴하는 loadEvents()를 객체의 events 키에 저장하여 defer()에 넣어 주면, defer()가 리턴하는 값을 loader()가 리턴하게 된다.

이제 연기된 데이터를 사용해 보자.

3. 연기된 데이터를 사용하려는 컴포넌트로 가서 useLoaderData()를 사용하여 로더의 값을 가져온다.

function EventsPage() {
  //연기된 데이터
  const { events } = useLoaderData();
  //...

4. 연기된 데이터 Await 컴포넌트로 렌더링하기

연기된 데이터를 JSX코드에 직접 렌더링 하여 사용하지 않고,

function EventsPage() {
  //연기된 데이터
  const { events } = useLoaderData();
  
  //❌
  return <EventsList events={events} />;
}

대신, react-router-dom이 제공하는 Await 컴포넌트를 사용한다.

function EventsPage() {
  // 연기된 데이터
  const { events } = useLoaderData();
  
  return (
    <Suspense fallback={<p style={{ textAlign: "center" }}>로딩 중...</p>}>
      <Await resolve={events}>
        {(loadedEvents) => <EventsList events={loadedEvents} />}
      </Await>
    </Suspense>
  );
}

Await 컴포넌트

  • <Await resolve={events}> ... </Await>
    Await 컴포넌트의 resolve 프로퍼티는 연기된 Promise 값을 취한다.
    그러면 Await 컴포넌트는 데이터가 올 때 까지 기다리고, 이어서 시작 태그와 종료 태그 사이에서 역동적인 값을 출력한다.

  • {(loadedEvents) => <EventsList events={loadedEvents} />}
    사이에 들어가는 동적인 값은 바로, 데이터가 도착하면, 즉 Promise가 resolving 되고 우리에게 데이터가 도착하면 리액트 라우터가 실행할 함수이다.
    따라서 로딩된 이벤트를 받아서 EventList 호출해서 프롭으로 뿌려주면 된다.

Suspense 컴포넌트로 Await 컴포넌트를 감싸기

마지막으로 react에서 제공하는 Suspense 컴포넌트로 Await 컴포넌트를 감싸주자.

  • <Suspense fallback={<p style={{ textAlign: "center" }}>로딩 중...</p>}> ... </Suspense>
    Suspense 컴포넌트는 다른 데이터가 도착하길 기다리는 동안 폴백을 보여주는 특정한 상황에서 사용할 수 있다.

전체 코드

📍/src/pages/EventsPage.js

import { Suspense } from "react";
import { Await, defer, json, useLoaderData } from "react-router-dom";
import EventsList from "../components/EventsList";

function EventsPage() {
  //연기된 데이터
  const { events } = useLoaderData();

  return (
    <Suspense fallback={<p style={{ textAlign: "center" }}>로딩 중...</p>}>
      <Await resolve={events}>
        {(loadedEvents) => <EventsList events={loadedEvents} />}
      </Await>
    </Suspense>
  );
}

export default EventsPage;

//이벤트 로딩을 연기하기 위해 별도의 함수에서 아웃소싱
const loadEvents = async () => {
  const response = await fetch("http://localhost:8080/events");

  if (!response.ok) {
    throw json({ message: "이벤트를 가져올 수 없습니다." }, { status: 500 });
  } else {
    //response는 useLoaderData()로 직접 받은 값일 경우에는 작동하지만 defer 단계에 있으면 작동하지 않음
    //따라서 수동으로 파싱해야 함
    const resData = await response.json();
    return resData.events;
  }
};

export const loader = () => {
  //로더 안에서 loadEvents 프로미스를 기다리기 원치 않으므로 밖으로 뺌
  return defer({
    events: loadEvents(),
  });
};

defer 장점

  • defer 기능을 사용하면 페이지 속도가 높아지고 다른 콘텐츠를 기다리는 동안 약간의 콘텐츠를 먼저 보여줄 수 있다.
  • 속도가 다른 다수의 HTTP 요청이 있는 페이지들이 있을 때 사용하기 유용하다.

📝 연기시킬 데이터 제어하는 방법


속도가 다른 다수의 요청이 있을 때, defer() 사용하기

현재 싱글 이벤트는 로더를 통해 상세 페이지 로딩 전에 싱글 이벤트 데이터를 미리 받아오고 있다.

이벤트 상세 페이지에서, 싱글 이벤트에 대한 데이터 뿐만 아니라 이벤트 리스트 데이터도 함께 http 요청을 받아 화면에 렌더링해보자.
이때 이벤트 리스트 데이터는 defer()를 사용하여 데이터를 지연시켜 페이지 로딩 후에 받아보자.

await으로 세부 조정

// 싱글 이벤트, 이벤트 리스트 defer()로 가져오는 loader 함수
export const loader = async ({ request, params }) => {
  const id = params.id;

  //🔥데이터 연기: 세부 조정 가능
  //async 함수가 있는 async 로더가 있으면 await 키워드를 넣어서 그 데이터 로딩될 때 까지 기다렸다가 페이지 컴포넌트 로딩되게 함
  return defer({
    //페이지 이동 전 기다려야(await)하는 싱글 이벤트 데이터
    //페이지 이동 후 바로 데이터 출력됨
    event: await loadEvent(id),
    //페이지 이동 후(데이터 연기하여) 로딩하면 되는 이벤트 리스트 데이터
    //따라서 "로딩중..." 뜬 후 출력됨
    events: loadEventsList(),
  });
};

전체 코드

📍/src/pages/EventDetailPage.js

import {
  Await,
  defer,
  json,
  redirect,
  useRouteLoaderData,
} from "react-router-dom";
import EventItem from "../components/EventItem";
import EventsList from "../components/EventsList";
import { Suspense } from "react";


const EventDetailPage = () => {
  const { event, events } = useRouteLoaderData("event-detail");  
 
  //서스펜스, 어웨잇 컴포넌트로 각각 감싸주기
  return (
    <>
      <Suspense fallback={<p style={{ textAlign: "center" }}>로딩중...</p>}>
        <Await resolve={event}>
          {(loadedEvent) => <EventItem event={loadedEvent} />}
        </Await>
      </Suspense>
      <Suspense fallback={<p style={{ textAlign: "center" }}>로딩중...</p>}>
        <Await resolve={events}>
          {(loadedEventsList) => <EventsList events={loadedEventsList} />}
        </Await>
      </Suspense>
    </>
  );
};

export default EventDetailPage;


// 1. id에 따른 싱글 이벤트 가져오는 http 요청
const loadEvent = async (id) => {
  const response = await fetch(`http://localhost:8080/events/${id}`);

  if (!response.ok) {
    throw json(
      { message: "선택된 이벤트의 세부 정보를 가져올 수 없습니다." },
      { status: 500 }
    );
  } else {
    const resData = await response.json();
    return resData.event;
  }
};


// 2. 모든 이벤트 리스트 가져오는 http 요청
const loadEventsList = async () => {
  const response = await fetch("http://localhost:8080/events");

  if (!response.ok) {
    throw json({ message: "이벤트를 가져올 수 없습니다." }, { status: 500 });
  } else {
    //response는 useLoaderData()로 직접 받은 값일 경우에는 작동하지만 defer 단계에 있으면 작동하지 않음
    //따라서 수동으로 파싱해야 함
    const resData = await response.json();
    return resData.events;
  }
};


// 3. 싱글 이벤트, 이벤트 리스트 defer()로 가져오는 loader 함수
export const loader = async ({ request, params }) => {
  const id = params.id;

  //🔥데이터 연기: 세부 조정 가능
  //async 함수가 있는 async 로더가 있으면 await 키워드를 넣어서 그 데이터 로딩될 때 까지 기다렸다가 페이지 컴포넌트 로딩되게 함
  return defer({
    //페이지 이동 전 기다려야(await)하는 싱글 이벤트 데이터
    //페이지 이동 후 바로 데이터 출력됨
    event: await loadEvent(id),
    //페이지 이동 후(데이터 연기하여) 로딩하면 되는 이벤트 리스트 데이터
    //따라서 "로딩중..." 뜬 후 출력됨
    events: loadEventsList(),
  });
};

//...

profile
Always have hope🍀 & constant passion🔥

0개의 댓글