글을 시작하며
리액트 공식문서를 끝까지 읽으며 공부한 적이 없는 것 같아서 더 깊이 있게 공부하고자 공식문서를 공부하게 되었습니다. 학부생으로서 막학기를 지내며 이력서, 포트폴리오, 코딩테스트 등등 할 일이 많지만 차근차근 학습해 나가도록 하겠습니다!
이번 시간에는 useActionState를 공부하며 직접 코드로 구현해보는 시간을 가져보려고 합니다.
useActionState란
React 19버전에 도입되었으며, 폼 액션의 결과를 기반으로 state를 업데이트할 수 있도록 제공하는 Hook입니다.
이전 버전에서는 비동기 작업의 상태를 추적하기 위해 여러 개의 useState를 사용해 각각의 상태를 따로 관리해야 했습니다.
하지만 useActionState를 사용하면, 별도의 상태 훅 없이도 form 액션의 결과와 처리 상태를 한 번에 관리할 수 있습니다.
const [state, formAction, isPending] = useActionState(fn, initialState, permalink?);
fn : 폼에서 실행할 액션 함수
initialState: 초기 state 값
permalink (Optional): 상태 동기화를 위한 참조값
자바스크립트가 로드되어 폼이 완전히 활성화된 상태(hydrated)가 되면 permalink는 더 이상 필요하지 않음
ref> hydrate: 렌더링된 HTML 마크업에 기반하여 클라이언트 측에서 자바스크립트 이벤트와 상태를 연결하는 과정
이 훅은 다음 3가지를 반환합니다.
- state : 액션 함수 실행 결과 상태
- formAction : form action={formAction} 형태로 연결되는 핸들러
- isPending :현재 Transition이 대기 중인지 알려주는 플래그 (true/false)
useActionState 실제로 활용하기
Next.js 환경에서 회원가입 폼을 서버로 전송하는 예시를 만들어 보겠습니다.
src/actions/registerUser.tsx
"use server";
import { validateSignupForm } from "@/utils/validateSignupForm";
interface RegisterFormData {
name: string;
phone: string;
email: string;
password: string;
}
interface FormState {
success: boolean;
message: string;
errors?: {
name?: string;
phone?: string;
email?: string;
password?: string;
message?: string;
};
}
export async function registerUser(
prevState: FormState,
formData: FormData
): Promise<FormState> {
const data: RegisterFormData = {
name: formData.get("name") as string,
phone: formData.get("phone") as string,
email: formData.get("email") as string,
password: formData.get("password") as string,
};
const errors = validateSignupForm(data);
if (Object.keys(errors).length > 0) {
return {
success: false,
message: "에러 고치기",
errors,
};
}
console.log("회원가입 요청", data);
await new Promise((resolve) => setTimeout(resolve, 4000));
return {
success: true,
message: "회원가입 완료",
};
}
서버 액션을 정의하여 폼 데이터를 추출하고 (formData.get…) 유효성 검사를 실시해서(validateSignupForm) setTimeout으로 대기하여 폼 처리 시뮬레이션을 구현하고 성공 또는 실패를 반환하도록 구현했습니다.
src/app/signup/page.tsx
"use client";
import { registerUser } from "@/actions/registerUser";
import { useActionState } from "react";
import { useFormStatus } from "react-dom";
const initialState = {
success: false,
message: "",
errors: {},
};
function FormSubmitButton() {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{pending ? "가입중..." : " 회원가입"}
</button>
);
}
export default function SignupForm() {
const [state, formAction, isPending] = useActionState(
registerUser,
initialState
);
return (
<form
action={formAction}
className="space-y-4 max-w-md mx-auto p-6 bg-white shadow rounded"
>
<h2 className="text-xl font-bold">회원가입</h2>
<div>
<label htmlFor="name">이름</label>
<input
id="name"
name="name"
type="text"
required
className={`w-full mt-1 p-2 border rounded ${
state.errors?.name ? "border-red-400" : "border-gray-300"
}`}
/>
{state.errors?.name && (
<p className="text-sm text-red-600">{state.errors.name}</p>
)}
</div>
<div>
<label htmlFor="phone">전화번호</label>
<input
id="phone"
name="phone"
type="tel"
required
className="w-full mt-1 p-2 border rounded border-gray-300"
/>
{state.errors?.phone && (
<p className="text-sm text-red-600">{state.errors.phone}</p>
)}
</div>
<div>
<label htmlFor="email">이메일</label>
<input
id="email"
name="email"
type="email"
required
className={`w-full mt-1 p-2 border rounded ${
state.errors?.email ? "border-red-400" : "border-gray-300"
}`}
/>
{state.errors?.email && (
<p className="text-sm text-red-600">{state.errors.email}</p>
)}
</div>
<div>
<label htmlFor="password">비밀번호</label>
<input
id="password"
name="password"
type="password"
required
className={`w-full mt-1 p-2 border rounded ${
state.errors?.password ? "border-red-400" : "border-gray-300"
}`}
/>
{state.errors?.password && (
<p className="text-sm text-red-600">{state.errors.password}</p>
)}
</div>
<FormSubmitButton />
{isPending && <p>회원가입 처리중...</p>}
{state.message && (
<p
className={`mt-4 p-2 text-center rounded ${
state.success
? "text-green-700 bg-green-100"
: "text-red-700 bg-red-100"
}`}
>
{state.message}
</p>
)}
</form>
);
}
useFormStatus는 React에서 <form> 내부의 폼 전송 상태를 감지하고 현재 폼이 제출 중인지 여부를 확인할 수 있도록 도와주는 훅입니다.
주로 button, form 등 폼 내부 하위 컴포넌트에서 제출 상태에 따른 UI 변화를 줄 때 사용합니다.
src/utils/validateSignupForm.tsx
export function validateSignupForm(data: {
name: string;
phone: string;
email: string;
password: string;
}) {
const errors: Record<string, string> = {};
if (!data.name || data.name.length < 2) {
errors.name = "이름은 최소 2자 이상이어야 합니다.";
}
if (!data.email || !data.email.includes("@")) {
errors.email = "올바른 이메일 주소를 입력해주세요.";
}
if (!data.password || data.password.length < 6) {
errors.password = "비밀번호는 최소 6자 이상이어야 합니다.";
}
return errors;
}
각 필드에 대해 간단한 유효성 검사를 진행합니다.
글을 마치며
기존에 form 제출 시 새로고침이 발생하지 않아 사용자 경험을 향상할 수 있겠다는 생각이 들었고,
UI로직에서 추가적으로 상태 변수 컴포넌트를 만들어서 사용했었는데 폼 상태와 함께 관리하기 때문에 코드 복잡성이 줄어들고 가독성이 높아짐을 알 수 있었습니다.
긴 글 읽으시느라 고생 많으셨습니다.
위에서 다뤘던 useActionState 개념과 특징들을 정리하면서 글을 마치도록 하겠습니다 😀
- 서버 액션 함수의 결과 상태 + 로딩상태를 통합적으로 관리할 수 있게 해주는 훅
- 기존의 useState, useTransition 없이도 폼 제출의 흐름을 간편하게 구현
- state, formAction, isPending을 한 번에 관리가 가능하고, 새로고침없이 서버 동작 연결도 가능
- 서버에 폼을 제출하거나 유효성을 검사할 때 유용
'FE' 카테고리의 다른 글
HTML과 React의 이벤트 처리 차이점 (0) | 2024.10.18 |
---|---|
[Type Challenges] Pick 풀이 (1) | 2024.07.03 |
프론트에서 Transaction을 ? (0) | 2024.05.23 |
[ Jest ] toBeCalledWith vs toEqual (1) | 2023.11.07 |
[Error : Warning: Each child in a list should have a unique "key" prop ] (0) | 2023.09.21 |