로딩표시가 없으면 검색이 다소 느린 느낌이 듭니다. 어쩔 때는 앱이 멈춘것 처럼 보이기도 합니다. 데이터 베이스를 더 빠르게 만드는것도 중요하지만 더 나은 UX를 위해 검색에 대한 즉각적인 UI 피드백을 추가해 봅시다.
여기서는 useNavigation
을 사용합니다.
src/routes/root.jsx
// existing code
export default function Root() {
const { contacts, q } = useLoaderData();
const navigation = useNavigation();
const submit = useSubmit();
const searching =
navigation.location &&
new URLSearchParams(navigation.location.search).has(
"q"
);
useEffect(() => {
document.getElementById("q").value = q;
}, [q]);
return (
<>
<div id="sidebar">
<h1>React Router Contacts</h1>
<div>
<Form id="search-form" role="search">
<input
id="q"
className={searching ? "loading" : ""}
// existing code
/>
<div
id="search-spinner"
aria-hidden
hidden={!searching}
/>
{/* existing code */}
</Form>
{/* existing code */}
</div>
{/* existing code */}
</div>
{/* existing code */}
</>
);
}
이제 검색을 진행할 때 로딩스피너가 추가되었습니다.
여기서 navigation.location
은 앱이 새 URL로 이동하고 이에 대한 데이터를 로드할 때 표시됩니다. 새 URl로 이동할 때 loader가 데이터를 전부 로딩하기 전까지는 여전히 이전페이지를 보여줍니다. 그사이의 시간동안 로딩스피너를 보여주므로써 사용자경험을 향상시킬 수 있습니다.
submit을 하게 되면 제출 기록이 남습니다. 근데 지금 onChange함수
로 키보드의 input
이 있을 때마다 submit
을 해주기 때문에 모든 단어의 기록이 기록스택에 남게 됩니다.
히스토리 스택의 현재 항목을 미렁넣는 대신 다음페이지로 대체하면 이를 방지할 수 있습니다.
src/routes/root.jsx
// existing code
export default function Root() {
// existing code
return (
<>
<div id="sidebar">
<h1>React Router Contacts</h1>
<div>
<Form id="search-form" role="search">
<input
id="q"
// existing code
onChange={(event) => {
const isFirstSearch = q == null;
submit(event.currentTarget.form, {
replace: !isFirstSearch,
});
}}
/>
{/* existing code */}
</Form>
{/* existing code */}
</div>
{/* existing code */}
</div>
{/* existing code */}
</>
);
}
이렇게 하면 첫번째 검색결과가 아니라면 검색 결과를 고체하도록 설계하였습니다.
지금 까지는 탐색을 통해 데이터를 찾고 변경시켰지만 탐색을 하지 않고 데이터를 변경하는 방법에 대해 알아보겠습니다. useFetcher
를 사용하여 탐색을 하지 않고 로더 및 작업과 통신할 수 있습니다.
탐색을 하지 않는다는것은 url변경에 의한 이동이 없다는 뜻!
src/routes/contact.jsx
import {
useLoaderData,
Form,
useFetcher,
} from "react-router-dom";
// existing code
function Favorite({ contact }) {
const fetcher = useFetcher();
let favorite = contact.favorite;
return (
<fetcher.Form method="post">
<button
name="favorite"
value={favorite ? "false" : "true"}
aria-label={
favorite
? "Remove from favorites"
: "Add to favorites"
}
>
{favorite ? "★" : "☆"}
</button>
</fetcher.Form>
);
}
favorite에 따라 그려지는 버튼의 모양을 다르게 합니다. post 요청이기 때문에 action을 만들어 줍니다.
src/routes/contact.jsx
// existing code
import { getContact, updateContact } from "../contacts";
export async function action({ request, params }) {
let formData = await request.formData();
return updateContact(params.contactId, {
favorite: formData.get("favorite") === "true",
});
}
export default function Contact() {
// existing code
}
이제 양식을 가져와 요청을 보냅니다. post 요청이기 때문에 request.에서 formData()를 가져옵니다.
이제 favorite 표시를 사용할 수 있습니다.
html에서 Form을 제출할때 기본적으로 action경로로 페이지 이동
이 발생합니다. 이것을 navigation(탐색)
을 유발한다고 표현하는데 url이 변경
되는 걸 말합니다. action이 없더라도 같은 페이지로 navigation(탐색)
이 발생하기 때문에 navigation.state 가 loading이 되는 현상
이 발생합니다. 좋아요 같은 버튼기능은 이런 기능이 필요 없습니다.
가장 기본적으로 이러한 현상을 막는것이 e.preventDefault()
이고 react-router-dom
을 사용할 때는 위의 예시와 같이 useFetcher
를 사용합니다.
fetcher.Form
을 사용하면 페이지 이동이 발생하지 않습니다. 즉 url이 변경되지 않고 기록 스택이 영향을 받지 않습니다.
그래서 왜 사용하냐고요? 만약 페이지를 전환할 때 loader가 데이터를 불러오는데 시간이 걸린다면 개발자는 화면이 전환되는동안 끊기는 느낌을 주지 않기 위해 로딩스피너를 넣을 것입니다. 그 조건이 바로 navigation입니다.
const navigation = useNavigation()
navigation.state //loading
다음 훅을 사용하면 페이지 전환상태를 받아올 수 있는데 좋아요 버튼을 누를 때마다 페이지 이동이 발생 즉 navigation(탐색)이 발생하면 좋아요. 버튼을 클릭할 때마다 로딩스피너가 보일것입니다.
이것을 방지하기위함 그리고 페이지 이동을 막기 위해 fetcher.Form
를 사용합니다.
더욱 다양한 사용법은 https://reactrouter.com/en/main/hooks/use-fetcher 공식문서에서 확인할 수 있습니다.
본 예제에서는 즐겨찾기(Favorite)버튼을 누를 때 약간의 지연이 발생되도록 설계되었습니다. 이는 실제 서비스 환경에서 네트워크 문제가 있을 수 있기 때문입니다.
우리는 navigation.state
를 사용했지만 fetcher.state
를 사용해서 더 나은 피드백을 제공하도록 할 수 있습니다.
이때 사용하는 전략이 낙관적UI
입니다.
🪄 낙관적 UI란?
사용자 인터페이스 디자인 및 개발 패턴중 하나로, 사용자에게 빠른 피드백과 더 나은 사용자 경험을 제공하기 위해 사용됩니다. 낙관적 UI의 핵심 개념은 사용자의 동작에 대한 응답을 가능한 한 빨리 보여주는 것입니다.
src/routes/contact.jsx
// existing code
function Favorite({ contact }) {
const fetcher = useFetcher();
let favorite = contact.favorite;
if (fetcher.formData) {
favorite = fetcher.formData.get("favorite") === "true";
}
return (
<fetcher.Form method="post">
<button
name="favorite"
value={favorite ? "false" : "true"}
aria-label={
favorite
? "Remove from favorites"
: "Add to favorites"
}
>
{favorite ? "★" : "☆"}
</button>
</fetcher.Form>
);
}
이제 버튼을 클릭하면 즉각적으로 상태가 변경됩니다. 실제 제출하는데이터가 있다면 제출된 데이터를 대신 사용하고 제출하는 데이터가 없다면 실제 데이터를 사용하게 됩니다.
만일 업데이트가 실패했다고 하더라도 contact.favorite
을 사용하기 때문에 원래 데이터로 돌아갑니다.
<button/>
을 누르게 되면 fetcher의 상태는 다음과 같이 변경됩니다.
submitting → loading → idle
위의 코드에는 fetcher.formData
를 사용하여 요청한 favorite
데이터를 가져와 사용합니다. 이 데이터는 submitting
과 loading
상태에서 유지되다가 action
함수가 완료되면서 fetcher
의 상태가 idle
변경되면서 fetcher.formData
는 null
값으로 변경됩니다. 이때는 추가한 if문
이 동작하지 않기 때문에 실제로 변환된 데이터
를 가져와 사용하게 됩니다!
이로써 즉시 데이터를 변경할 수 있고, 실제로 네트워크 에러가 나더라도 idle상태에서는 정상적인 favorite 값을제공할 수 있습니다.
로드하려는 연락처가 존재하지 않는다면 어떻게 할까요?
src/routes/contact.jsx
export async function loader({ params }) {
const contact = await getContact(params.contactId);
if(!contact){
throw new Response("", { //구체적인 커스텀 에러를 생성해서 반환할 수 있다.
status:404,
statusText:`custom error page Not Found`
})
}
return { contact };
}
Cannot read properties of null
을 피하고 오류 경로를 렌더링 하여 사용자에게 구체적인 내용을 알려줄 수 있습니다.
이경우 뒤로가기나 새로고침 밖에 해결할 방법이 없습니다.
위 화면과 같은 형태의 에러페이지를 만들어 봅시다.
src/index.jsx
createBrowserRouter([
{
path: "/",
element: <Root />,
loader: rootLoader,
action: rootAction,
errorElement: <ErrorPage />,
children: [
{
errorElement: <ErrorPage />,
children: [
{ index: true, element: <Index /> },
{
path: "contacts/:contactId",
element: <Contact />,
loader: contactLoader,
action: contactAction,
},
/* the rest of the routes */
],
},
],
},
]);
하위 경로에서 오류가 발생하면 경로가 없고 errorElement만 있는 경로에서 오류를 포작하고 렌더링하여 루트경로 의 UI 즉 sidebar는 그대로 유지됩니다.!
마지막으로 사람들은 jsx를 사용해 경로를 구성하는것을 선호합니다. createRoutesFromElements
로 경로를 구성할 때 JSX 나 객체 사이에는 기능적인 차이가 없으며 단순히 스타일에 따른 선호일 뿐입니다.
import {
createRoutesFromElements,
createBrowserRouter,
Route,
} from "react-router-dom";
const router = createBrowserRouter(
createRoutesFromElements(
<Route
path="/"
element={<Root />}
loader={rootLoader}
action={rootAction}
errorElement={<ErrorPage />}
>
<Route errorElement={<ErrorPage />}>
<Route index element={<Index />} />
<Route
path="contacts/:contactId"
element={<Contact />}
loader={contactLoader}
action={contactAction}
/>
<Route
path="contacts/:contactId/edit"
element={<EditContact />}
loader={contactLoader}
action={editAction}
/>
<Route
path="contacts/:contactId/destroy"
action={destroyAction}
/>
</Route>
</Route>
)
);
지금 까지 공식문서에 있는 튜토리얼을 따라 작업을 계속했습니다.
Router를 사용한 적은 많았는데 이렇게 세부적인 기능까지 사용한 적은 없었던것 같습니다. 이해하는데 시간이 걸렸지만 그만큼 유용한 시간였습니다. 물론 React Router로 할 수 있는 일은 이것보다 훨씬 더 많기 때문에 공식 문서에서 API를 확인해보세요!
참고문서
https://reactrouter.com/en/main/start/tutorial#the-root-route