Void 폼과 액션: useForm으로 끝까지 타입 안전하게
Void 라우팅에서 데이터를 읽어 오는 로더는 자세히 봤는데요. 정작 데이터를 바꾸는 쪽은 “로더 대신 action을 내보낸다”는 한 줄로 넘어갔습니다. 사실 폼을 다루는 일은 읽기보다 손이 많이 가죠. 입력값을 검증하고, 에러를 화면에 표시하고, 제출 중에는 버튼을 막고, 성공하면 목록을 새로고침하고… 이번 글에서는 Void가 이 과정을 useForm 하나로 어떻게 묶어주는지 살펴보겠습니다.
액션으로 데이터 바꾸기
데이터를 읽을 때 로더를 썼다면, 바꿀 때는 액션을 씁니다. 페이지의 .server.ts에서 action을 내보내면 돼요. POST, PUT, PATCH, DELETE 같은 변경 요청이 이 함수로 들어옵니다.
import { defineHandler } from "void";
import { db } from "void/db";
import { users, insertUserSchema } from "@schema";
export const action = defineHandler.withValidator({
body: insertUserSchema,
})(async (c, { body }) => {
await db.insert(users).values(body);
// 반환값이 없으면 로더가 다시 실행되고 페이지가 갱신됩니다
});
여기서 insertUserSchema는 Void 데이터베이스에서 봤던, 스키마에서 뽑아낸 그 검증기예요. withValidator로 걸면 잘못된 입력은 액션 본문에 닿기도 전에 걸러집니다.
액션의 반환값은 동작을 결정합니다. 아무것도 반환하지 않으면 같은 페이지의 로더가 다시 실행되면서 화면이 최신 데이터로 갱신돼요. 다른 페이지로 보내고 싶으면 c.redirect('/users')처럼 리다이렉트하면 되고요.
여러 액션이 필요하면 named actions
한 페이지에서 수정도 하고 삭제도 해야 한다면, action 하나로는 부족하죠. 이럴 때는 actions(복수)로 내보냅니다.
export const actions = {
update: defineHandler.withValidator({
body: updateUserSchema,
})(async (c, { body }) => {
// 수정 로직
}),
delete: defineHandler(async (c) => {
// 삭제 로직
}),
};
각 액션은 ?update, ?delete처럼 URL 뒤에 이름을 붙여 호출합니다. 이름 없는 평범한 POST는 actions.default 키로 받고요. 한 페이지의 여러 변경을 파일 하나에 모아둘 수 있어 깔끔합니다.
useForm으로 폼 다루기
이제 클라이언트 차례예요. 폼은 useForm 훅으로 다룹니다. 폼 값부터 검증 에러, 제출 상태까지 한 번에 관리해주고, 위에서 정의한 액션 스키마에 맞춰 타입까지 입혀줍니다.
import { useForm } from "@void/react";
export default function CreateUser() {
const form = useForm("/users/create", { name: "", email: "" });
return (
<form action={form.post}>
<input
name="name"
value={form.data.name}
onChange={(e) => form.setData("name", e.target.value)}
/>
{form.errors.name && <span>{form.errors.name}</span>}
<button disabled={form.pending}>만들기</button>
</form>
);
}
form.post를 <form action>에 넘기면 제출이 액션으로 연결됩니다. form.data로 값을 읽고 form.setData로 바꾸며, form.errors에는 서버가 돌려준 필드별 에러가 담겨요. form.pending은 제출 중일 때 참이라, 버튼을 막아 중복 제출을 손쉽게 방지할 수 있습니다.
useForm이 제공하는 것들을 정리하면 이렇습니다.
data/setData— 폼 값과 값 변경 함수 (액션 스키마 타입)errors— 필드별 검증 에러error— 검증 외의 호출 에러post/put/patch/delete— HTTP 메서드별 제출 핸들러pending— 제출이 진행 중인지hasChanges— 초기값과 달라졌는지 (이탈 경고 등에 유용)wasSuccessful/recentlySuccessful— 제출 성공 직후 상태 (후자는 2초간 참)reset()/clearErrors()— 폼 초기화, 에러 비우기
검증 에러 다루기
폼을 다룰 때 가장 까다로운 게 에러 처리인데요. Void는 에러를 두 갈래로 나눠 다룹니다. 입력이 잘못됐거나 중복처럼 호출 지점에서 처리할 만한 에러(400, 404, 409, 422, 429)는 form.errors나 form.error에 담겨, 폼 옆에 메시지로 보여주기 좋아요. 반면 인증 실패나 서버 오류, 네트워크 장애(401, 403, 500 등)는 프레임워크의 에러 바운더리로 던져집니다. “이 입력이 틀렸어요”와 “지금 뭔가 크게 잘못됐어요”를 분리해서 다루는 셈이죠.
직접 검증 에러를 던지고 싶을 때는 ValidationError를 씁니다.
import { ValidationError } from "void";
export const action = defineHandler(async (c) => {
const body = await c.req.json();
if (await emailExists(body.email)) {
throw new ValidationError({ email: "이미 사용 중인 이메일이에요" });
}
// ...
});
이렇게 던진 에러는 form.errors.email로 흘러가 해당 입력 칸 아래에 그대로 표시됩니다. 스키마로 거를 수 없는 “DB를 조회해봐야 아는” 검증을 여기서 처리하면 돼요.
파일 업로드
파일 업로드도 useForm이 알아서 처리합니다. 폼 값에 File이나 Blob이 들어 있으면 자동으로 multipart/form-data로 보내거든요.
const form = useForm("/photos", { title: "", photo: null as File | null });
서버 액션에서는 parseBody로 파일을 꺼내 R2 스토리지 같은 곳에 저장하면 됩니다.
export const action = defineHandler(async (c) => {
const body = await c.req.parseBody();
const file = body["photo"] as File;
await storage.put(file.name, file.stream());
});
직접 FormData를 만들거나 인코딩을 신경 쓸 일 없이, 평소처럼 폼 값에 파일을 담기만 하면 되는 거죠.
폼 없이 변경하기: action() 헬퍼
목록의 삭제 버튼이나 토글 스위치처럼 폼 상태가 필요 없는 변경도 있죠. 이럴 때는 action() 헬퍼로 한 번에 호출합니다.
import { action } from "@void/react";
const result = await action("/?delete-user", {
data: { id: 42 },
method: "DELETE",
});
if (!result.ok) {
showToast(result.error.message);
}
성공하면 { ok: true, pageData }를, 실패하면 { ok: false, error }를 돌려줘서, 결과에 따라 토스트를 띄우는 식으로 처리하기 좋습니다.
언제 무엇을 쓸까
세 가지 도구의 쓰임이 조금씩 겹쳐 보이지만, 기준은 분명합니다. 입력 칸이 여럿이고 변경 추적, 초기화, 필드 에러가 필요한 본격적인 폼이라면 useForm이 제격이에요. 삭제 버튼이나 토글처럼 폼 없이 한 번 찌르는 변경은 action() 헬퍼가 가볍고요. Inertia식 페이지 갱신 없이 순수한 응답만 받고 싶다면 평범한 fetch()를 쓰면 됩니다.
마치며
Void의 폼과 액션은 서버의 action과 클라이언트의 useForm이 액션 스키마를 사이에 두고 양쪽에서 같은 타입을 바라보는 구조입니다. 데이터베이스 스키마에서 뽑은 검증기 하나가 서버 검증과 클라이언트 폼 타입을 동시에 책임지니, 폼 코드에서 흔히 새던 “서버와 클라이언트의 타입 불일치”가 들어설 자리가 없어요.
데이터를 읽어 오는 로더 쪽은 Void 라우팅에서, 그 검증기를 만들어내는 스키마는 Void 데이터베이스에서 다룹니다. 세 글을 함께 보면 Void의 풀스택 데이터 흐름이 한눈에 이어질 거예요.
더 자세한 내용은 Void 공식 폼/액션 문서를 참고하세요.
This work is licensed under
CC BY 4.0