티스토리 뷰
⇩ 완성본 미리보기
지난 스터디에서 진행했던 튜토리얼을 다시 파헤쳐 보자!
1. Setup
👉 기본 템플릿 생성하기
npx create-remix@latest --template remix-run/remix/templates/remix-tutorial
아주 기본적인 템플릿을 사용하지만 CSS와 데이터 모델을 포함하므로 Remix에 집중할 수 있다.
명령어를 입력하면 프로젝트 이름과 git, npm에 대한 설정을 묻는다.
실제 프로젝트에서는 pnpm을 사용하지만 여기서는 npm을 사용했다.
설치가 완료되면 폴더가 생성된다!
여기서 app 폴더 안에 있는 root.tsx가 "루트 경로"가 된다.
렌더링되는 UI의 첫 번째 구성요소로 일반적으로 페이지의 전역 레이아웃이 포함된다.
2. links에 스타일시트 추가하기
심심한 디자인의 페이지에 스타일을 적용하자!
Remix 앱의 스타일을 지정하는 방법은 여러 가지가 있지만 튜토리얼에서는 이미 작성된 일반 스타일시트를 사용한다.
CSS 파일을 JavaScript 모듈로 직접 가져올 수 있다.
Vite는 asset를 복제하여 빌드의 클라이언트 디렉토리에 저장하고 공개적으로 액세스 가능한 href를 모듈에 제공한다.
import type { LinksFunction } from "@remix-run/node";
import appStylesHref from "./app.css?url";
export const links: LinksFunction = () => [
{ rel: "stylesheet", href: appStylesHref },
];
Remix는 경로 기반 스타일시트를 사용해 중첩된 경로에도 각 페이지에 자체 스타일시트를 추가할 수 있고 해당 스타일시트를 자동으로 프리페치, 로드 및 언로드한다.
모든 경로는 link 함수를 내보낼 수 있다. link 함수는 app/root.tsx에서 렌더링한 <Links /> 컴포넌트로 수집될 것이다.
3. Contact Route UI
사이드바 아이템을 누르면 404 페이지가 표시된다. /contacts/1 URL과 일치하는 경로를 만들자!
👉 app/routes 디렉토리와 contact 경로 모듈 만들기
mkdir app/routes
touch app/routes/contacts.\$contactId.tsx
((난 위의 방법보다는 폴더를 만들고 contactd.$contactId.tsx 파일을 생성하는게 더 편하다...))
👉 contact 컴포넌트 추가하기
import { Form } from "@remix-run/react";
import type { FunctionComponent } from "react";
import type { ContactRecord } from "../data";
export default function Contact() {
const contact = {
first: "Your",
last: "Name",
avatar: "https://i.pinimg.com/564x/b5/66/57/b56657c0af0005a9ce471c2f411e038b.jpg",
twitter: "your_handle",
notes: "Some notes",
favorite: true,
};
return (
<div id="contact">
<div>
<img
alt={`${contact.first} ${contact.last} avatar`}
key={contact.avatar}
src={contact.avatar}
/>
</div>
<div>
<h1>
{contact.first || contact.last ? (
<>
{contact.first} {contact.last}
</>
) : (
<i>No Name</i>
)}{" "}
<Favorite contact={contact} />
</h1>
{contact.twitter ? (
<p>
<a
href={`https://twitter.com/${contact.twitter}`}
>
{contact.twitter}
</a>
</p>
) : null}
{contact.notes ? <p>{contact.notes}</p> : null}
<div>
<Form action="edit">
<button type="submit">Edit</button>
</Form>
<Form
action="destroy"
method="post"
onSubmit={(event) => {
const response = confirm(
"Please confirm you want to delete this record."
);
if (!response) {
event.preventDefault();
}
}}
>
<button type="submit">Delete</button>
</Form>
</div>
</div>
</div>
);
}
const Favorite: FunctionComponent<{
contact: Pick<ContactRecord, "favorite">;
}> = ({ contact }) => {
const favorite = contact.favorite;
return (
<Form method="post">
<button
aria-label={
favorite
? "Remove from favorites"
: "Add to favorites"
}
name="favorite"
value={favorite ? "false" : "true"}
>
{favorite ? "★" : "☆"}
</button>
</Form>
);
};
코드를 추가한 뒤에 사이드바 아이템을 누르면 404페이지가 뜨지는 않지만 아무것도 뜨지 않는다..!!!
4. 중첩된 경로와 Outlets
Remix는 React Router 위에 구축되었으므로 중첩 라우팅을 지원한다.
하위 경로가 상위 레이아웃 내부에 렌더링되도록 하려면 상위 레이아웃에 Outlet을 렌더링해야 한다.
⇨ app/root.tsx 내부에서 Outlet을 렌더링한다!
import {
...
Outlet,
} from "@remix-run/react";
export default function App() {
return (
<html lang="en">
{/* other elements */}
<body>
<div id="sidebar">{/* other elements */}</div>
<div id="detail">
<Outlet />
</div>
{/* other elements */}
</body>
</html>
);
}
Outlet 컴포넌트를 추가하면 페이지가 정상적으로 렌더링된다.
5. Client Side Routing
사이드바의 링크를 클릭하면 브라우저는 클라이언트 측 라우팅 대신 다음 URL에 대한 전체 document 요청을 수행한다.
클라이언트 측 라우팅을 사용하면 앱이 서버에서 다른 document를 요청하지 않고도 URL을 업데이트할 수 있다.
대신 앱은 새 UI를 즉시 렌더링할 수 있다.
<Link>를 사용해 실현할 수 있다!!
👉 사이드바 <a href>를 <Link to>로 바꾸기
import {
...,
Link,
} from "@remix-run/react";
export default function App() {
return (
<html lang="en">
{/* other elements */}
<body>
<div id="sidebar">
{/* other elements */}
<nav>
<ul>
<li>
<Link to={`/contacts/1`}>Your Name</Link>
</li>
<li>
<Link to={`/contacts/2`}>Your Friend</Link>
</li>
</ul>
</nav>
</div>
{/* other elements */}
</body>
</html>
);
}
브라우저 개발자 도구에서 네트워크 탭을 열어 더 이상 document를 요청하지 않는지 확인할 수 있다.
(초기 렌더링 시간은 오래 걸리지만)한 번 받아온 document에 대해서는 요청을 보내지 않고도 UI를 바로 렌더링한다.
6. 데이터 로딩
URL 세그먼트, 레이아웃 및 데이터는 함께 결합되지 않는 경우가 더 많다.
URL 세그먼트 | 컴포넌트 | 데이터 |
/ | <Root> | contacts 목록 |
contacts/:contactId | <Contact> | 개별 contact |
이러한 자연스러운 결합으로 인해 Remix에는 경로 컴포넌트에 데이터를 쉽게 가져올 수 있는 데이터 규칙이 있다.
데이터를 로드하는 데 사용할 두 가지 API, loader 및 useLoaderData가 있다.
먼저 루트 경로에서 loader 함수 내보내기를 만든 다음 데이터를 렌더링한다.
👉 app/root.tsx에서 loader 함수를 내보내고 데이터 렌더링하기
import { json } from "@remix-run/node";
import {
...,
useLoaderData,
} from "@remix-run/react";
// getContacts 함수 가져오기
import { getContacts } from "./data";
// loader로 데이터 제공하기
// getContacts 함수를 호출해 json화 하기
export const loader = async () => {
const contacts = await getContacts();
return json({ contacts });
};
export default function App() {
// useLoaderData를 호출해 데이터 가져오기
const { contacts } = useLoaderData();
return (
<html lang="en">
{/* other elements */}
<body>
<div id="sidebar">
{/* other elements */}
<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>
) : null}
</Link>
</li>
))}
</ul>
) : (
<p>
<i>No contacts</i>
</p>
)}
</nav>
</div>
{/* other elements */}
</body>
</html>
);
}
데이터를 불러와 사이드바의 아이템 UI와 자동으로 동기화되었다!
7. Type 추론
map 내부의 contact의 타입에 대한 타입 경고가 뜬다.
typeof loader를 추가해 데이터에 대한 타입 추론을 얻을 수 있다.
export default function App() {
const { contacts } = useLoaderData<typeof loader>();
...
}
8. Loader에서의 URL Params
app/routes/contacts.$contactId.tsx에서 $contactId는 동적 세그먼트로 URL의 해당 위치에 있는 동적 값과 일치한다.
이를 "URL 매개변수"또는 "매개변수"라고 부른다.
params는 동적 세그먼트와 일치하는 키와 함께 loader에 전달된다.
예를 들어, 세그먼트 이름이 $contactId이므로 값은 params.contactId로 전달된다.
이러한 params는 ID로 레코드를 찾는 데 가장 자주 사용된다.
👉 contact 페이지에 loader 함수를 추가하고 useLoaderData를 통해 데이터에 액세스하기
import { json } from "@remix-run/node";
import { Form, useLoaderData } from "@remix-run/react";
import { getContact } from "../data";
export const loader = async ({ params }) => {
const contact = await getContact(params.contactId);
return json({ contact });
};
export default function Contact() {
// 기존 contact는 주석 처리
// const contact = {
// first: "Your",
// last: "Name",
// avatar:
// "https://i.pinimg.com/564x/b5/66/57/b56657c0af0005a9ce471c2f411e038b.jpg",
// twitter: "your_handle",
// notes: "Some notes",
// favorite: true,
// };
// loader에서 데이터 가져오기
const { contact } = useLoaderData<typeof loader>();
...
}
기존 contact 대신 URL의 매개변수와 일치하는 contactId를 가진 데이터를 가져와 표시한다.
9. Params 검증하기와 응답 던지기
타입 경고 투성이가 되었다!
1 ) 매개변수 이름
첫 번째 문제는 파일 이름과 코드 사이에 매개변수 이름이 잘못되었을 수 있다는 것이다.
Invariant는 코드에 잠재적인 문제가 있을 것으로 예상되는 경우 사용자 지정 메세지와 함께 오류를 발생시킨다.
import type { LoaderFunctionArgs } from "@remix-run/node";
import invariant from "tiny-invariant";
export const loader = async ({
params,
}: LoaderFunctionArgs) => {
invariant(params.contactId, "Missing contactId param");
const contact = await getContact(params.contactId);
return json({ contact });
};
2) null 처리
useLoaderData<typeof loader>()는 contact 또는 null(해당 ID에 대한 contact이 없다면)을 가진다는 걸 안다.
이러한 잠재적인 null은 컴포넌트 코드에 거슬리고 TS 오류는 계속해서 발생한다.
컴포넌트에서 contact를 찾을 수 없는 가능성을 고려할 수 있지만, 웹에서 해야 할 일은 적절한 404를 보내는 것이다.
loader에서 이를 수행하고 모든 문제를 한 번에 해결할 수 있다.
export const loader = async ({ params }: LoaderFunctionArgs) => {
invariant(params.contactId, "Missing contactId params");
const contact = await getContact(params.contactId);
// 존재하지 않는 contact 처리하기
if (!contact) {
throw new Response("Not Found", { status: 404 });
}
return json({ contact });
};
10. Data Mutations
contact를 생성해보자!
Remix는 HTML Form 탐색을 원시 데이터 변형으로 에뮬레이트한다.
Remix의 Forms는 기존 웹 모델의 단순성과 함께 클라이언트 렌더링 앱의 UX 기능을 제공한다.
HTML form은 실제로 링크를 클릭하는 것 처럼 브라우저에서 탐색을 유발한다.
여기서 유일한 차이점은 요청에 있다. 링크는 URL만 변경할 수 있는 반면, form는 요청 방법(GET vs POST)과 요청 본문(POST 폼 데이터)도 변경할 수 있다.
클라이언트 측 라우팅이 없으면 브라우저는 form의 데이터를 자동으로 직렬화하여 POST의 경우 요청 본문으로, GET의 경우 URLSearchParams로 서버에 보낸다.
Remix는 서버에 요청을 보내는 대신 클라이언트 측 라우팅을 사용하여 이를 라우트의 action 함수로 보내는 것을 제외하고 동일한 작업을 수행한다.
앱의 New 버튼을 클릭하면 이를 테스트할 수 있다.
export default function App() {
...
return (
...
<Form method="post">
<button type="submit">New</button>
</Form>
...
);
}
하지만 아직 서버에 이 탐색을 처리할 코드가 없기 때문에 405 페이지가 뜬다.
11. Contacts 만들기
root route에서 action 함수를 내보내 새 contact를 만든다.
유저가 "New" 버튼을 클릭하면 폼은 root route action에 POST를 할 것이다.
👉 app/root.tsx에서 action 함수 내보내기
import { createEmptyContact, getContacts } from "./data";
export const action = async () => {
const contact = await createEmptyContact();
return json({ contact });
};
New 버튼을 클릭하면 "No Name"이라는 새 레코드가 나타난다!
createEmptyContact 메소드는 이름이나 데이터 등이 없는 빈 contact를 생성한다. 하지만 레코드는 생성한다는 거!!
이것이 "구식 웹"프로그래밍 모델이 나타나는 곳이다.
<Form>은 브라우저가 서버에 요청을 보내는 것을 방지하고 fetch대신 라우트 action 함수로 보낸다.
웹 의미론에서 POST는 일반적으로 일부 데이터가 변경되고 있음을 의미한다. 관례에 따라, Remix는 이를 힌트로 사용하여 작업이 완료된 후 페이지의 데이터를 자동으로 재검증한다.
사실, 모두 HTML과 HTTP이기 때문에 JavaScript를 비활성화해도 모든 것이 계속 작동한다.
Remix가 폼을 직렬화하고 서버에 fetch 요청을 하는 대신, 브라우저는 폼을 직렬화하고 document를 요청한다.
거기에서 Remix는 페이지 서버 측을 렌더링하여 아래로 보낸다. 어느 쪽이든 결국 동일한 UI이다.
12. Data 업데이트
새 레코드에 대한 정보를 입력하는 방법을 추가해보자
데이터를 생성하는 것과 마찬가지로 <Form>을 사용해 데이터를 업데이트한다.
app/routes/contacts.$contactId_.edit.tsx을 만들어 새 경로를 만들자!
👉 edit 컴포넌트 생성하기
touch app/routes/contacts.\$contactId_.edit.tsx
경로에 후행 _를 추가하면 app/routes/contacts.$contactId.tsx 내에 중첩되지 않도록 지시한다.
2024.04.11 - [코딩/코딩노트] - [Remix] Remix 공식 문서 파헤치기 1탄 - Remix가 뭔데?!(route 집중 파보기)의 레이아웃 중첩 없이 중첩된 URL 참고
👉 edit 페이지 UI 추가하기
import type { LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { Form, useLoaderData } from "@remix-run/react";
import invariant from "tiny-invariant";
import { getContact } from "../data";
export const loader = async ({
params,
}: LoaderFunctionArgs) => {
invariant(params.contactId, "Missing contactId param");
const contact = await getContact(params.contactId);
if (!contact) {
throw new Response("Not Found", { status: 404 });
}
return json({ contact });
};
export default function EditContact() {
const { contact } = useLoaderData<typeof loader>();
return (
<Form key={contact.id} id="contact-form" method="post">
<p>
<span>Name</span>
<input
defaultValue={contact.first}
aria-label="First name"
name="first"
type="text"
placeholder="First"
/>
<input
aria-label="Last name"
defaultValue={contact.last}
name="last"
placeholder="Last"
type="text"
/>
</p>
<label>
<span>Twitter</span>
<input
defaultValue={contact.twitter}
name="twitter"
placeholder="@jack"
type="text"
/>
</label>
<label>
<span>Avatar URL</span>
<input
aria-label="Avatar URL"
defaultValue={contact.avatar}
name="avatar"
placeholder="https://example.com/avatar.jpg"
type="text"
/>
</label>
<label>
<span>Notes</span>
<textarea
defaultValue={contact.notes}
name="notes"
rows={6}
/>
</label>
<p>
<button type="submit">Save</button>
<button type="button">Cancel</button>
</p>
</Form>
);
}
edit 버튼을 클릭하면 edit 페이지가 정상적으로 나타난다.
13. FormData로 Contacts 업데이트하기
edit 경로는 이미 form을 렌더링한다. 해야 할 일은 action 함수를 추가하는 것!
Remix는 폼을 직렬화하고 fetch를 통해 POST하고, 자동으로 모든 데이터의 유효성을 다시 검사한다.
👉 edit 경로에 action 함수 추가하기
import type {
ActionFunctionArgs,
LoaderFunctionArgs,
} from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { getContact, updateContact } from "../data";
export const action = async ({
params,
request,
}: ActionFunctionArgs) => {
invariant(params.contactId, "Missing contactId param");
const formData = await request.formData();
const updates = Object.fromEntries(formData);
await updateContact(params.contactId, updates);
// contacts 페이지로 이동
return redirect(`/contacts/${params.contactId}`);
};
폼을 작성하고 저장을 누르면 업데이트가 반영된다!
14. Mutation Discussion
contacts.$contactId_.edit.tsx에서 폼 엘리먼트를 살펴보자!
각각의 이름이 어떻게 지정되어 있는지 확인한다.
<input
defaultValue={contact.first}
aria-label="First name"
name="first"
type="text"
placeholder="First"
/>
JavaScript가 없으면 폼이 제출되면 브라우저는 FormData를 생성하고 이를 서버에 보낼 때 요청 본문으로 설정한다.
Remix는 FormData를 포함하여 fetch를 사용하여 action 함수에 요청을 보내 이를 방지하고 브라우저를 에뮬레이션한다.
폼의 각 필드는 formData.get(name)을 사용하여 액세스할 수 있다. 예를 들어, 위의 input 필드가 있으면 first와 last names에 액세스할 수 있다.
export const action = async ({
params,
request,
}: ActionFunctionArgs) => {
const formData = await request.formData();
const firstName = formData.get("first");
const lastName = formData.get("last");
// ...
};
소수의 폼 필드가 있으므로 Object.formEntries를 사용해 모든 필드를 객체로 수집했다.
이것이 바로 updateContact 함수가 원하는 것이다.
const updates = Object.fromEntries(formData);
updates.first; // "Some"
updates.last; // "Name"
action 함수를 제외하고 request, request.formData, Object.formEntries API는 모두 웹 플랫폼에서 제공되지만 Remix에서는 제공되지 않는다.
action을 마친 후 마지막에 리다이렉션을 확인하세요!
export const action = async ({
params,
request,
}: ActionFunctionArgs) => {
invariant(params.contactId, "Missing contactId param");
...
return redirect(`/contacts/${params.contactId}`);
};
action과 loader 함수는 둘 다 응답을 반환할 수 있다. 리다이렉션 헬퍼를 사용하면 앱에 위치를 변경하라고 응답을 더 쉽게 반환할 수 있다.
클라이언트 측 라우팅이 없으면 POST 요청 후 서버가 리다이렉션되면 새 페이지가 최신 데이터를 가져와 렌더링한다.
Remix는 이 모델을 에뮬레이트하고 action 호출 후 페이지의 데이터를 자동으로 다시 검증한다. 이것이 폼을 저장할 때 사이드바가 자동으로 업데이트되는 이유이다.
추가 재검증 코드는 클라이언트 측 라우팅 없이는 존재하지 않으므로 Remix의 클라이언트 측 라우팅에도 존재할 필요가 없다.
마지막으로, JavaScript가 없으면 리다이렉션은 일반적인 리다이렉션이 된다.
그러나 JavaScript를 사용하면 클라이언트 측 사용자는 스크롤 위치나 컴포넌트와 같은 클라이언트 상태를 잃지 않는다.
15. 생성하기를 edit 페이지로 리다이렉션하기
New 버튼을 누르면 edit 페이지로 리다이렉션하는 action을 업데이트하자!
👉 새 레코드의 edit 페이지로 리다이렉션하기
import { json, redirect } from "@remix-run/node";
export const action = async () => {
const contact = await createEmptyContact();
return redirect(`/contacts/${contact.id}/edit`);
};
New 버튼을 누르면 edit 페이지가 표시된다.
16. Active Link 스타일링
사이드바에서 어떤 레코드를 보고 있는 지 명확하게 표시하자!
NavLink를 사용해 이를 해결할 수 있다.
👉 사이드바에서 <Link>를 <NavLink>로 교체하기
import {
...,
NavLink,
} from "@remix-run/react";
export default function App() {
const { contacts } = useLoaderData<typeof loader>();
return (
<html lang="en">
{/* existing elements */}
<body>
<div id="sidebar">
{/* existing elements */}
<ul>
{contacts.map((contact) => (
<li key={contact.id}>
<NavLink
className={({ isActive, isPending }) =>
isActive
? "active"
: isPending
? "pending"
: ""
}
to={`contacts/${contact.id}`}
>
{/* existing elements */}
</NavLink>
</li>
))}
</ul>
{/* existing elements */}
</div>
{/* existing elements */}
</body>
</html>
);
}
함수에 className을 전달해 유저가 <NavLink to>와 일치하는 URL에 있으면 isActive는 ture가 된다.
active가 되려고 하면(데이터가 여전히 로딩 중) isPending이 true가 된다.
이를 통해 유저의 위치를 쉽게 표시할 수 있으며 링크를 클릭했지만 데이터를 로드해야 할 때 즉각적인 피드백을 제공할 수도 있다.
17. 글로벌 Pending UI
유저가 앱을 탐색할 때 Remix는 다음 페이지에 대한 데이터가 로드되는 동안 이전 페이지를 그대로 둔다.
때문에 리스트 사이를 클릭할 때 앱이 약간 응답하지 않는 느낌을 받을 수 있다.
앱이 응답하지 않는 느낌이 들지 않도록 사용자에게 몇 가지 피드백을 제공하자!
Remix는 배후의 모든 상태를 관리하고 동적 웹 앱을 구축하는 데 필요한 부분을 보여준다. 이 경우 useNavigation 훅을 사용한다.
useNavigation -> 보류 중인 탐색 페이지에 대한 정보를 제공
(( 2024.04.24 - [코딩/코딩노트] - [Remix]Remix 공식문서 파헤치기 5탄 - Hooks의 useNavigation 참고))
👉 글로벌 pending UI를 위해 useNavigation 사용하기
import {
...,
useNavigation,
} from "@remix-run/react";
export default function App() {
const navigation = useNavigation();
...
return (
<html lang="en">
{/* existing elements */}
<body>
{/* existing elements */}
<div
className={
navigation.state === "loading" ? "loading" : ""
}
id="detail"
>
<Outlet />
</div>
{/* existing elements */}
</body>
</html>
);
}
대기 상태가 아닌 경우 앱의 메인 부분에 "loading" 클래스를 추가한다. 그런 다음 CSS는 짧은 지연 후에 멋진 페이드를 추가한다(빠른 로드를 위해 UI가 깜빡이는 것을 방지하기 위함). 상단에 스피너나 로딩 바를 표시하는 등 원하는 것은 무엇이든 할 수 있다!
18. 레코드 삭제하기
contact 경로의 코드를 보면 삭제 버튼이 다음과 같다.
<Form
action="destroy"
method="post"
onSubmit={(event) => {
const response = confirm(
"Please confirm you want to delete this record."
);
if (!response) {
event.preventDefault();
}
}}
>
<button type="submit">Delete</button>
</Form>
action이 "destory"인 점을 보자.
<Link to>와 마찬가지로 <Form action>도 상대값을 가질 수 있다. 폼이 contacts.$contactId.tsx에서 렌더링되므로, destroy를 사용한 상대적인 action을 클릭하면 폼이 contacts.$contactId.destroy에 제출된다.
삭제 버튼을 작동시키기 위해서 필요한 것들이 있다.
- 새로운 경로
- 그 경로에 있는 action
- app/data.ts로부터 받은 deleteContact
- 이후 어딘가로 redirect하기
👉 "destroy" 라우트 모듈 생성하기
touch app/routes/contacts.\$contactId.destroy.tsx
👉 destroy action 추가하기
import type { ActionFunctionArgs } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import invariant from "tiny-invariant";
import { deleteContact } from "../data";
export const action = async ({
params,
}: ActionFunctionArgs) => {
invariant(params.contactId, "Missing contactId param");
await deleteContact(params.contactId);
return redirect("/");
};
Delete 버튼을 누르면 레코드가 삭제된다!
유저가 버튼을 클릭하면
- <Form>은 새 document POST 요청을 서버에 보내는 기본 브라우저 동작을 방지하는 대신 클라이언트 측 라우팅 및 fetch를 사용해 POST 요청을 생성해 브라우저를 에뮬레이션함(구현함)
- <Form action="destroy"> 는 contacts$contactId.destroy 새 경로와 일치하고 요청을 보냄
- action이 리다이렉션된 후, Remix는 페이지의 데이터에 대한 모든 로더를 호출해 최신 값을 가져옴(➪ 재검증). useLoader는 새 값을 반환하고 컴포넌트를 업데이트함
즉, Form을 추가하고 action을 추가하면 Remix가 나머지를 해 준다!
19. Index Routes
앱을 로드하면 리스트 오른쪽에 큰 빈 페이지가 표시된다.
하나의 경로가 자식을 가지고 부모 라우트 경로에 있는 경우, <Outlet>는 일치하는 자식이 없기 때문에 렌더링할 것이 없다.
이때 인덱스 경로를 해당 공간을 채우는 기본 하위 경로로 생각할 수 있다.
👉 root route에 대한 index route 생성하기
touch app/routes/_index.tsx
👉 index 컴포넌트의 엘리먼트 채우기
export default function Index() {
return (
<p id="index-page">
This is a demo for Remix.
<br />
Check out{" "}
<a href="https://remix.run">the docs at remix.run</a>.
</p>
);
}
경로 이름인 _index는 유저가 상위 경로의 정확한 경로에 있을 때 이 경로를 일치시키고 렌더링하도록 Remix에 지시한다.
때문에 <Outlet />에 렌더링할 다른 하위 경로가 없다.
대시보드, 통계, 피드 등을 index route에 배치하는 것이 일반적이다. 또, 데이터 로딩에도 참여할 수 있다.
20. 취소 버튼
edit 페이지에 있는 Cancel 버튼이 브라우저의 뒤로 버튼과 같은 동작을 하도록 만들어보자!
버튼에 대한 클릭 핸들러와 useNavigate가 필요하다.
👉 useNavigate와 함께 취소 버튼 클릭 핸들러를 추가하기
import {
...,
useNavigate,
} from "@remix-run/react";
export default function EditContact() {
const navigate = useNavigate();
return (
<Form key={contact.id} id="contact-form" method="post">
{/* existing elements */}
<p>
<button type="submit">Save</button>
<button onClick={() => navigate(-1)} type="button">
Cancel
</button>
</p>
</Form>
);
}
💬 왜 버튼에 event.preventDefault( )를 추가하지 않을까?
중복되어 보이지만, <button type="button">은 버튼이 폼을 제출하는 것을 방지하는 HTML 방식이다.
21. URLSearchParams와 GET 제출
검색 필드는 폼이지만 URL만 변경하고 데이터는 변경하지 않는다.
지금까지 모든 interactive UI는 URL을 변경하는 링크이거나 action 함수에 데이터를 게시하는 폼이었지만 검색 필드는 두 가지가 혼합되어있다.
👉 검색창에 이름을 입력하고 엔터키 누르기
브라우저의 URL에는 URLSearchParams로 URL에 쿼리가 포함되어 있다.
<Form method="post">가 아니기 때문에 Remix는 FormData를 요청 본문 대신 URLSearchParams로 직렬화하여 브라우저를 구현한다.
loader 함수는 요청의 검색 파라미터에 액세스할 수 있다.
이를 사용해 목록을 필터링하자!
👉 URLSearchParams가 있다면 리스트 필터링하기
import type {
LinksFunction,
...,
LoaderFunctionArgs,
} from "@remix-run/node";
export const loader = async ({
request,
}: LoaderFunctionArgs) => {
const url = new URL(request.url);
const q = url.searchParams.get("q");
const contacts = await getContacts(q);
return json({ contacts });
};
POST가 아닌 GET이기 때문에 Remix는 action 함수를 호출하지 않는다.
GET 폼을 제출하는 것은 링크를 클릭하는 것과 같이 URL만 변경된다. 이는 일반적인 페이지 탐색임을 의미하고 뒤로 버튼을 클릭하면 이전 위치로 돌아갈 수 있다.
22. Form State에 URL 동기화하기
신속하게 처리할 수 있는 몇 가지 UX 문제가 있다.
- 검색 후 다시 클릭하면 리스트가 더 이상 필터링되지 않더라도 폼 필드에는 입력한 값이 그대로 유지된다.
- 검색 후 페이지를 새로 고침하면 리스트가 필터링되더라도 폼 필드에 더 이상 값이 없다.
즉, URL과 입력 상태가 동기화되지 않았다.
두 번째 문제를 먼저 해결하고 URL의 값으로 입력을 시작한다.
👉 loader에서 q를 반환하고 이를 input의 기본값으로 설정하기
export const loader = async ({
request,
}: LoaderFunctionArgs) => {
const url = new URL(request.url);
const q = url.searchParams.get("q");
const contacts = await getContacts(q);
return json({ contacts, q });
};
export default function App() {
const { contacts, q } = useLoaderData<typeof loader>();
const navigation = useNavigation();
return (
<html lang="en">
{/* existing elements */}
<body>
<div id="sidebar">
{/* existing elements */}
<div>
<Form id="search-form" role="search">
<input
aria-label="Search contacts"
defaultValue={q || ""}
id="q"
name="q"
placeholder="Search"
type="search"
/>
{/* existing elements */}
</Form>
{/* existing elements */}
</div>
{/* existing elements */}
</div>
{/* existing elements */}
</body>
</html>
);
}
첫 번째 문제의 경우 뒤로 버튼을 클릭하고 입력을 업데이트한다.
React에서 useEffect를 가져와 DOM의 입력값을 직접 조작한다.
👉 URLSearchParams로 input 값 동기화하기
import { useEffect } from "react";
export default function App() {
const { contacts, q } = useLoaderData<typeof loader>();
const navigation = useNavigation();
useEffect(() => {
const searchField = document.getElementById("q");
if (searchField instanceof HTMLInputElement) {
searchField.value = q || "";
}
}, [q]);
// existing code
}
뒤로/앞으로/새로 고침 버튼을 클릭해도 input 값이 URL 및 결과와 동기화 되었다!
23. Form의 onChange 제출
때로는 유저가 일부 결과를 필터링하기 위해 폼을 제출하기를 원할 수 있고, 다른 경우에는 유저 유형에 따라 필터링하기를 원할 수도 있다.
두 번째 경우를 useSubmit을 사용해 구현해보자.
👉 useSubmit을 사용해 입력 중인 값을 제출하기
import {
...,
useSubmit,
} from "@remix-run/react";
export default function App() {
...
const submit = useSubmit();
return (
<html lang="en">
{/* existing elements */}
<body>
<div id="sidebar">
{/* existing elements */}
<div>
<Form
id="search-form"
onChange={(event) =>
submit(event.currentTarget)
}
role="search"
>
{/* existing elements */}
</Form>
{/* existing elements */}
</div>
{/* existing elements */}
</div>
{/* existing elements */}
</body>
</html>
);
}
입력하면 form이 자동으로 제출된다.
useSubmit 함수는 전달된 모든 폼을 직렬화하고 제출한다.
event.currentTarget을 전달하는데 currentTarget은 이벤트가 연결된 DOM 노드이다.
24. 검색 스피너 추가하기
프로덕션 앱에서 이 검색은 한 번에 모두 보내고 클라이언트 측을 필터링하기에는 너무 큰 데이터베이스의 레코드를 찾을 가능성이 높다.
이것이 바로 데모에 가짜 네트워크 대기 시간이 있는 이유이다.
로딩 표시가 없으면 검색이 다소 느린 느낌이 든다. 데이터베이스를 더 빠르게 만들 수 있더라도 항상 유저의 네트워크 대기 시간이 방해가 되고 통제할 수 없게 된다.
더 나은 사용자 경험을 위해 useNavigation을 사용해 검색에 대한 UI 피드백을 추가하자.
👉 검색 중인지 알 수 있도록 변수 추가하기
export default function App() {
const { contacts, q } = useLoaderData<typeof loader>();
const navigation = useNavigation();
const submit = useSubmit();
const searching =
navigation.location &&
new URLSearchParams(navigation.location.search).has(
"q"
);
...
}
아무 일도 일어나지 않으면 navigation.location이 undefined가 되지만, 유저가 탐색하면 데이터가 로드되는 동안 다음 위치로 채워진다.
그런 다음 데이터가 location.search로 검색하고 있는지 확인한다.
👉 새로운 searching state를 사용해 폼 엘리먼트를 검색하는 클래스 추가하기
export default function App() {
...
return (
<html lang="en">
{/* existing elements */}
<body>
<div id="sidebar">
{/* existing elements */}
<div>
<Form
id="search-form"
onChange={(event) =>
submit(event.currentTarget)
}
role="search"
>
<input
aria-label="Search contacts"
className={searching ? "loading" : ""}
defaultValue={q || ""}
id="q"
name="q"
placeholder="Search"
type="search"
/>
<div
aria-hidden
hidden={!searching}
id="search-spinner"
/>
</Form>
{/* existing elements */}
</div>
{/* existing elements */}
</div>
{/* existing elements */}
</body>
</html>
);
}
⇩ 추가로 검색할 때 메인 화면이 사라지지 않게 하기
export default function App() {
...
return (
<html lang="en">
{/* existing elements */}
<body>
{/* existing elements */}
<div
className={
navigation.state === "loading" && !searching
? "loading"
: ""
}
id="detail"
>
<Outlet />
</div>
{/* existing elements */}
</body>
</html>
);
}
25. 기록 스택 관리하기
키를 누를 때마다 폼이 제출되므로 문자를 입력한 다음 백스페이스로 삭제하면 엄청난 기록 스택이 생성된다...!
기록 스택에 현재 항목을 푸시하는 대신 다음 페이지로 대체하면 이를 방지할 수 있다.
👉 submit에서 replace 사용하기
export default function App() {
...
return (
<html lang="en">
{/* existing elements */}
<body>
<div id="sidebar">
{/* existing elements */}
<div>
<Form
id="search-form"
onChange={(event) => {
const isFirstSearch = q === null;
submit(event.currentTarget, {
replace: !isFirstSearch,
});
}}
role="search"
>
{/* existing elements */}
</Form>
{/* existing elements */}
</div>
{/* existing elements */}
</div>
{/* existing elements */}
</body>
</html>
);
}
첫 번째 검색인지 아닌지를 확인한 후 교체를 결정한다.
첫 번째 검색에서는 새 항목이 추가되지만 그 이후의 모든 키 입력은 현재 항목을 대체한다. 검색어를 제거하기 위해 뒤로 7번 클릭하는 대신 사용자는 한 번만 뒤로 클릭하면 된다.
26. 탐색 없는 Forms
지금까지 모든 폼의 URL이 변경되었다. 이러한 사용자 흐름은 일반적이지만 탐색을 유발하지 않고 폼을 제출하려는 경우도 마찬가지로 일반적이다.
이러한 경우에는 useFetcher를 사용한다.
useFetcher를 사용하면 탐색을 유발하지 않고도 action 및 loader와 통신할 수 있다.
contact 페이지의 ★ 버튼이 이에 적합하다. 새 레코드를 생성 또는 삭제하거나 페이지를 변경하지 않고 보고 있는 페이지의 데이터를 변경한다.
👉 fetcher form에서 <Favorite> 변경하기
import {
...,
useLoaderData,
} from "@remix-run/react";
const Favorite: FunctionComponent<{
contact: Pick<ContactRecord, "favorite">;
}> = ({ contact }) => {
const fetcher = useFetcher();
const favorite = contact.favorite;
return (
<fetcher.Form method="post">
<button
aria-label={
favorite
? "Remove from favorites"
: "Add to favorites"
}
name="favorite"
value={favorite ? "false" : "true"}
>
{favorite ? "★" : "☆"}
</button>
</fetcher.Form>
);
};
이 폼은 더 이상 탐색을 유발하지 않고 단순하게 action을 가져온다.
⇨ action을 생성하기 전까지는 작동하지 않는다.
👉 action 생성하기
import type {
ActionFunctionArgs,
LoaderFunctionArgs,
} from "@remix-run/node";
import { getContact, updateContact } from "../data";
export const action = async ({
params,
request,
}: ActionFunctionArgs) => {
invariant(params.contactId, "Missing contactId param");
const formData = await request.formData();
return updateContact(params.contactId, {
favorite: formData.get("favorite") === "true",
});
};
// existing code
별을 누르면 자동으로 업데이트 된다!
<fetcher.Form method="post">는 <Form>과 거의 동일하게 작동한다.
action을 호출한 다음 모든 데이터가 자동으로 재검증된다. 심지어 오류도 같은 방식으로 포착된다.
그러나 한 가지 주요 차이점은 탐색이 아니므로 URL이 변경되지 않고 기록 스택이 영향을 받지 않는다는 것이다.
27. 낙관적인 UI
즐겨찾기 버튼을 클릭했을 때 앱이 응답하지 않는 느낌이 들 것이다. ⇦ 약간의 네트워크 대기 시간을 추가했기 때문
유저에게 피드백을 제공하기 위해 fetcher.state를 사용하여 별표를 로드 상태로 설정할 수 있지만 "Optimistic UI"라는 전략을 사용해 더 나은 작업을 수행할 수 있다.
fetcher는 action에 제출되는 FormData를 알고 있으므로 fetcher.formData에서 사용할 수 있다.
네트워크가 완료되지 않은 경우에도 이를 사용해 별의 상태를 즉시 업데이트한다.
업데이트가 결국 실패하면 UI는 실제 데이터로 되돌아간다.
👉 fetcher.formData에서 낙관적인 값 읽기
const Favorite: FunctionComponent<{
contact: Pick<ContactRecord, "favorite">;
}> = ({ contact }) => {
const fetcher = useFetcher();
const favorite = fetcher.formData
? fetcher.formData.get("favorite") === "true"
: contact.favorite;
return (
<fetcher.Form method="post">
<button
aria-label={
favorite
? "Remove from favorites"
: "Add to favorites"
}
name="favorite"
value={favorite ? "false" : "true"}
>
{favorite ? "★" : "☆"}
</button>
</fetcher.Form>
);
};
별을 클릭하면 즉시 새 상태로 변경된다!
'코딩 > 코딩노트' 카테고리의 다른 글
[React Query] react-query로 무한 스크롤링 구현하기 (0) | 2024.06.20 |
---|---|
백엔드에서 API를 아직 만들지 않았다면 MSW를 사용하자! - 회원가입 (0) | 2024.05.30 |
[Remix]Remix 공식문서 파헤치기 6탄 - 컴포넌트 (0) | 2024.04.26 |
[Remix]Remix 공식문서 파헤치기 5탄 - Hooks (0) | 2024.04.23 |
[Remix]Remix 공식문서 파헤치기 4탄 - 파일 컨벤션 (0) | 2024.04.21 |