React Router를 사용할때는 이전에 알아보았던 useEffect를 사용하여 데이터를 fetch해오는 작업을 하는것이 아니라 React Router에서 지원하는 여러가지 방법으로 데이터를 fetch해오는것이 가능하다.
기본적으로 다음과 같은 구조의 loader함수를 생성하고 해당 함수를 router를 정의하는 객체의 loader프로퍼티에 전달하여 사용한다.
export async function eventsLoader() {
const response = await fetch("http://localhost:8080/events");
if (!response.ok) {
throw new Response(JSON.stringify({ message: "Could not fetch events" }), {
status: 500,
headers: { "Content-Type": "application/json" }
});
} else {
return response;
}
}
const router = createBrowserRouter([
{
path: "/",
element: <RootLayout />,
errorElement: <ErrorPage />,
children: [
{ index: true, element: <HomePage /> },
{
path: "events",
element: <EventsRootLayout />,
children: [
{
index: true,
element: <EventsPage />,
loader: eventsLoader
},
...
...
]
}
])
loader함수는 기본적으로 객체를 인자로 가지며, 해당 객체는 LoaderFunctionArgs를 타입으로 가진다.
해당 객체에 존재하는 params를 통해 현재 라우트의 파라미터를 가져올 수 있으며, request를 통해 method등에 접근할 수 있다.
loader를 통해 등록한 함수에서 가져오는 데이터에 접근하기위해서는 어떻게 해야할까?
가장 간단하게 데이터를 접근하는 방법에는 useLoaderData() 훅이 존재한다.
해당 훅은 가장 가까운 loader데이터를 받아온다.
import { useLoaderData } from "react-router-dom";
import EventsList from "../components/EventsList";
import { EventType } from "../types/type";
function EventsPage() {
const data: { events: EventType[] } = useLoaderData();
const events = data.events;
return (
<>
<EventsList events={events} />
</>
);
}
export default EventsPage;
가장 가까운 loader데이터를 받아온다는것이 무슨 의미일까?
우리가 router를 정의할 때 다음과 같이 /events경로의 children중 index인 객체한테 loader함수를 넣어주었다.
{
path: "events",
element: <EventsRootLayout />,
children: [
{
index: true,
element: <EventsPage />,
loader: eventsLoader
},
]
}
따라서 <EventsPage />에서 useLoaderData()훅을 호출할 경우는 eventsLoader함수를 통해 받아온 데이터를 로드하게 된다.
쉽게 말해서 현재 라우트에 정의된 loader함수를 통해 데이터를 받아온다는 의미이다.
그렇다면 여러 라우트에 동일한 loader함수가 필요한 경우에는 어떻게 해야할까?
동일한 loader함수가 필요한 모든 라우트에 해당 함수를 넣어줄수도 있지만, 부모 라우트에 loader함수를 정의하여 간단하게 해결할 수 있다.
{
path: `:${PARAMS_IDS.EVENT_ID}`,
loader: eventDetailLoader,
id: ROUTER_IDS.EVENT_DETAIL,
children: [
{ index: true, element: <EventDetailPage />, action: deleteEventActoin },
{ path: "edit", element: <EditEventPage />, action: manipulateEventAction }
]
},
하지만 useLoaderData()훅은 현재 라우트에 정의된 loader함수의 데이터를 가져오기 때문에 <EventDetailPage />나 <EditEventPage />에서 useLoaderData()를 사용하면 오류가 발생한다.
이럴때 사용하는 훅으로 useRouteLoaderData(id)가 존재한다.
위의 코드를 자세히 보면 부모 라우트에 id를 정의한 것을 볼 수 있다.
부모에 정의된 id를 통해 부모의 loader함수에 접근할 수 있으며, 해당 함수에서 반환하는 데이터를 사용할 수 있게된다.
import { useRouteLoaderData } from "react-router-dom";
import EventForm from "../components/EventForm";
import { ROUTER_IDS } from "../constants/constants";
import { EventType } from "../types/type";
const EditEventPage = () => {
const { event } = useRouteLoaderData(ROUTER_IDS.EVENT_DETAIL) as { event: EventType };
return <EventForm method="PATCH" event={event} />;
};
export default EditEventPage;
일반적으로 react프로젝트에서 데이터를 fetch해오고 해당 데이터를 통해 UI를 변경시킨다고 할때 데이터를 fetch해오는 작업보다 컴포넌트가 그려지는 과정이 더 오래걸리는 경우가 있기 때문에 useEffect를 사용하여 화면이 모두 그려진 이후에 데이터를 fetch해오는 작업을 수행한다.
하지만 React Router의 loader가 실행되는 시기는 먼저 해당 라우트로 이동시에 백엔드에 데이터를 요청하게되고, 해당 작업이 끝나면 해당 페이지를 렌더링하는 순서로 동작한다.
따라서 만약에 백엔드에서 데이터를 전송하기까지 2초가 걸린다고 하면, 사용자가 해당 페이지로 이동하는 명령을 보내고 2초 뒤에, 즉 데이터를 모두 전달받은 후에 화면이 그려지게 된다.
한마디로 데이터가 전부 로딩된 후에 해당 컴포넌트가 호출된다.
그렇기 때문에 만약 지연시간이 생기면 사용자에게 적절한 피드백을 해주는 것이 중요하다.
React Router는 사용자에게 현재 라우팅 상태를 알려주는 훅을 제공한다.
useNavigation()훅은 객체를 반환하는데 그 중 state프로퍼티는 현재 상태를 알려주는 역할을 한다.
state프로퍼티는 idle, loading, submitting 이렇게 3가지중 한가지를 값으로 가지는데, 각각 라우트 전환이 일어나지 않는 상태, 데이터 로딩중 상태, 데이터 제출중 상태를 의미한다.
따라서 state가 loading일 경우 fallback을 보여줄 수 있게 된다.
import { Outlet, useNavigation } from "react-router-dom";
import MainNavigation from "../components/MainNavigation";
const RootLayout = () => {
const navigation = useNavigation();
const { state } = navigation;
return (
<>
<MainNavigation />
<main>
{state === "loading" && <p style={{ textAlign: "center" }}>Loading...</p>}
<Outlet />
</main>
</>
);
};
export default RootLayout;
위의 방법을 사용하면 현재 네비게이션 상태를 사용하여 사용자에게 피드백을 줄 수 있다.
하지만 백엔드에서 데이터를 받아오는데 시간이 오래걸리면 사용자는 여전히 해당 페이지를 들어갈때까지 많은 시간이 걸리게 된다.
만약 현재 이동하는 페이지에서 데이터를 가져오는데 관여하지 않는 컴포넌트들은 미리 렌더링하여 사용자에게 보여줄 수 있다면 더 좋은 사용자 경험을 이끌어 낼 수 있을것이다.
먼저 현재 React Router는 v7로 업그레이드되면서 사라진 함수들이 있는데, 해당 기능을 구현하기 위해 과거에 사용되었던 defer()함수도 최신버전부터는 사라졌다.
React Router는 defer()함수에 대한 대안으로 loader함수가 Promise를 반환하게 한 뒤, react의 <Suspense />컴포넌트와 react router의 <Await />컴포넌트를 사용하는 것을 권장한다.
이를 위해 커스텀 fetch함수를 사용하여 다음과 같이 구현하였다.
export function fetchData<T>(input: RequestInfo | URL, init?: RequestInit) {
const data = fetch(input, init)
.then((res) => res.json())
.then((data: T) => data)
.catch(() => {
throw new Response(JSON.stringify({ message: "Could not fetch!" }), {
status: 500,
headers: { "Content-Type": "application/json" }
});
});
return data;
}
먼저 fetchData()함수를 살펴보면 fetch함수를 통해 http요청을 보내는 작업을 Promise체이닝을 통해 수행하며 해당하는 Promise 자체를 return해준다.
이렇게 해야하는 이유는 만약 이것을 비동기 함수로 바꾸어 async/await을 사용하게 되면 await fetch()부분에서 이미 백엔드로부터 데이터를 받아올때까지 기다리는 것이 되어버려 fallback컴포넌트를 불러올 수 없게된다.
따라서 반드시 Promise체이닝을 사용하여 Promise를 반환해주어야 한다.
import { Await, useLoaderData } from "react-router-dom";
import EventsList from "../components/EventsList";
import { EventType } from "../types/type";
import { Suspense } from "react";
import { fetchData } from "../utils/http";
type loadedDataType = { data: Promise<{ events: EventType[] }> };
function EventsPage() {
const { data } = useLoaderData<loadedDataType>();
return (
<Suspense fallback={<p style={{ textAlign: "center" }}>Loading...</p>}>
<Await resolve={data}>{(loadedEvents) => <EventsList events={loadedEvents.events} />}</Await>
</Suspense>
);
}
export default EventsPage;
export function eventsLoader() {
const data = fetchData<{ events: EventType[] }>("http://localhost:8080/events");
return { data };
}
또한 loader함수에서 fetchData()를 통해 반환된 Promise를 객체 형태로 반환하고있는데, React Router는 loader에서 Promise를 직접 반환하면, 해당 Promise가 해결될 때까지 라우트 전환을 멈추기 때문에 꼭 객체로 감싸서 반환해야한다.
이렇게 받은 데이터를 <Suspense />와 <Await />을 통해 fallback UI를 보여줄 수 있다.
<Suspense />컴포넌트는 비동기 데이터가 로드될 때까지 대기하는 동안 Fallback UI를 제공하는 컴포넌트로, react에서 제공하는 컴포넌트이다.
<Await />컴포넌트는 <Suspense />컴포넌트와 함께 사용해야 하는 컴포넌트로, React Router에서 비동기 데이터를 기다리는 동안 대체 UI를 제공하는 컴포넌트이다.
resolve에 넣은 Promise가 해결될때까지 기다린 뒤, 전부 해결되었을 때 함수를 통해 해당 데이터를 사용할 수 있게 해준다.
기다리는 동안에는 <Suspense />를 통해 정의한 fallback UI를 보여준다.
import {
ActionFunctionArgs,
Await,
LoaderFunctionArgs,
Params,
redirect,
useRouteLoaderData
} from "react-router-dom";
import EventItem from "../components/EventItem";
import { EventType, parameterIds } from "../types/type";
import { ROUTER_IDS } from "../constants/constants";
import EventsList from "../components/EventsList";
import { Suspense } from "react";
import { fetchData } from "../utils/http";
interface loadedDataType {
event: Promise<{ event: EventType }>;
events: Promise<{ events: EventType[] }>;
}
const EventDetailPage = () => {
const { event, events } = useRouteLoaderData(ROUTER_IDS.EVENT_DETAIL) as loadedDataType;
return (
<>
<Suspense fallback={<p style={{ textAlign: "center" }}>Loading...</p>}>
<Await resolve={event}>{(loadedEvent) => <EventItem event={loadedEvent.event} />}</Await>
</Suspense>
<Suspense fallback={<p style={{ textAlign: "center" }}>Loading...</p>}>
<Await resolve={events}>
{(loadedEvents) => <EventsList events={loadedEvents.events} />}
</Await>
</Suspense>
</>
);
};
export default EventDetailPage;
interface eventDetailLoaderArgs extends LoaderFunctionArgs {
params: Params<parameterIds>;
}
export function eventDetailLoader({ params }: eventDetailLoaderArgs) {
const id = params.eventId;
const event = fetchData<{ event: EventType }>(`http://localhost:8080/events/${id}`);
const events = fetchData<{ events: EventType[] }>("http://localhost:8080/events");
return { events, event };
}
위의 코드와 같이 여러개의 서로 다른 지연 컴포넌트를 사용할수도 있다.
데이터 제출도 마찬가지로 form을 이용하여 직접 데이터를 넘기는 방법과 폼 액션을 이용하는 방법을 제외하고 react router에서 자체적으로 제공하는 여러 방법들이 존재한다.
loader와 마찬가지로 라우트의 action속성에 함수를 전달하여 데이터를 제출하는 방식이다.
해당 action속성에 전달된 함수는 해당 라우트의 form이 제출되면 자동으로 실행된다.
action함수도 ActionFunctionArgs를 타입으로 갖는 객체를 인자로 받는데, 해당 객체의 request.formData()를 통해 데이터를 가져올 수 있다.
export async function newEventAction({ request }: ActionFunctionArgs) {
const data = await request.formData();
const eventData = Object.fromEntries(data.entries());
const url = "http://localhost:8080/events";
const response = await fetch(url, {
method: "POST",
headers: { "Content-type": "application/json" },
body: JSON.stringify(eventData)
});
if (response.status === 422) {
return response;
}
if (!response.ok) {
throw new Response(JSON.stringify({ message: "Could not save event." }), {
status: 500,
headers: { "Content-type": "application/json" }
});
}
return redirect("/events");
}
이때 중요한것은 action을 사용하는 컴포넌트의 form을 react router가 제공하는 <Form />컴포넌트로 바꿔주어야 한다.
해당 컴포넌트를 사용하게되면 폼이 제출될 때 자동으로 action이 현재 라우트로 지정되며, 새로고침도 막아준다.
폼을 이용하여 데이터를 제출하는 방법 이외에도 useSubmit을 사용하여 프로그래밍적으로 데이터를 제출하는것도 가능하다.
useSubmit훅은 함수를 반환하는데, 해당하는 함수는 인자로 target과 options를 갖는다.
submit: SubmitFunction(target: SubmitTarget, options?: SubmitOptions) => Promise<void>
target은 데이터를 받으며, options는 method, action 등의 옵션을 받는다.
이러한 submit함수가 호출되면 현재 라우트의 action을 실행한다.
import { Link, useSubmit } from "react-router-dom";
import classes from "./EventItem.module.css";
import { EventType } from "../types/type";
interface EventItemProps {
event: EventType;
}
function EventItem({ event }: EventItemProps) {
const submit = useSubmit();
function startDeleteHandler() {
const proceed = window.confirm("Are you sure?");
if (proceed) {
submit(null, { method: "DELETE" });
}
}
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;
...
...
{
path: `:${PARAMS_IDS.EVENT_ID}`,
loader: eventDetailLoader,
id: ROUTER_IDS.EVENT_DETAIL,
children: [
{ index: true, element: <EventDetailPage />, action: deleteEventActoin },
{ path: "edit", element: <EditEventPage />, action: manipulateEventAction }
]
},
...
...
위에서 설명한 useNavigation()훅을 사용하여 현재 상태가 제출중인지 확인하고, 해당 상태를 사용할 수 있다.
action에서 반환하는 데이터를 사용하는것은 loader에서 받은 데이터를 사용할때와 매우 유사하다.
const data = useActionData<ActionResponseType>();와 같이 useActionData()훅을 사용하면 현재 라우트의 action이 실행되고 반환된 데이터를 받아와 사용할 수 있다.
useFetcher() 훅은 페이지 이동 없이 데이터를 가져오거나 제출할 때 사용되는 react router가 제공하는 훅이다.
useSubmit()과 유사하지만 라우트 이동 없이 데이터만 전송하거나 가져올 수 있다.
useFetcher()는 다음과 같은 속성이 포함된 객체를 반환한다.
다음과 같이 사용하여 라우트 이동 없이 action을 호출하고, 전송이 완료되면 피드백 메세지를 띄울 수 있다.
import { useFetcher } from "react-router-dom";
import classes from "./NewsletterSignup.module.css";
import { useEffect } from "react";
import { NewsletterSignupResponseType } from "../types/type";
function NewsletterSignup() {
const fetcher = useFetcher<NewsletterSignupResponseType>();
const { data, state } = fetcher;
useEffect(() => {
if (state === "idle" && data && data.message) {
window.alert(data.message);
}
}, [data, state]);
return (
<fetcher.Form method="post" action="/newsletter" className={classes.newsletter}>
<input
type="email"
name="email"
placeholder="Sign up for newsletter..."
aria-label="Sign up for newsletter"
/>
<button>Sign up</button>
</fetcher.Form>
);
}
export default NewsletterSignup;
router를 정의할 때 errorElement에 컴포넌트를 넘겨주는 것으로 에러 페이지를 로딩할 수 있었다.
여기서는 추가로 action이나 loader를 사용할 때 오류가 발생할 경우 해당 오류를 어떻게 렌더링할지 알아볼것이다.
기본적으로 react router에서는 useRouteError()훅을 제공하여 에러가 발생하면 에러를 해당 훅을 통해 가져와서 사용하는 것이 가능하다.
useRouteError()훅에서 반환한 객체는 status와 message등을 포함할 수 있으며, 다음과 같이 사용된다.
import { isRouteErrorResponse, useRouteError } from "react-router-dom";
import MainNavigation from "../components/MainNavigation";
import PageContent from "../components/PageContent";
const ErrorPage = () => {
const error = useRouteError();
let title = "An error occurred!";
let message = "Something went wrong!";
console.log(error);
if (isRouteErrorResponse(error)) {
if (error.status === 500) {
message = error.data?.message || "Something went wrong!";
}
if (error.status === 404) {
title = "Not found!";
message = "Could not find resource or page";
}
}
return (
<>
<MainNavigation />
<PageContent title={title}>{message}</PageContent>
</>
);
};
export default ErrorPage;