RSS듀오랩스
React

React useActionState와 useOptimistic으로 폼 다루기

작성자
듀오랩스 대표·9분 읽기

견적 요청 폼을 보내는 코드입니다.

function QuoteForm() {
  const [error, setError] = useState(null);
  const [isSubmitting, setIsSubmitting] = useState(false);

  async function handleSubmit(e) {
    e.preventDefault();
    setIsSubmitting(true);
    setError(null);
    const result = await submitQuote(new FormData(e.target));
    if (result.error) setError(result.error);
    setIsSubmitting(false);
  }

  return (
    <form onSubmit={handleSubmit}>
      {/* 입력칸들 */}
      <button disabled={isSubmitting}>요청</button>
      {error && <p>{error}</p>}
    </form>
  );
}

동작은 합니다. 그런데 이 코드가 관리하는 것은 폼의 내용이 아니라 요청의 진행 상황입니다. 보내는 중인지, 끝났는지, 실패했는지. 폼마다 이 세 줄을 다시 씁니다. 그리고 setIsSubmitting(false) 를 오류가 난 경로에서 빠뜨리면 버튼이 영원히 비활성화됩니다.

폼 상태는 useState 로 직접 관리한다는 생각

React 에서 폼을 다룬다는 것은 흔히 「입력값, 로딩 여부, 오류를 각각 state 로 두고 이벤트 핸들러에서 순서대로 바꾸는 것」으로 이해됩니다. 요청 하나에 state 두세 개와 그것을 켜고 끄는 코드가 따라옵니다.

지금의 React 는 이 패턴을 Action 이라는 단위로 다룹니다. transition 글에서 startTransition 에 넘기는 함수를 Action 이라고 부른다고 했습니다. React 는 Action 이 진행 중인지를 이미 알고 있으므로, 「보내는 중」 state 를 우리가 따로 켜고 끌 필요가 없습니다. 이 위에 올라간 훅이 useActionStateuseOptimistic 입니다.

useActionState: 요청의 결과를 state 로

useActionState 문서는 이 훅을 「Action 을 이용해 부수 효과와 함께 state 를 갱신하게 해 주는 훅」으로 소개합니다. 모양은 reducer 와 닮았습니다.

const [state, dispatchAction, isPending] = useActionState(reducerAction, initialState);

reducerAction이전 state 와 Action 에 넘긴 값(form 이라면 FormData)을 받아 다음 state 를 돌려줍니다. 처음에는 initialState 를, 그다음부터는 직전에 돌려준 값을 받습니다. 처음 코드를 바꾸면 이렇습니다.

async function submitAction(prevState, formData) {
  const result = await submitQuote(formData);
  if (result.error) return { error: result.error };
  return { error: null, done: true };
}

function QuoteForm() {
  const [state, formAction, isPending] = useActionState(submitAction, { error: null });
  return (
    <form action={formAction}>
      {/* 입력칸들 */}
      <button disabled={isPending}>요청</button>
      {state.error && <p>{state.error}</p>}
    </form>
  );
}

isSubmitting state 와 그것을 켜고 끄는 두 줄이 사라졌습니다. isPending 은 React 가 Action 의 시작과 끝에 맞춰 알려 주므로, 오류 경로에서 끄는 것을 빠뜨릴 수 없습니다. e.preventDefault() 도 필요 없습니다. <form action> 에 함수를 넘기면 React 가 제출을 가로채 Action 으로 실행합니다.

오류는 던지지 않고 state 로 돌려주는 모양이 됩니다. 폼에서 흔한 실패(입력 검증 실패, 중복된 요청)는 예외가 아니라 결과의 한 종류라서 이쪽이 자연스럽다고 봅니다.

submitQuote 가 서버 함수라면 서버 컴포넌트와 함께 쓸 때 추가로 얻는 것이 있습니다. 문서에 따르면 서버 함수와 함께 쓰면 하이드레이션이 끝나기 전에도 서버의 응답을 보여 줄 수 있고, 선택 인자 permalink 로 자바스크립트가 로드되기 전에도 폼이 동작하게 할 수 있습니다. 문서는 이 부분을 보통 프레임워크가 처리해 준다고 적습니다.

useOptimistic: 응답 전에 결과를 먼저 보여 주기

「좋아요」 버튼을 누르면 서버 응답이 올 때까지 기다렸다가 하트를 채우는 것보다, 누르는 순간 채우고 실패하면 되돌리는 편이 반응이 빠르게 느껴집니다. 이것을 낙관적 업데이트라고 부릅니다.

useOptimistic 문서의 설명에서 핵심은 반환값의 정의입니다.

optimisticState: The current optimistic state. It is equal to value unless an Action is pending, in which case it is equal to the state returned by reducer (or the value passed to the set function if no reducer was provided).

낙관적 state 는 Action 이 진행 중일 때만 우리가 넣은 값이 되고, Action 이 끝나면 원래 값(value)으로 돌아갑니다.

function LikeButton({ isLiked, toggleLikeAction }) {
  const [optimisticLiked, setOptimisticLiked] = useOptimistic(isLiked);

  function handleClick() {
    startTransition(async () => {
      setOptimisticLiked(!optimisticLiked);
      await toggleLikeAction();
    });
  }

  return <button onClick={handleClick}>{optimisticLiked ? "♥" : "♡"}</button>;
}

누르는 순간 하트가 바뀝니다. 요청이 성공해 부모가 isLiked 를 새 값으로 갱신하면, Action 이 끝난 뒤에도 새 값이 보입니다. 요청이 실패해 부모의 isLiked 가 그대로라면, Action 이 끝나는 순간 자동으로 원래 하트로 돌아갑니다. 실패했을 때 되돌리는 코드를 따로 쓰지 않는다는 점이 이 훅의 설계라고 봅니다.

Action 밖에서 부르면 바로 되돌아간다

문서의 문제 해결 절에는 흔한 실수가 하나 적혀 있습니다. set 함수를 Action 밖에서 부르면 낙관적 state 가 잠깐 나타났다가 곧바로 원래 값으로 돌아간다는 것입니다. Action(transition)이 진행 중이어야 그 값을 「붙잡아 둘」 수 있는데, Action 밖에서는 붙잡을 것이 없기 때문입니다.

앞의 예에서 setOptimisticLikedstartTransition 안에서 부른 이유입니다. form 의 action 이나 useActionState 가 만든 함수 안에서 부르는 것도 Action 안이므로 같은 방식으로 동작합니다.

두 훅을 고르는 기준

알고 싶은 것 쓰는 훅
요청이 진행 중인가 useActionStateisPending (또는 useTransition)
요청의 결과(성공, 오류 메시지)를 화면에 보여 줄 것인가 useActionStatestate
응답 전에 결과를 먼저 보여 주고 실패하면 되돌릴 것인가 useOptimistic

셋 다 「요청의 진행 상황을 우리가 state 로 직접 켜고 끄지 않는다」는 같은 방향입니다. 직접 관리하던 state 가 줄어든 만큼, 진행 상태가 실제와 어긋날 곳도 줄어듭니다.

여기까지가 확실한 부분

useActionState 의 인자와 반환값, reducerAction 이 이전 state 를 받는 방식, permalink 와 하이드레이션 전 응답 표시, useOptimistic 의 반환값 정의와 Action 밖에서 부를 때의 동작은 react.dev 문서 기준입니다. 서버 함수와 결합했을 때의 동작은 프레임워크가 처리하는 부분이 있어서, 쓰는 프레임워크의 문서를 함께 확인해야 합니다.

이 게시글 공유하기

마지막 수정:

공유하실 때는 출처(Duolabs)와 원문 주소를 표시해 주세요.