학습 자료

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) 한 줄뿐이다.

폼마다 반복되는 것들
  1. 01

    로딩 상태 만들기

    isPending state 를 만들고 시작할 때 켜고 끝날 때 끈다.

  2. 02

    에러 상태 만들기

    error state 를 만들고 try/catch 로 잡아 넣는다.

  3. 03

    기본 동작 막기

    event.preventDefault() 를 빠뜨리면 새로고침된다.

  4. 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 과 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]stateAction 이 반환한 값
[1]formActionform 의 action 에 넘길 함수
[2]isPending실행 중인지 여부 — React 가 관리한다
인자1action 함수(이전 state, formData) 를 받는다
인자2초기값첫 렌더링에서의 state

핵심은 두 가지다.

React 가 대신 해주는 것
  1. 01

    isPending 을 자동으로 관리한다

    Action 이 시작되면 true, 끝나면 false. setIsPending 을 부를 일이 없다.

  2. 02

    반환값이 state 가 된다

    에러 메시지를 return 하면 그게 state 로 들어온다. setError 가 필요 없다.

    if (!result.ok) return result.message;
  3. 03

    이전 state 를 인자로 준다

    첫 인자가 이전 값이다. 9강의 갱신 함수 setCount(c => c + 1) 과 같은 발상이다.

값이 도는 길
  1. 폼 제출action 실행
  2. isPending = trueReact 가 켠다
  3. 함수가 값을 반환return message
  4. 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가 내부적으로 그렇게 구현했다.

useFormStatus 가 읽는 것
  • formaction 이 실행 중이다
    • inputname="name"
    • SubmitButtonuseFormStatus() → { pending: true }
      • props 를 받지 않았다

react가 아니라 **react-dom**에서 가져온다는 것도 헷갈리는 지점이다. 폼은 DOM의 개념이기 때문이다.

세 가지를 언제 쓰나

필요한 것쓸 것어디서 import
제출 처리만form action
에러 메시지 · 결과값useActionStatereact
자식에서 pending 읽기useFormStatusreact-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>
  );
}

useTransitionisPending을 관리한다. 폼이 없는 버튼에도 같은 편의를 얻는다.

서버 액션은 프레임워크의 몫

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**을 본다.

Actions — 폼 제출이 쉬워졌다 — 디코드랩(DCODELAB)