데이터 쓰기
데이터 쓰기(Data Writes): HTML 폼으로 뮤테이션 만들기
Remix의 데이터 쓰기(이를 뮤테이션이라고 부르기도 해요)는 <form>과 HTTP라는 두 가지 기본 웹 API 위에 세워져요. 여기에 점진적 향상을 더해 낙관적 UI, 로딩 인디케이터, 검증 피드백을 가능하게 하지만, 프로그래밍 모델 자체는 여전히 HTML 폼 위에 있어요.
사용자가 폼을 제출하면 Remix는 1) 폼의 액션을 호출하고 2) 페이지의 모든 라우트 데이터를 다시 로드해요. 많은 사람들이 서버 상태를 컴포넌트에 가져오고 사용자가 바꿀 때 UI를 동기화하려고 redux 같은 전역 상태 관리 라이브러리, apollo 같은 데이터 라이브러리, React Query 같은 fetch 래퍼를 찾곤 하는데, Remix의 HTML 기반 API가 이런 도구들의 대부분 용도를 대체해요. 표준 HTML API를 쓰면 Remix가 데이터를 로드하는 방법과 데이터가 바뀐 뒤 재검증하는 방법을 모두 알기 때문이에요.
본문
액션을 호출하고 라우트를 재검증시키는 방법은 몇 가지가 있어요: <Form>, useSubmit(), useFetcher(). 이 가이드는 <Form>만 다뤄요. 나머지 둘은 이 가이드를 읽고 나서 문서를 보시길 권할게요. 이 가이드의 대부분은 useSubmit에도 적용되지만 useFetcher는 조금 다르답니다.
순수 HTML 폼
Remix <Form>은 <form>과 동일하게 동작하므로, HTML 폼부터 제대로 정리하고 가는 게 좋아요. 그러면 HTML과 Remix를 동시에 배울 수 있어요.
HTML 폼의 HTTP 동사
네이티브 폼은 GET과 POST 두 가지 HTTP 동사를 지원해요. Remix는 이 동사로 당신의 의도를 파악해요. GET이면 페이지에서 바뀌는 부분만 찾아 변경되는 레이아웃의 데이터만 가져오고 바뀌지 않는 레이아웃은 캐시된 데이터를 쓰죠. POST면 서버의 갱신을 확실히 반영하려고 모든 데이터를 다시 로드해요.
HTML 폼 GET
GET은 폼 데이터가 URL 검색 파라미터로 전달되는 일반 내비게이션이에요. <a>와 같은 일반 내비게이션이지만, 사용자가 폼을 통해 검색 파라미터에 데이터를 제공한다는 차이가 있어요. 검색 페이지 외에는 <form>에서 쓰는 일이 드물어요.
<form method="get" action="/search">
<label>Search <input name="term" type="text" /></label>
<button type="submit">Search</button>
</form>
사용자가 채우고 제출을 누르면 브라우저가 폼 값을 자동으로 URL 검색 파라미터 문자열로 직렬화하고, 쿼리 문자열이 붙은 폼의 action으로 이동해요. 예를 들어 사용자가 "remix"를 입력했다면 브라우저는 /search?term=remix로 이동하죠. 필드를 <input name="q"/>로 바꾸면 /search?q=remix로 이동하고요.
이렇게 만든 링크 <a href="/search?term=remix">Search for "remix"</a>와 같은 동작이에요. 유일한 차이는 사용자가 정보를 직접 제공했다는 것이에요. 필드가 더 많으면 브라우저가 그대로 더해요:
<form method="get" action="/search">
<fieldset>
<legend>Brand</legend>
<label>
<input name="brand" value="nike" type="checkbox" />
Nike
</label>
<label>
<input name="brand" value="reebok" type="checkbox" />
Reebok
</label>
<label>
<input name="color" value="white" type="checkbox" />
White
</label>
<label>
<input name="color" value="black" type="checkbox" />
Black
</label>
<button type="submit">Search</button>
</fieldset>
</form>
체크한 체크박스에 따라 브라우저는 이런 URL로 이동해요:
/search?brand=nike&color=black
/search?brand=nike&brand=reebok&color=white
HTML 폼 POST
웹사이트에서 데이터를 만들거나, 삭제하거나, 갱신하고 싶을 때는 폼 POST가 정답이에요. 커다란 사용자 프로필 편집 폼만 얘기하는 게 아니에요. "좋아요" 버튼도 폼으로 처리할 수 있죠.
"새 프로젝트" 폼을 생각해볼게요:
<form method="post" action="/projects">
<label><input name="name" type="text" /></label>
<label><textarea name="description"></textarea></label>
<button type="submit">Create</button>
</form>
사용자가 이 폼을 제출하면 브라우저는 필드를 URL 검색 파라미터가 아니라 요청 "body"로 직렬화해 서버로 "POST"해요. 이것도 사용자가 링크를 클릭한 것과 같은 일반 내비게이션이에요. 차이는 두 가지죠: 사용자가 서버에 데이터를 제공했고, 브라우저가 요청을 "GET"이 아니라 "POST"로 보냈다는 점이에요.
데이터는 서버 요청 핸들러에서 사용할 수 있으므로 레코드를 만들 수 있어요. 그 후 응답을 반환하죠. 이 경우 새로 만든 프로젝트로 리다이렉트하는 게 일반적이에요. Remix 액션은 이렇게 생겼어요:
export async function action({
request,
}: ActionFunctionArgs) {
const body = await request.formData();
const project = await createProject(body);
return redirect(`/projects/${project.id}`);
}
브라우저는 /projects/new에서 시작해 요청에 폼 데이터를 담아 /projects로 전송하고, 서버가 브라우저를 /projects/123으로 리다이렉트해요. 이 모든 일이 일어나는 동안 브라우저는 평소의 "로딩" 상태에 들어가요. 주소 진행 막대가 차오르고 파비콘이 회전 아이콘으로 바뀌죠. 실제로 꽤 괜찮은 사용자 경험이에요.
웹 개발이 처음이라면 이런 방식으로 폼을 사용해 본 적이 없을 수도 있어요. 많은 사람들이 항상 이렇게 해왔거든요:
<form onSubmit={(event) => { event.preventDefault(); // good
luck! }} />
당신이 그런 사람이라면, 브라우저와 Remix가 내장한 것만 사용해도 뮤테이션이 얼마나 쉬운지 보고 기뻐할 거예요!
Remix 뮤테이션, 처음부터 끝까지
이제 JavaScript 선택, 검증, 에러 처리, 점진적으로 향상된 로딩 인디케이터, 점진적으로 향상된 에러 표시까지 갖춘 뮤테이션을 처음부터 끝까지 만들어볼게요.
데이터 뮤테이션에는 HTML 폼을 쓰는 것과 같은 방식으로 Remix <Form> 컴포넌트를 사용해요. 차이는 이제 pending 폼 상태에 접근해 문맥에 맞는 로딩 인디케이터나 "낙관적 UI" 같은 더 나은 사용자 경험을 만들 수 있다는 것이에요.
<form>을 쓰든 <Form>을 쓰든 같은 코드를 작성해요. <form>으로 시작했다가 아무것도 바꾸지 않고 <Form>으로 승격할 수 있어요. 그 후 특별한 로딩 인디케이터와 낙관적 UI를 더하게 되죠. 하지만 여유가 없거나 마감이 촉박하다면 그냥 <form>을 써서 브라우저가 사용자 피드백을 처리하게 해도 돼요. Remix <Form>은 뮤테이션을 위한 "점진적 향상"의 구현이에요.
폼 만들기
app/routes/projects.new.tsx 라우트에 이 폼이 있다고 해볼게요:
export default function NewProject() {
return (
<form method="post" action="/projects/new">
<p>
<label>
Name: <input name="name" type="text" />
</label>
</p>
<p>
<label>
Description:
<br />
<textarea name="description" />
</label>
</p>
<p>
<button type="submit">Create</button>
</p>
</form>
);
}
이제 라우트 액션을 추가해요. "post"인 모든 폼 제출은 데이터 "action"을 호출하고, "get" 제출(<Form method="get">)은 "loader"가 처리해요.
import type { ActionFunctionArgs } from "@remix-run/node"; // or cloudflare/deno
import { redirect } from "@remix-run/node"; // or cloudflare/deno
// Note the "action" export name, this will handle our form POST
export const action = async ({
request,
}: ActionFunctionArgs) => {
const formData = await request.formData();
const project = await createProject(formData);
return redirect(`/projects/${project.id}`);
};
export default function NewProject() {
// ... same as before
}
그게 전부예요! createProject가 원하는 대로 동작한다고 가정하면, 해야 할 일은 이것뿐이에요. 어떤 SPA를 만들었든 사용자에게서 데이터를 얻으려면 항상 서버 사이드 액션과 폼이 필요하다는 점을 기억하세요. Remix의 차이는 그게 전부라는 것(그리고 예전 웹도 그랬다는 것)이에요.
물론 기본 브라우저 동작보다 나은 사용자 경험을 만들려고 우리가 일을 복잡하게 만들기 시작한 거죠. 계속 가면 도달하겠지만, 핵심 기능을 얻기 위해 이미 작성한 코드는 바꿀 필요가 없어요.
폼 검증
폼을 클라이언트와 서버 양쪽에서 검증하는 건 흔한 일이에요. 안타깝게도 클라이언트에서만 검증하는 일도 흔해서 데이터에 여러 문제가 생기죠. 핵심은, 한 곳에서만 검증한다면 서버에서 하라는 거예요. Remix에서는 앞으로 신경 쓸 곳이 그곳뿐임을 발견하게 될 거예요 (브라우저로 보낼수록 좋지 않으니까요!).
우리 액션에서 이렇게 검증 에러를 반환하는 API가 있다고 해볼게요:
const [errors, project] = await createProject(formData);
검증 에러가 있으면 폼으로 돌아가 표시하고 싶어요.
import { json, redirect } from "@remix-run/node"; // or cloudflare/deno
export const action = async ({
request,
}: ActionFunctionArgs) => {
const formData = await request.formData();
const [errors, project] = await createProject(formData);
if (errors) {
const values = Object.fromEntries(formData);
return json({ errors, values });
}
return redirect(`/projects/${project.id}`);
};
useLoaderData가 loader의 값을 반환하듯, useActionData는 액션의 데이터를 반환해요. 이 데이터는 네비게이션이 폼 제출일 때만 존재하므로 항상 있는지 확인해야 해요.
import type { ActionFunctionArgs } from "@remix-run/node"; // or cloudflare/deno
import { json, redirect } from "@remix-run/node"; // or cloudflare/deno
import { useActionData } from "@remix-run/react";
export const action = async ({
request,
}: ActionFunctionArgs) => {
// ...
};
export default function NewProject() {
const actionData = useActionData<typeof action>();
return (
<form method="post" action="/projects/new">
<p>
<label>
Name:{" "}
<input
name="name"
type="text"
defaultValue={actionData?.values.name}
/>
</label>
</p>
{actionData?.errors.name ? (
<p style={{ color: "red" }}>
{actionData.errors.name}
</p>
) : null}
<p>
<label>
Description:
<br />
<textarea
name="description"
defaultValue={actionData?.values.description}
/>
</label>
</p>
{actionData?.errors.description ? (
<p style={{ color: "red" }}>
{actionData.errors.description}
</p>
) : null}
<p>
<button type="submit">Create</button>
</p>
</form>
);
}
모든 입력에 defaultValue를 더한 걸 주목하세요. 이것은 일반 HTML <form>이므로 평범한 브라우저/서버 동작이 일어나고 있어요. 사용자가 다시 입력하지 않아도 되도록 서버에서 값을 받아오는 거예요. (진행 중인 UI와 중단은 브라우저가 처리해주니) 이 코드를 그대로 배포해도 돼요.
<Form>으로 승격하고 pending UI 추가하기
점진적 향상을 사용해 이 UX를 좀 더 멋지게 만들어볼게요. <form>을 <Form>으로 바꾸면 Remix가 브라우저 동작을 fetch로 흉내 내고, pending 폼 데이터에 접근해 pending UI를 만들 수 있게 해줘요.
import { json, redirect } from "@remix-run/node"; // or cloudflare/deno
import { useActionData, Form } from "@remix-run/react";
// ...
export default function NewProject() {
const actionData = useActionData<typeof action>();
return (
// note the capital "F" <Form> now
<Form method="post">{/* ... */}</Form>
);
}
여기서 잠깐! 폼을 Form으로만 바꾸면 UX가 조금 나빠져요. 나머지 작업을 할 시간이나 의욕이 없다면 <Form reloadDocument>를 쓰세요. 그러면 브라우저가 pending UI 상태(탭 파비콘의 회전 아이콘, 주소 막대 진행 바 등)를 계속 처리해줘요. pending UI를 구현하지 않고 <Form>만 쓰면 사용자는 제출했을 때 아무 일도 일어나는지 알 수 없어요.
항상 대문자 F Form을 쓰고, 브라우저가 pending UI를 처리하도록 하고 싶다면 <Form reloadDocument> prop을 쓰는 걸 권장해요.
이제 제출했을 때 무슨 일이 일어났는지 알 수 있게 pending UI를 추가해볼게요. useNavigation이라는 훅이 있어요. pending 폼 제출이 있으면 Remix가 폼의 직렬화 버전을 FormData 객체로 제공해요. 가장 관심 있을 메서드는 formData.get()이에요.
import { json, redirect } from "@remix-run/node"; // or cloudflare/deno
import {
useActionData,
Form,
useNavigation,
} from "@remix-run/react";
// ...
export default function NewProject() {
// when the form is being processed on the server, this returns different
// navigation states to help us build pending and optimistic UI.
const navigation = useNavigation();
const actionData = useActionData<typeof action>();
return (
<Form method="post">
<fieldset
disabled={navigation.state === "submitting"}
>
<p>
<label>
Name:{" "}
<input
name="name"
type="text"
defaultValue={
actionData
? actionData.values.name
: undefined
}
/>
</label>
</p>
{actionData && actionData.errors.name ? (
<p style={{ color: "red" }}>
{actionData.errors.name}
</p>
) : null}
<p>
<label>
Description:
<br />
<textarea
name="description"
defaultValue={
actionData
? actionData.values.description
: undefined
}
/>
</label>
</p>
{actionData && actionData.errors.description ? (
<p style={{ color: "red" }}>
{actionData.errors.description}
</p>
) : null}
<p>
<button type="submit">
{navigation.state === "submitting"
? "Creating..."
: "Create"}
</button>
</p>
</fieldset>
</Form>
);
}
이제 사용자가 "Create"를 클릭하면 입력이 비활성화되고 제출 버튼 텍스트가 바뀌어요. 전체 작업도 더 빨라지는데, 전체 페이지 리로드 대신 네트워크 요청이 하나만 일어나기 때문이에요 (페이지 리로드는 추가 네트워크 요청, 브라우저 캐시에서 자산 읽기, JavaScript 파싱, CSS 파싱 등을 수반할 수 있죠).
이 페이지에서 navigation을 많이 쓰진 않았지만, 여기에는 제출에 대한 모든 정보(navigation.formMethod, navigation.formAction, navigation.formEncType)와 서버에서 처리 중인 모든 값(navigation.formData)이 담겨 있어요.
검증 에러 애니메이션
JavaScript로 이 페이지를 제출하고 있으므로 페이지가 상태를 가지며 검증 에러를 애니메이션 할 수 있어요. 먼저 높이와 투명도를 애니메이션하는 멋진 컴포넌트를 만들게요:
function ValidationMessage({ error, isSubmitting }) {
const [show, setShow] = useState(!!error);
useEffect(() => {
const id = setTimeout(() => {
const hasError = !!error;
setShow(hasError && !isSubmitting);
});
return () => clearTimeout(id);
}, [error, isSubmitting]);
return (
<div
style={{
opacity: show ? 1 : 0,
height: show ? "1em" : 0,
color: "red",
transition: "all 300ms ease-in-out",
}}
>
{error}
</div>
);
}
이제 기존 에러 메시지를 이 새 컴포넌트로 감싸고, 에러가 있는 필드의 테두리도 빨갛게 만들 수 있어요:
export default function NewProject() {
const navigation = useNavigation();
const actionData = useActionData<typeof action>();
return (
<Form method="post">
<fieldset
disabled={navigation.state === "submitting"}
>
<p>
<label>
Name:{" "}
<input
name="name"
type="text"
defaultValue={
actionData
? actionData.values.name
: undefined
}
style={{
borderColor: actionData?.errors.name
? "red"
: "",
}}
/>
</label>
</p>
{actionData?.errors.name ? (
<ValidationMessage
isSubmitting={navigation.state === "submitting"}
error={actionData?.errors?.name}
/>
) : null}
<p>
<label>
Description:
<br />
<textarea
name="description"
defaultValue={actionData?.values.description}
style={{
borderColor: actionData?.errors.description
? "red"
: "",
}}
/>
</label>
</p>
<ValidationMessage
isSubmitting={navigation.state === "submitting"}
error={actionData?.errors.description}
/>
<p>
<button type="submit">
{navigation.state === "submitting"
? "Creating..."
: "Create"}
</button>
</p>
</fieldset>
</Form>
);
}
서버와 통신하는 방식을 전혀 바꾸지 않고 멋진 UI를 만들었어요. JavaScript 로딩을 막는 네트워크 상황에도 견고하답니다.
리뷰
- 먼저 JavaScript를 염두에 두지 않고 프로젝트 폼을 만들었어요. 서버 사이드 액션으로 전송되는 단순 폼이죠. 1998년에 온 걸 환영해요.
- 그것이 동작하자
<form>을<Form>으로 바꿔 JavaScript로 폼을 제출했지만, 다른 것은 아무것도 바꿀 필요가 없었어요! - React로 상태를 가진 페이지가 생기자, Remix에 네비게이션 상태만 물어봄으로써 로딩 인디케이터와 검증 에러 애니메이션을 추가했어요.
컴포넌트 관점에서 보면 폼이 제출됐을 때 useNavigation 훅이 상태 갱신을 일으키고, 데이터가 돌아왔을 때 또 한 번 상태 갱신을 일으킨 게 전부예요. 물론 Remix 내부에서는 훨씬 더 많은 일이 일어나지만, 컴포넌트 입장에서는 그게 다예요. 상태 갱신 몇 번뿐이죠. 덕분에 어떤 사용자 흐름도 쉽게 꾸밀 수 있어요.