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;
리액트 라우터 v.6 이상을 사용 중이라면 데이터를 가져오고 다양한 상태들을 처리하는 위의 코드를 사실은 작성하지 않아도 된다! 🤭 리액트 라우터가 다 도와주기 때문이다.
{ loader: () => {} }
데이터 가져온 후에 컴포넌트 렌더링하기 위해 데이터를 패치하는 라우트인 EventsPage에 loader 프로퍼티를 추가하고 함수를 작성한다.
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;
리액트 라우터는 loader 함수에서 리턴하는 모든 값을 자동으로 취하고, 그 값을 렌더링하는 라우트의 컴포넌트 페이지 뿐만 아니라 다른 모든 컴포넌트에도 제공한다.
return resData.events;
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를 리턴한다.return <EventsList events={events} />;
이렇게 이벤트 어레이인 events 객체를 값으로 전달할 수 있다.
useLodaerData() 훅을 EventsPage 페이지에 사용하면 loader에 추가한 라우트에 의해 렌더링 된다.
다른 곳에서도 useLodaerData() 훅을 사용할 수 있다.
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;
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데이터에 접근할 수는 없다!
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;
}
};
//...
//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는 우리가 그 페이지로 이동하기 시작할 때, 즉 실제로 가기 전에 호출된다.
아래 코드는 데이터를 프론트엔드로 리턴하는 역할을 하는 백엔드 코드이다.
const events 라인과 res.json 라인 사이에 setTimeout을 추가한후 res.json를 setTimeout에 넣고 시간을 지연시킨 후 백엔드 서버를 다시 실행해 보자.
그러고 나서 홈에서 이벤트 페이지를 눌렀을 때, 지연된 시간 뒤에 화면이 렌더링 되는 것을 확인할 수 있다.
장점
EventsPage 컴포넌트가 렌더링되고 있을 때, 데이터가 거기 있다는 것을 확신할 수 있다는 점이다. 따라서 데이터가 있는지 없는지 걱정할 필요가 없다.
단점
데이터를 가져오기 까지 지연이 있고, 사용자가 보기에는 아무 일도 일어나지 않는 것처럼 보인다는 점이다.
따라서 사용자 경험을 개선하기 위해 조치를 취해야 한다.
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;
이 방법은 현재 데이터를 기다리는 중인지 알아내고 로딩 인디케이터를 불러 올 수 있는 한 가지 방법이다.
다른 솔루션도 있으므로 이건 알고만 지나가자.
여기서는 응답 데이터의 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();
컴포넌트는 아니지만 브라우저에 있으므로 여전히 클라이언트 측 코드이다.
이 점이 아주 중요하다.
const res = new Response("any data", { status: 201 });
loader에서 이런 응답을 리턴할 때 마다 리액트 라우터 패키지는 useLoaderData를 사용할 때, 내 응답에서 자동으로 데이터를 추출한다.
따라서 useLoaderData가 리턴하는 데이터는 내가 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;
}
};
어쨌든 이 기능이 존재하는 이유는 loader 함수에서 브라우저에 내장된 fetch 함수로 백엔드에 도달하는 방식을 자주 사용하기 때문이다.
fetch함수는 실제로 Response로 resolving 되는 Promise를 리턴한다.
리액트 라우터는 이런 응답 객체를 지원하고 자동으로 데이터를 추출하기 때문에 결국, 간단히 말해 여기서 받은 response, 즉 이 응답 객체를 취하여 내 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;
return response;
}
};
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 안에서 정의된 코드는 서버가 아닌 브라우저에서 실행된다.
앞에서는 오류를 처리할 때 useEffect 기반 솔루션을 사용하여 수작업으로 오류를 처리했었다.
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을 잠깐 이상하게 바꿔보면 이렇게 오류 메시지가 표시된다.
하지만 다른 방법도 있다.
throw new Error("이벤트를 가져올 수 없습니다.");
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;
}
};
Response를 생성하여 에러메시지를 보낼 수도 있다.
throw new Response(
JSON.stringify({ message: "이벤트를 가져올 수 없습니다." }),
{ status: 500 }
);
에러페이지를 설정해 두면 라우트의 어디서나 어떤 종류의 오류가 발생하더라도 에러페이지가 표시된다.
중첩된 라우트인 EventsPages의 loader에서도 오류를 내도 에러가 bubble up 되어서 에러페이지에 오류가 출력된다.
여튼, loader에서 오류가 던져지면 리액트 라우터는 가장 근접한 오류 엘리먼트를 렌더링한다!
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
이렇게 같은 오류 페이지에서 조건에 따라 오류를 잡을 수 있다.
리액트 라우터를 사용할 때 종종 Response를 생성한다. 특히 오류를 throw할 때 말이다.
하지만 이렇게 수작업으로 Response를 생성하는 것은 조금 귀찮은 일이다.
리액트 라우터는 헬퍼 유틸리티를 제공하는데, Response를 만들고 throw하는 대신 json()
호출 결과를 throw하여 동일한 효과를 가질 수 있다.
json()
은 react-router-dom에서 임포트하는 함수로, json 형식의 데이터가 포함된 Response객체를 생성하는 함수이다.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형식을 파싱할 필요도 없어진다.
if (error.status === 500) {
message = error.data.message;
}
먼저 이벤트 리스트에 Link를 추가하여 각 이벤트 id 별로 url이 형성되게 해준다.
이제 이벤트 디테일 페이지를 만들어 보자.
이벤트 디테일 페이지에서는 <EventItem />
컴포넌트를 출력하자.
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가지가 들어있다.
요청 객체 담은 request 프로퍼티
따라서 loader 안의 request 객체를 사용하여 url에 접근할 수 있다.
예를 들면 쿼리 파라미터를 추출하는 등의 모든 작업을 할 수 있다.
모든 라우트 파라미터가 담긴 params 프로퍼티
new
파라미터:id
라우트 파라미터 :id/edit
파라미터import EventDetailPage,
{ loader as eventDetailLoader } from "./pages/EventDetailPage";
//...
{
path: ":id",
element: <EventDetailPage />,
loader: eventDetailLoader,
},
로더를 라우트에 등록하면, EventDetailPage에 방문하려 할 때 마다 loader가 먼저 호출되고 데이터를 받아 올 수 있다.
데이터 받아와서 EventItem 컴포넌트의 props으로 데이터를 보내면 된다.
import { json, useLoaderData } from "react-router-dom";
import EventItem from "../components/EventItem";
const EventDetailPage = () => {
const data = useLoaderData();
return (
<>
<EventItem event={data.event} />
</>
);
};
이제 이벤트 상세페이지에서 edit 버튼을 클릭하면 form으로 이벤트 내용을 수정할 수 있게 해보자.
edit 버튼을 누르면 수정할 수 있는 form이 뜨는데, 이 때 form의 기본 값에 데이터를 미리 채워두자. 그러기 위해서는 부모 라우트의 데이터를 동일하게 사용해야 한다.
loader가 추가된 라우트 보다 같거나 낮은 수준의 컴포넌트에서 loader 데이터에 접근 가능하다.
따라서 상위 라우트에 작성한 로더는 하위 라우트를 방문할때도 실행된다.
:id
):id
, :id/edit
){
path: ":id",
loader: eventDetailLoader,
id: "event-detail",
children: [
{
index: true,
element: <EventDetailPage />,
},
{ path: "edit", element: <EditEventPage /> },
],
},
값은 원하는대로 설정하면 된다.
id를 설정하지 않고 useLoaderData()로 데이터를 받아올 경우, data를 제대로 받아오지 못하는 오류가 발생한다.
따라서 부모 라우트의 데이터를 사용하기 위해 라우트에 id 프로퍼티
추가하고, useRouteLoaderData("id값")
훅을 이용해 데이터를 받아오자.
useRouteLoaderData()
훅은 라우트의 id 프로퍼티의 값을 인자로 받는다.
데이터를 사용할 컴포넌트로 props으로 데이터를 보내자.
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;
}
};
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;
각 인풋의 기본 값 defaultValue
을 설정한다.
function EventForm({ method, event }) {
//..
//각 인풋의 기본 값 설정
defaultValue={event ? event.title : ""}
새로운 이벤트를 만들어 백엔드로 데이터를 보내보자.
양방향 바인딩, Refs 등으로 폼에서 데이터 추출하여 수동으로 HTTP 요청 전송 및 로딩/오류 상태 관리하고, 전송 완료 시 프로그램적으로(useNavigate) 페이지에서 나가기
✅ 리액트 라우터의 action으로 데이터 전송하기
loader를 사용하여 데이터를 로딩한 것 처럼, action을 사용하여 데이터를 전송 할 수 있다.
지금 전송하려는 데이터는 form으로 제출된 데이터이다.
리액트 라우터는 form 제출 시 데이터를 추출하는 것을 쉽게 도와줄 수 있는 <Form />
컴포넌트를 제공한다.
<Form />
은 백엔드 요청을 전송하는 브라우저 기본값을 생략할 수 있기 때문에 submitHandler에서 event.preventDefault()
를 사용하지 않아도 된다.<Form />
컴포넌트를 사용하면 이 폼 데이터를 포함한 요청은 자동으로 백엔드로 전송되는 것이 아니라 액션으로 전송된다.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>
//...
import NewEventPage, { action as newEventAction } from "./pages/NewEventPage";
//...
{ path: "new", element: <NewEventPage />, action: newEventAction },
loader 코드와 마찬가지로 action 코드도 사용하려는 라우트에 작성하면 되므로 NewEventPage
에 작성하여 export 하자.
Form에서 제출된 요청 데이터는, 리액트 라우트가 잡아 액션으로 요청을 포워딩한다고 했다. 따라서 action()함수를 사용하여 요청을 잡아야 한다.
{ request, params }
를 받는다.const data = await request.formData();
formData()
메서드를 request객체에 호출한다.const title = data.get("title");
get()
메서드를 호출하면 제출된 다양한 입력 필드 값에 접근할 수 있다.const newEventData = {
title: data.get("title"),
image: data.get("image"),
date: data.get("date"),
description: data.get("description"),
};
fetch(`http://localhost:8080/events`, {
method: "POST",
header: { "Content-Type": "application/json" },
body: JSON.stringify(newEventData),
//newEventData 백엔드로 전송하기 위해 json으로 감싸기
});
json()
과 마찬가지로 redirect()
도 react-router-dom
이 제공하는 특수 함수이다.
json()
과 마찬가지로 redirect()
도 응답 객체를 생성한다.return redirect("/events")
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");
};
<Form method="post">
//...
/new
)의 action() 함수를 자동으로 트리거한다.<Form action="다른 경로 A" method="post">
//...
다른 경로 A
라우트에 action 속성이 있다면, Form의 action 프로퍼티 값을 action을 트리거하는 다른 경로 A
라우트의 경로로 설정하면 된다.다른 경로 A
라우트 정의 객체의 경로가 트리거 된다.따라서 현재 활성인 라우트의 action을 트리거하려면 action 프로퍼티를 굳이 쓰지 않아도 된다.
Form 컴포넌트를 사용할수 없다면 useSubmit()훅을 사용하여 프로그램적으로 액션을 트리거할 수 있다.
이벤트 상세 페이지의 EventItem 컴포넌트에 있는 삭제 버튼을 클릭했을 때 이벤트 삭제 액션을 트리거할 수 있게 해보자.
EventItem 컴포넌트는 EventDetailPage 라우트에 대해 로딩되는 EventDetailPage의 일부로 렌더링 되기 때문에 EventDetailPage 라우트에 이벤트 삭제 하는 액션을 추가해야 한다.
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 /> },
],
},
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");
};
삭제 버튼을 Form으로 감싸버리면 쉽게 액션이 트리거 되지만 여기서는 confirm도 실행하고 싶기 때문에 Form 컴포넌트를 사용할 수 없다.
따라서 따로 액션을 트리거하려면 프로그램적으로 약간의 데이터를 추가해야 한다.
이럴 때 useSubmit()
훅을 사용하면 된다.
useSubmit()
훅의 두 가지 인자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;
백엔드 코드를 잠시 지연 시킨 후 새로운 이벤트 등록 시 사용자에게 피드백을 주는 코드를 작성해 보자.
프론트엔드 코드 작성 후 확인한 뒤 이 코드는 다시 원상복구 시키면 된다.
//검증 성공 시 데이터 전송
//잠시 지연시켜서 프론트 엔드 코드 설정 잘되었는지 확인
try {
await add(data);
//setTimeout 1.5초 지연 시키기
setTimeout(() => {
res.status(201).json({ message: "Event saved.", event: data });
}, 1500);
} catch (error) {
next(error);
}
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
로 오류 응답을 회신하고 있는데, 이를 활용하여 프론트 엔드에서 제출된 상태코드에 따른 오류에 대응해 보자.검증 오류인 경우 기본 오류 페이지를 표시하는 대신 현 페이지에서 폼 바로 위에 검증 오류 표시 해보자.
액션에서 출력하려는 데이터를 폼 위에 리턴하기 위해서는
//..
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");
};
loader 안에서 response를 리턴하여 컴포넌트 페이지에서 response 데이터를 사용할 수 있는 것처럼, 리턴한 action 데이터도 페이지와 컴포넌트에서 사용할 수 있다.
즉, action에서 response를 리턴하면 이 response는 loader에서 처럼 리액트 라우터에 의해 자동으로 파싱된다.
const data = useActionData();
useActionData()
훅은 가장 가까운 action이 리턴한 데이터에 접근할 수 있게 해준다.이 데이터는 검증 오류가 있을 경우 백엔드에서 리턴하는 데이터로 백엔드를 살펴보면 errors 객체의 프로퍼티 별로 메시지가 다 다른 것을 확인 할 수 있다.
따라서 액션이 리턴한 response 객체인 `data에 접근하여 사용하면 된다.
data
가 있는지 확인하고 에러객체가 있다면 JS 내장 함수인 Object.values()
로 errors 객체 안의 모든 키 반복하여 data
에 매핑한다.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>
이벤트를 수정하는 기능을 추가해보자.
EditEventPage에서 사용하려는 action의 기능은 NewEventPage에서 사용하는 action과 method만 다를 뿐 거의 동일하다. 왜냐하면 같은 데이터를 가진 같은 폼이기 때문이다.
액션을 재사용하기 위해 다른 부분에 대해서는 동적으로 데이터가 적용될 수 있게 코드를 수정하면 된다.
//📍 NewEventPage: EventForm에서 이벤트 생성
return <EventForm method="POST" />;
//📍 EditEventPage: EventForm에서 이벤트 편집/수정
return <EventForm method="PATCH" event={data.event} />;
// 📍 EventForm
function EventForm({ method, event }) {
//..
return (
<Form className={classes.form} method={method}>
//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),
});
//...
새로운 이벤트 생성 시 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),
});
//...
import { action as manipulateEventAction } from "./components/EventForm";
//...
// new 라우트
{
path: "new",
element: <NewEventPage />,
action: manipulateEventAction,
},
// :id/edit 라우트
{
path: "edit",
element: <EditEventPage />,
action: manipulateEventAction,
},
이렇게 폼이 제출된 방법에 따라 약간 다른 일을 하는 action을 작성하여 동일한 액션을 다른 라우트에 재사용할 수 있다.
뉴스레터 페이지 컴포넌트에 작성해둔 뉴스레터구독액션이 있다고 하자.
그런데 뉴스레터 구독액션을 필요로하는 뉴스레터 구독 폼이 네비게이션에도 있고 뉴스레터 페이지에도 있다면 액션은 어떻게 트리거할 수 있을까?
만약 뉴스레터 페이지에만 구독 폼이 있다면 Form 컴포넌트 이용하여 액션을 자동으로 트리거 되게 하면 된다.
하지만 메인네비게이션의 일부로 구독 폼이 있다면 구독폼은 모든 라우트 포함되어 버린다.
그러면 이 액션을 모든 라우트에 추가해야 하는데 그러면 다른 액션이랑 충돌이 발생할 수도 있다.
이렇게 여러 곳에서 newsletterAction을 트리거해야 하지만 라우트 전환은 필요하지 않을 때 useFetcher()
를 사용할 수 잇다.
useFetcher()
훅은 액션이나 로더와 상호작용은 하지만 페이지 전환은 하지 않고 싶을 때 사용할 수 있다.const fetcher = useFetcher();
fetcher.Form
은 액션을 트리거하지만 라우트를 전환하지는 않는다.fetcher.load
도 마찬가지!<fetcher.Form
method="POST"
action="/newsletter"
//...
/newsletter
라고 지시하면 newsletter 라우트의 액션을 트리거한다. 중요한 점은 그 라우트의 엘리먼트인 컴포넌트는 로딩하지 않은 채 액션만 사용할 수 있다는 점이다.const { data, state } = fetcher;
fetcher 객체에는 트리거한 액션이나 로더가 성공했는지 알 수 있게 도와주는 프로퍼티가 많이 포함되어 있다.
따라서 액션이나 로더가 반환한 데이터에도 접근할 수 있다.
useNavigation 훅으로도 state를 알수 있는데 이 훅은 라우트 변경이 이루어 지는 경우에 사용해야한다.
fetcher의 state는, 트리거된 액션이나 로더를 배후의 fetcher가 완료했는지 알려준다.
이를 통해 ui를 업데이트할 수 있다
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;
데이터가 로딩되는(렌더링되는) 때를 연기하는 기능
백엔드의 get라우트에서 setTimeout으로 1.5초 늦게 응답을 받아보자.
그러면 이벤트가 나올때까지 1.5초 간 빈화면이 뜬다.
여기서 defer()
를 사용하여 로딩을 연기하고, 비록 데이터가 다 도착하지 않았더라도 컴포넌트를 미리 렌더링하라고 리액트 라우터에게 알릴 수 있다.
먼저 로더 안에서 Promise를 기다리기 원하지 않으므로 밖에서 함수를 만들자.
로더 안에서는 react-router-dom의 defer(객체)를 사용한다.
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;
}
};
export const loader = () => {
return defer({
events: loadEvents(),
});
};
defer()에는 객체{}
를 넣을 수 있다.
객체에 이 페이지에서 오갈 수 있는 모든 HTTP 요청을 넣어주면 된다.
여기에서는 요청이 loadEvents
한 개만 있다.
defer({ events: loadEvents() });
키 이름을 events로 하고 값으로 http요청건인 loadEvents를 넣어준다.
단순히 포인트(지시)만 하는 것이 아니라 loadEvents 함수를 실행해 주자.
그러면 loadEvents 함수가 실행되고 리턴한 값이 events 키에 저장된다.
async 함수이기 때문에 반환한 값을 Promise이다.
여튼 비록 그 미래 값인 Promise가 거기에 없어도 우리는 컴포넌트를 로딩하고 컴포넌트를 렌더링하려고 한다.
이제 연기된 데이터를 사용해 보자.
function EventsPage() {
//연기된 데이터
const { events } = useLoaderData();
//...
연기된 데이터를 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 resolve={events}> ... </Await>
Await 컴포넌트의 resolve 프로퍼티는 연기된 Promise 값을 취한다.
그러면 Await 컴포넌트는 데이터가 올 때 까지 기다리고, 이어서 시작 태그와 종료 태그 사이에서 역동적인 값을 출력한다.
{(loadedEvents) => <EventsList events={loadedEvents} />}
사이에 들어가는 동적인 값은 바로, 데이터가 도착하면, 즉 Promise가 resolving 되고 우리에게 데이터가 도착하면 리액트 라우터가 실행할 함수이다.
따라서 로딩된 이벤트를 받아서 EventList 호출해서 프롭으로 뿌려주면 된다.
마지막으로 react에서 제공하는 Suspense 컴포넌트로 Await 컴포넌트를 감싸주자.
<Suspense fallback={<p style={{ textAlign: "center" }}>로딩 중...</p>}> ... </Suspense>
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(),
});
};
현재 싱글 이벤트는 로더를 통해 상세 페이지 로딩 전에 싱글 이벤트 데이터를 미리 받아오고 있다.
이벤트 상세 페이지에서, 싱글 이벤트에 대한 데이터 뿐만 아니라 이벤트 리스트 데이터도 함께 http 요청을 받아 화면에 렌더링해보자.
이때 이벤트 리스트 데이터는 defer()를 사용하여 데이터를 지연시켜 페이지 로딩 후에 받아보자.
// 싱글 이벤트, 이벤트 리스트 defer()로 가져오는 loader 함수
export const loader = async ({ request, params }) => {
const id = params.id;
//🔥데이터 연기: 세부 조정 가능
//async 함수가 있는 async 로더가 있으면 await 키워드를 넣어서 그 데이터 로딩될 때 까지 기다렸다가 페이지 컴포넌트 로딩되게 함
return defer({
//페이지 이동 전 기다려야(await)하는 싱글 이벤트 데이터
//페이지 이동 후 바로 데이터 출력됨
event: await loadEvent(id),
//페이지 이동 후(데이터 연기하여) 로딩하면 되는 이벤트 리스트 데이터
//따라서 "로딩중..." 뜬 후 출력됨
events: loadEventsList(),
});
};
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(),
});
};
//...