react-router의 기본에 대한 내용을 이전 블로그에서 다뤘다.
이번에는 router를 활용하여 router가 제공하는 native form을 대체하는 Form
과 특정 route에 접근시 component rendering이전에 data를 fetching해주는 loader
에 대해서 알아보자.
data를 만들고 data에 따른 navigation list를 보여주기 위해서 contacts라는 data를 활용할 것이다.(이 글은 docs tutorial을 따라한다.)
일단은 contacts를 만들지 않았으므로 contacts를 빈 배열로 초기화하고 이에 따른 UI를 조금 변경하자.
import { Form, Link, Outlet } from "react-router-dom";
import { useState } from "react";
type Contact = {
id: string;
createdAt: Date;
first: string;
last: string;
avatar: string;
twitter: string;
notes: string;
favorite: boolean;
};
export default function Root() {
// 👇 빈 배열로 초기화
const [contacts, setContacts] = useState<Contact[]>([]);
return (
<>
<div id="sidebar">
...//
<nav>
// 👇 contacts에 따라 보여주는 UI
{contacts.length ? (
<ul>
{contacts.map((contact) => (
<li key={contact.id}>
<Link to={`contacts/${contact.id}`}>
{contact.first || contact.last ? (
<>
{contact.first} {contact.last}
</>
) : (
<i>No Name</i>
)}{" "}
{contact.favorite && <span>★</span>}
</Link>
</li>
))}
</ul>
) : (
<p>
<i>No contacts</i>
</p>
)}
</nav>
...//
</>
);
}
이제 브라우저를 확인해보면 아래와 같이 나올 것이다.
그리고 contacts에 mock data를 넣어서 확인해보면 no name
을 확인할 수 있다.
이제 data를 만들어보자.
대부분 사용자의 입력으로부터 데이터를 만들기 위해서 form tag
를 활용한다. 하지만 이 navtive form의 기본동작은 CSR
동작과는 좀 괴리감이 존재한다.
따라서, router가 제공하는 Form Component
를 활용하여 새로운 데이터를 만들자. 그 전에, native form과 router가 제공하는 Form component는 어떤 것이 다르고 Form이 발생시키는 action
에 대해서 알아보고 진행하자.
Form Component를 알기 위해서는 native form의 기본 동작을 이해해야 한다.
native form
- 서버에게 요청을 수행.
- 새로고침을 유발
Form Component
- 새로고침을 막아 state가 날아가는 것을 방지
- action을 발생시킴
- 현재 route로 요청을 보냄
<from docs>
<Form
> prevents the browser from sending the request to the server and sends it to your route action instead.
간략히 요약하자면 router가 제공하는 Form
은 서버로 요청을 보내는 것이 아닌 현재 페이지로 action
을 발생시켜 action을 처리하는 방식이다. native form이 발생시키는 새로고침으로 인해 state 날아감과 깜빡임을 방지하여CSR
동작을 유지하기 위해 router가 따로 Form
을 제공하는 이유이다.
그리고 action에 대한 처리가 끝나 생성된 data를 useLoaderData hook
을 이용하여 관련된 state 참조하고 있는 componenent들은 re-rendering이 발생한다.
왜 Re-rendering을 유발하는지 이해하는데 시간이 좀 걸렸는데, form의 목적에 기반하여 생각하니 무슨 뜻인지 이해할 수 있었다.
docs에서는 아래와 같이 말한다.
In web semantics, a POST usually means some data is changing. By convention, React Router uses this as a hint to automatically revalidate the data on the page after the action finishes. That means all of your useLoaderData hooks update and the UI stays in sync with your data automatically!
이를 보면 form이 수행하는 것은 POST요청인데 결국 data mutation
에 관한 것이다. data의 변경이 일어나면 당연히 이 data와 관련 있는 component들은 re-rendering을 하여 새로운 data를 기반으로 사용자에게 최신 UI를 보여주어야 한다. 즉, 최신 data와 UI가 Sync(동기화)가 되어야 하기 때문에 re-rendering을 유발하는 것이다.
이는 블로그 어플리케이션에서도 찾아볼 수 있는데, 다른 사용자가 특정 게시글을 올리면 서버와의 Data와 client와의 data의 동기화를 통해 나는 그 블로그를 즉각적으로 확인할 수 있는 예시를 들어볼 수 있을 것 같다.
사용자의 입력을 기반으로 data를 생성할 때 사용하는 것이 form이 하는 역할이라고 했다.
navigation에 존재하는 New button을 click하여 사용자가 action을 trigger해 새로운 data
를 만들자.
발생한 action을 router가 인지하여 내가 원하는 처리(데이터 생성)
를 할 수 있게 root에 존재하는 router에 action을 등록하자. 이 때 action을 감지하는 경로는 root
이므로 root router에 등록한다.
// main.tsx
import Root, { action as rootAction } from "./routes/root.tsx";
const router = createBrowserRouter([
{
path: "/",
element: <Root />,
errorElement: <ErrorPage />,
// 👇 new button을 눌렀을 때 발생하는 action에 의해 rootAction함수가 실행 됨
action: rootAction,
...//
},
]);
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<RouterProvider router={router} />
</React.StrictMode>
);
// routes/root.tsx
import { Form, Link, Outlet } from "react-router-dom";
import { createContact } from "../contacts";
// action 구현
export const action = async () => {
const contact = await createContact();
return { contact };
};
export default function Root() {
return (
<>
<div id="sidebar">
<h1>React Router Contacts</h1>
<div>
...//
// 👇 새로운 contact를 만드는 action을 유발
<Form method="POST" action="">
<button type="submit">New</button>
</Form>
</div>
...//
</div>
...//
</>
);
}
이제 navigation에 있는 new button을 클릭하여 console로 확인해보면 데이터가 생성된 것을 확인할 수 있다.
지금은 useLoaderData
를 이용해서 contacts data를 참조하고 있지 않기 때문에 계속 no contacts
로 뜨는 것이 맞다. 따라서 localforge의 코드를 살펴봤다.
코드를 보면 localforage
를 이용하여 browser 저장소를 사용한다. localstorage에 있겠거니 해서 확인해 본 결과 없었다....
interface LocalForage extends LocalForageDbMethods {
LOCALSTORAGE: string;
WEBSQL: string;
INDEXEDDB: string;
...//
}
localstorage가 아닌 indexeddb에도 존재할 수 있겠구나 생각하여 INDEXEDDB
에서 생성된 contact data를 확인할 수 있었다.
router를 이용하여 data를 loading하기 위해서는 2개의 API를 사용한다.
loader
:비동기 함수
로서 path에 일치하는 페이지로 이동(route 변경) 시 component가 rendering되기 전에 데이터를 fetching(pre-fetching
)한다.
useLoaderData
: pre-fetching한 데이터에 접근하능하게 해 주는 hook이다.
loader에 대해서 좀 더 살펴보자.
docs에서 loader의 정의를 아래와 같이 말한다.
loader
Each route can define a "loader" function to provide data to the route element before it renders.
loader를 사용할 때 알아두어야 할 것이 있는데, loader는 pre-fetching을 기능을 제공하지만, 경로에 접근할 때 마다 data를 fetching하므로 불필요한 fetching
이 계속 일어난다. 또한 caching
을 제공하지 않기 때문에 stale time에 따라 data를 최신화하는 작업을 직접 caching기능을 구현하던가 react-query와 연계하여 사용하면 단점을 보완할 수 있다.
🔎 loader 단점
1. route 접근 시 최신 data여도 불필요한 fetching을 함
2. caching을 지원하지 않음
그리고 loader는 이전에 주의사항이 존재하는데 docs에서 찾아볼 수 있다.
⚠️ This feature only works if using a data router, see Picking a Router
v6.4부터는 어떤 라우터를 쓰느냐에 따라서 data API인 loader를 사용할 수 있고가 정해져 있다. 대부분browser router
를 사용한다.
createBrowserRouter
: data API제공BrowserRouter
: data API 미제공
loader
는 router v6부터 제공되는 기능으로 이전 버전에는 BrowserRouter로 application에 라우터를 제공한다.
따라서 v6로 버전을 migration하기 위해서는 createRouterFromElement를 이용하여 createBrowserRouter가 가지는 값으로 변환해주어야 한다.
import {
createBrowserRouter,
createRoutesFromElements,
Route,
RouterProvider,
} from "react-router-dom";
const router = createBrowserRouter(
createRoutesFromElements(
<Route path="/" element={<Root />}>
<Route path="dashboard" element={<Dashboard />} />
{/* ... etc. */}
</Route>
)
);
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<RouterProvider router={router} />
</React.StrictMode>
);
loader에 대해 알아봤으니 loader를 적용해보자.
loader는 위에서 언급했 듯이 route 변경 시 component가 rendering되기 전 넣어준 비동기 함수를 호출해주는 data를 fetching하는 함수이다.
따라서 contact를 가져오는 loader를 정의
하고 router에 loader를 등록
하자.
// routes/root.tsx
import { Outlet, Link } from "react-router-dom";
import { getContacts } from "../contacts";
// 👇 loader 정의
export async function loader() {
const contacts = await getContacts();
return { contacts };
}
// main.tsx
import Root, { action as rootAction, loader as rootLoader } from "./routes/root.tsx";
const router = createBrowserRouter([
{
path: "/",
element: <Root />,
errorElement: <ErrorPage />,
action: rootAction,
// 👇 loader 등록
loader: rootLoader,
children: [
{
path: "contacts/:contactId",
element: <Contact />,
},
],
},
]);
loader를 설정해 주었으니 root route에 접근하면 loader가 contacts에 대한 data를 fetching할 것이다. 이제 fetching한 data에 접근하기 위해서 useLoaderData hook
을 사용하여 data에 접근되는지 살펴보자.
useLoaderData
로 변경.import { Form, Link, Outlet, useLoaderData, LoaderFunction } from "react-router-dom";
import { createContact, getContacts } from "../contacts";
type Contact = {
id: string;
createdAt: Date;
first: string;
last: string;
avatar: string;
twitter: string;
notes: string;
favorite: boolean;
};
type LoaderData<TLoaderFunc extends LoaderFunction> = Awaited<
ReturnType<TLoaderFunc>
> extends Response | infer D
? D
: never;
export const loader = (async (): Promise<{ contacts: Contact[] }> => {
const contacts = await getContacts();
return { contacts };
}) satisfies LoaderFunction;
export const action = async () => {
const contact = await createContact();
return { contact };
};
export default function Root() {
// 👇 useLoaderData로 변경
const { contacts } = useLoaderData() as LoaderData<typeof loader>;
return (
<>
<div id="sidebar">
...//
<nav>
{contacts.length ? (
<ul>
{contacts.map((contact) => (
<li key={contact.id}>
<Link to={`contacts/${contact.id}`}>
{contact.first || contact.last ? (
<>
{contact.first} {contact.last}
</>
) : (
<i>No Name</i>
)}{" "}
{contact.favorite && <span>★</span>}
</Link>
</li>
))}
</ul>
) : (
<p>
<i>No contacts</i>
</p>
)}
</nav>
</div>
...//
</>
);
}
이제 확인해보면 변경사항을 찾아볼 수 있다.
<useLoaderData 사용 전>
<useLoaderData 사용 후>
가져온 contacts의 데이터가 존재하여
contacts
가 아닌no name
이 표시되어 sync되는 것을 확인할 수 있다.
router loader에 대한 typescript를 정의하기 어려웠는데 따로 (여기)[https://velog.io/@rlwjd31/router-loader-typescript]에 정리했다.
type 정의가 어렵다면 그냥 아래와 같이 사용해도 무방하지만 여러 곳에서 쓰이는 데이터라면 좋지 않은 방법이라 생각한다.
const { contacts } = useLoaderData() as { contacts: Contact[] };
이렇게 정의해주면 contacts를 루핑할 때 contact의 type을 알 수 있다.
typescript를 공부하긴 했지만, 이론과 직접 적용은 확실히 다르다. loader에 대한 type을 정의하기 위해 많은 곳을 찾았는데 가장 마음에 드는 답변을 찾아 그에 대해서 공부했다. type 정의가 이해가 안 가는 부분도 있었지만, 차근이 풀어보니 이해가 됐다.
type에 대해서 고민하기 싫은 사람들은 react-router-typesafe
를 사용해도 좋을 것 같지만, 난 typescript가 아직 부족하므로 직접 다 하는 것이 좋다 생각한다.