Actions — 폼 제출이 쉬워졌다
Article
Part 6이 시작된다. 여기서부터는 React 19에서 새로 생긴 것들이다.
지금까지 배운 것만으로도 앱을 만들 수 있다. 다만 폼을 다룰 때마다 똑같은 코드를 반복하게 되는데, 그걸 줄이는 게 이번 Part의 주제다.
지금까지의 폼
이름을 서버에 보내는 폼을 10강과 15강에서 배운 것으로 써본다.
function NameForm() {
const [name, setName] = useState("");
const [isPending, setIsPending] = useState(false);
const [error, setError] = useState(null);
async function handleSubmit(event) {
event.preventDefault();
setIsPending(true);
setError(null);
try {
await updateName(name);
setName("");
} catch (err) {
setError(err.message);
} finally {
setIsPending(false);
}
}
return (
<form onSubmit={handleSubmit}>
<input value={name} onChange={e => setName(e.target.value)} />
<button disabled={isPending}>{isPending ? "저장 중…" : "저장"}</button>
{error && <p>{error}</p>}
</form>
);
}
동작한다. 그런데 이 코드에서 정말 우리 앱만의 것은 updateName(name) 한 줄뿐이다.
- 01
로딩 상태 만들기
isPendingstate 를 만들고 시작할 때 켜고 끝날 때 끈다. - 02
에러 상태 만들기
errorstate 를 만들고try/catch로 잡아 넣는다. - 03
기본 동작 막기
event.preventDefault()를 빠뜨리면 새로고침된다. - 04
성공하면 초기화
setName("")를 잊으면 입력값이 남는다.
폼이 열 개면 이 네 가지를 열 번 쓴다. React 19는 이걸 전부 가져간다.
form 의 action
<form>에 action 속성으로 함수를 넘긴다.
function NameForm() {
async function submit(formData) {
await updateName(formData.get("name"));
}
return (
<form action={submit}>
<input name="name" />
<button>저장</button>
</form>
);
}
onSubmit이 아니라 action이다. 그리고 이 함수를 Action이라 부른다.
onSubmit
이벤트 핸들러
- 이벤트 객체를 받는다
preventDefault()를 직접 부른다- 값은 state 로 관리한다
- 로딩·에러를 직접 만든다
action
Action 함수
- FormData 를 받는다
- 기본 동작을 React 가 막는다
name속성으로 값을 읽는다- 성공하면 폼을 자동으로 비운다
formData.get("name")이 <input name="name">의 값을 준다. useState도, value도, onChange도 없다.
10강에서 배운 제어 컴포넌트를 안 써도 된다는 뜻이다. 글자 수를 세거나 입력을 실시간으로 검사할 게 아니라면, name 속성만으로 충분하다.
useActionState — 상태까지 받는다
action만으로는 로딩과 에러를 알 수 없다. 그걸 주는 훅이 useActionState다.
import { useActionState } from "react";
function NameForm() {
const [error, submitAction, isPending] = useActionState(
async (previousState, formData) => {
const result = await updateName(formData.get("name"));
if (!result.ok) return result.message;
return null;
},
null
);
return (
<form action={submitAction}>
<input name="name" />
<button disabled={isPending}>{isPending ? "저장 중…" : "저장"}</button>
{error && <p>{error}</p>}
</form>
);
}
useState와 생김새가 비슷하지만 돌려주는 게 셋이다.
| 이름 | 무엇인가 | |
|---|---|---|
| [0] | state | Action 이 반환한 값 |
| [1] | formAction | form 의 action 에 넘길 함수 |
| [2] | isPending | 실행 중인지 여부 — React 가 관리한다 |
| 인자1 | action 함수 | (이전 state, formData) 를 받는다 |
| 인자2 | 초기값 | 첫 렌더링에서의 state |
핵심은 두 가지다.
- 01
isPending 을 자동으로 관리한다
Action 이 시작되면
true, 끝나면false.setIsPending을 부를 일이 없다. - 02
반환값이 state 가 된다
에러 메시지를
return하면 그게state로 들어온다.setError가 필요 없다.if (!result.ok) return result.message; - 03
이전 state 를 인자로 준다
첫 인자가 이전 값이다. 9강의 갱신 함수
setCount(c => c + 1)과 같은 발상이다.
- 폼 제출action 실행
- isPending = trueReact 가 켠다
- 함수가 값을 반환return message
- state 가 된다isPending = false
useFormStatus — 자식에서 상태를 읽는다
버튼을 별도 컴포넌트로 뺐다고 하자.
function SubmitButton({ isPending }) {
return <button disabled={isPending}>저장</button>;
}
isPending을 props로 넘겨야 한다. 폼이 깊어지면 6강에서 본 prop drilling이 된다.
useFormStatus는 부모 <form>의 상태를 직접 읽는다.
import { useFormStatus } from "react-dom";
function SubmitButton() {
const { pending } = useFormStatus();
return <button disabled={pending}>{pending ? "저장 중…" : "저장"}</button>;
}
function NameForm() {
return (
<form action={submitAction}>
<input name="name" />
<SubmitButton />
</form>
);
}
props를 넘기지 않았는데 버튼이 상태를 안다. 18강의 Context와 같은 원리다. 실제로 React가 내부적으로 그렇게 구현했다.
- form
- input
- SubmitButton
- props 를 받지 않았다
react가 아니라 **react-dom**에서 가져온다는 것도 헷갈리는 지점이다. 폼은 DOM의 개념이기 때문이다.
세 가지를 언제 쓰나
| 필요한 것 | 쓸 것 | 어디서 import |
|---|---|---|
| 제출 처리만 | form action | — |
| 에러 메시지 · 결과값 | useActionState | react |
| 자식에서 pending 읽기 | useFormStatus | react-dom |
셋을 같이 쓰는 게 일반적이다. useActionState로 에러를 받고, useFormStatus로 버튼을 비활성화한다.
폼 밖에서도 쓴다
Action은 <form> 전용이 아니다. startTransition으로 직접 부를 수 있다.
import { useState, useTransition } from "react";
function LikeButton({ postId }) {
const [isPending, startTransition] = useTransition();
function handleClick() {
startTransition(async () => {
await likePost(postId);
});
}
return (
<button onClick={handleClick} disabled={isPending}>
{isPending ? "…" : "좋아요"}
</button>
);
}
useTransition이 isPending을 관리한다. 폼이 없는 버튼에도 같은 편의를 얻는다.
서버 액션은 프레임워크의 몫
Next.js 같은 프레임워크에서는 Action 함수를 서버에서 실행할 수 있다.
async function updateName(formData) {
"use server";
await db.user.update({ name: formData.get("name") });
}
"use server" 한 줄로 이 함수가 서버에서 돈다. 클라이언트는 요청만 보낸다.
이건 React 혼자 하는 일이 아니라 프레임워크가 번들러와 함께 만들어주는 기능이다. Vite로 만든 순수 React 앱에서는 쓸 수 없다. 이름만 알아둔다.
정리하면
- Action은
<form action={fn}>에 넘기는 함수다.FormData를 받고,preventDefault()가 필요 없다. - 비동기 함수를 그대로 넘길 수 있다. 성공하면 폼이 자동으로 비워진다.
- **
useActionState**는[state, formAction, isPending]을 준다. Action의 반환값이 state가 된다. isPending을 직접 관리할 일이 없다.setIsPending,setError,try/catch가 사라진다.- **
useFormStatus**는 자식 컴포넌트에서 부모 폼의 상태를 읽는다.react-dom에서 가져오고, 같은 컴포넌트에서 부르면 안 된다. - 폼이 없어도
useTransition으로 같은 편의를 얻는다.
다음 강의에서는 서버 응답을 기다리지 않고 화면을 먼저 바꾸는 법, **useOptimistic**을 본다.