🔥 React Query와 Form

1303자
15분

주의사항

이 글에서는 react-hook-form을 사용할 것입니다. 이 라이브러리가 훌륭하다고 생각하기 때문입니다. 하지만 여기서 소개하는 패턴들은 react-hook-form에만 국한되지 않습니다. 다른 폼 라이브러리나 폼 라이브러리를 사용하지 않는 경우에도 적용할 수 있는 개념들입니다.


폼은 많은 웹 애플리케이션에서 데이터를 갱신하는 주요 수단으로 중요한 역할을 합니다. React Query를 사용하면 데이터를 가져오는 것(쿼리)뿐만 아니라 수정(뮤테이션)도 할 수 있습니다. 따라서 우리가 애용하는 비동기 상태 관리자를 폼과 어떻게 통합할지 고민해야 합니다.

다행히도 폼에는 특별한 점이 없습니다. 결국 데이터를 표시하기 위해 렌더링하는 HTML 요소들의 모음일 뿐입니다. 하지만 데이터를 _변경_하고 싶을 때는 서버 상태와 클라이언트 상태의 경계가 모호해지면서 복잡성이 증가할 수 있습니다.

서버 상태 vs 클라이언트 상태

다시 한번 정리해보겠습니다. _서버 상태_는 우리가 소유하지 않고, 대부분 비동기적이며, 마지막으로 가져온 시점의 데이터 스냅샷만 볼 수 있는 상태입니다.

_클라이언트 상태_는 프론트엔드가 완전히 제어할 수 있고, 대부분 동기적이며, 언제나 정확한 값을 알 수 있는 상태입니다.

사람 목록을 표시할 때, 이는 분명히 서버 상태입니다. 하지만 한 사람을 클릭해서 세부 정보를 폼에 표시하고 일부 값을 수정하려고 할 때는 어떨까요? 이 서버 상태가 클라이언트 상태로 바뀌는 걸까요? 아니면 혼합된 상태일까요?

간단한 접근 방식

저는 이미 한 상태 관리자에서 다른 상태 관리자로 상태를 복사하는 것을 좋아하지 않는다고 말한 적이 있습니다. 속성을 상태로 옮기는 것이나 React Query에서 로컬 상태로 복사하는 것 모두 마찬가지입니다.

하지만 폼의 경우에는 예외가 될 수 있다고 생각합니다. 의도적으로 하고 트레이드오프를 알고 있다면 말이죠 (모든 것은 결국 트레이드오프니까요). 사람 폼을 렌더링할 때, 우리는 서버 상태를 초기 데이터로만 취급하고 싶을 겁니다. 이름과 성을 가져와서 폼 상태에 넣고, 사용자가 수정할 수 있게 하는 거죠.

예제를 살펴봅시다:

function PersonDetail({ id }) {
  const { data } = useQuery({
    queryKey: ['person', id],
    queryFn: () => fetchPerson(id),
  })
  const { register, handleSubmit } = useForm()
  const { mutate } = useMutation({
    mutationFn: (values) => updatePerson(values),
  })
 
  if (data) {
    return (
      <form onSubmit={handleSubmit(mutate)}>
        <div>
          <label htmlFor="firstName">이름</label>
          <input
            {...register('firstName')}
            defaultValue={data.firstName}
          />
        </div>
        <div>
          <label htmlFor="lastName"></label>
          <input
            {...register('lastName')}
            defaultValue={data.lastName}
          />
        </div>
        <input type="submit" />
      </form>
    )
  }
 
  return '로딩 중...'
}
 
javascript

이 방식은 매우 잘 작동합니다. 그렇다면 트레이드오프는 무엇일까요?

데이터가 undefined일 수 있습니다

useForm이 전체 폼에 대한 기본값을 직접 받을 수 있다는 걸 알고 계실 겁니다. 큰 폼에서는 이 방식이 꽤 유용하죠. 하지만 훅을 조건부로 호출할 수 없고, data가 첫 번째 렌더 사이클에서는 undefined이기 때문에 (먼저 가져와야 하니까요), 같은 컴포넌트에서 이렇게 할 수 없습니다:

const { data } = useQuery({
  queryKey: ['person', id],
  queryFn: () => fetchPerson(id),
})
// 🚨 이렇게 하면 폼이 undefined로 초기화됩니다
const { register, handleSubmit } = useForm({ defaultValues: data })
 
javascript

useState로 복사하거나 비제어 폼을 사용할 때도 (react-hook-form이 내부적으로 사용하는 방식입니다) 같은 문제가 발생합니다. 이 문제를 해결하는 가장 좋은 방법은 폼을 별도의 컴포넌트로 분리하는 것입니다:

function PersonDetail({ id }) {
  const { data } = useQuery({
    queryKey: ['person', id],
    queryFn: () => fetchPerson(id),
  })
  const { mutate } = useMutation({
    mutationFn: (values) => updatePerson(values),
  })
 
  if (data) {
    return <PersonForm person={data} onSubmit={mutate} />
  }
 
  return '로딩 중...'
}
 
function PersonForm({ person, onSubmit }) {
  const { register, handleSubmit } = useForm({ defaultValues: person })
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label htmlFor="firstName">이름</label>
        <input {...register('firstName')} />
      </div>
      <div>
        <label htmlFor="lastName"></label>
        <input {...register('lastName')} />
      </div>
      <input type="submit" />
    </form>
  )
}
 
javascript

이 방식은 나쁘지 않습니다. 데이터 가져오기와 표시를 분리하니까요. 개인적으로는 이런 분리를 좋아하지 않지만, 여기서는 잘 작동합니다.

백그라운드 업데이트가 없습니다

React Query의 장점은 서버 상태와 UI를 최신 상태로 유지하는 것입니다. 하지만 그 상태를 다른 곳으로 복사하면 React Query가 제 역할을 할 수 없게 됩니다. 어떤 이유로 백그라운드에서 다시 가져오기가 발생하고 새 데이터가 생겨도, 우리의 폼 상태는 업데이트되지 않습니다. 우리만 그 폼 상태를 다루고 있다면 (예를 들어 프로필 페이지 폼) 이는 문제가 되지 않을 겁니다. 이런 경우에는 쿼리에 더 긴 staleTime을 설정해서 백그라운드 업데이트를 비활성화하는 것이 좋습니다. 어차피 화면에 반영되지 않을 업데이트를 서버에 계속 요청할 이유가 없으니까요.

// ✅ 백그라운드 업데이트 비활성화
const { data } = useQuery({
  queryKey: ['person', id],
  queryFn: () => fetchPerson(id),
  staleTime: Infinity,
})
 
javascript

이 접근 방식은 큰 폼이나 협업 환경에서 문제가 될 수 있습니다. 폼이 클수록 사용자가 작성하는 데 시간이 오래 걸립니다. 여러 사람이 같은 폼의 다른 필드를 작업한다면, 마지막에 업데이트한 사람이 다른 사람들이 변경한 값을 덮어쓸 수 있습니다. 화면에 부분적으로 오래된 버전이 표시되고 있기 때문입니다.

react-hook-form을 사용하면 사용자가 변경한 필드를 감지하고 "더러운" 필드만 서버로 보내는 기능을 구현할 수 있습니다 (여기 예제를 참고하세요). 이는 꽤 멋진 기능입니다. 하지만 여전히 다른 사용자가 변경한 최신 값을 보여주지는 못합니다. 만약 어떤 필드가 그 사이에 다른 사람에 의해 변경됐다는 걸 알았다면 입력을 바꿨을 수도 있잖아요.

그렇다면 폼을 편집하는 동안에도 백그라운드 업데이트를 반영하려면 어떻게 해야 할까요?

백그라운드 업데이트 유지하기

한 가지 방법은 상태를 엄격하게 분리하는 것입니다. 서버 상태는 React Query에 유지하고, 클라이언트 상태로는 사용자가 변경한 내용만 추적합니다. 그리고 사용자에게 보여주는 실제 값은 이 두 가지에서 _파생된 상태_입니다. 사용자가 필드를 변경했다면 클라이언트 상태를 보여주고, 그렇지 않다면 서버 상태로 돌아갑니다:

function PersonDetail({ id }) {
  const { data } = useQuery({
    queryKey: ['person', id],
    queryFn: () => fetchPerson(id),
  })
  const { control, handleSubmit } = useForm()
  const { mutate } = useMutation({
    mutationFn: (values) => updatePerson(values),
  })
 
  if (data) {
    return (
      <form onSubmit={handleSubmit(mutate)}>
        <div>
          <label htmlFor="firstName">이름</label>
          <Controller
            name="firstName"
            control={control}
            render={({ field }) => (
              // ✅ 필드 값(클라이언트 상태)과
              // data(서버 상태)에서 상태를 파생시킵니다
              <input
                {...field}
                value={field.value ?? data.firstName}
              />
            )}
          />
        </div>
        <div>
          <label htmlFor="lastName"></label>
          <Controller
            name="lastName"
            control={control}
            render={({ field }) => (
              <input
                {...field}
                value={field.value ?? data.lastName}
              />
            )}
          />
        </div>
        <input type="submit" />
      </form>
    )
  }
 
  return '로딩 중...'
}
 
javascript

이 방식을 사용하면 백그라운드 업데이트를 유지할 수 있습니다. 변경되지 않은 필드에는 여전히 의미가 있기 때문입니다. 폼을 처음 렌더링할 때의 초기 상태에 얽매이지 않게 됩니다. 하지만 이 방식에도 주의할 점이 있습니다:

제어 필드가 필요합니다

제가 아는 한, 비제어 필드로는 이를 구현할 좋은 방법이 없습니다. 그래서 위 예제에서 제어 필드를 사용했습니다. 제가 놓친 부분이 있다면 알려주세요.

업데이트: React Hook Form에 새로운 values API가 생겼습니다. 이 API는 변경 사항에 반응하고 폼 값을 업데이트합니다. defaultValues 대신 이를 사용해 서버 상태에서 상태를 파생시킬 수 있습니다.

상태 파생이 어려울 수 있습니다

이 방식은 얕은 폼에서 가장 잘 작동합니다. Nullish 병합 연산자를 사용해 쉽게 서버 상태로 돌아갈 수 있기 때문입니다. 하지만 중첩된 객체에서는 제대로 병합하기가 더 어려울 수 있습니다. 또한 백그라운드에서 폼 값을 그냥 변경하는 것이 사용자 경험 측면에서 좋지 않을 수 있습니다. 서버 상태와 동기화되지 않은 값을 강조 표시하고 사용자가 직접 결정하도록 하는 것이 더 나은 방법일 수 있습니다.


어떤 방식을 선택하든, 각 접근 방식의 장단점을 잘 알고 있어야 합니다.

팁과 요령

폼을 설정하는 두 가지 주요 방식 외에도, React Query를 폼과 통합할 때 알아두면 좋은 작은 팁들이 있습니다:

이중 제출 방지

폼이 두 번 제출되는 것을 방지하려면 useMutation에서 반환되는 isLoading 속성을 사용할 수 있습니다. 이 속성은 뮤테이션이 진행되는 동안 true 값을 가집니다. 폼 자체를 비활성화하려면 주요 제출 버튼만 비활성화하면 됩니다:

const { mutate, isLoading } = useMutation({
  mutationFn: (values) => updatePerson(values)
})
<input type="submit" disabled={isLoading} />
 
javascript

뮤테이션 후 무효화 및 초기화

폼 제출 직후 다른 페이지로 이동하지 않는다면, 무효화가 완료된 후에 폼을 초기화하는 것이 좋을 수 있습니다. Mastering Mutations에서 설명한 대로, mutateonSuccess 콜백에서 이 작업을 수행하는 것이 좋습니다. 이 방법은 상태를 분리해 두었을 때 가장 잘 작동합니다. 서버 상태가 다시 적용되도록 undefined로만 초기화하면 되기 때문입니다:

function PersonDetail({ id }) {
  const queryClient = useQueryClient()
  const { data } = useQuery({
    queryKey: ['person', id],
    queryFn: () => fetchPerson(id),
  })
  const { control, handleSubmit, reset } = useForm()
  const { mutate } = useMutation({
    mutationFn: updatePerson,
    // ✅ 무효화에서 Promise를 반환하여
    // 대기할 수 있도록 합니다
    onSuccess: () =>
      queryClient.invalidateQueries({ queryKey: ['person', id] }),
  })
 
  if (data) {
    return (
      <form
        onSubmit={handleSubmit((values) =>
          // ✅ 클라이언트 상태를 undefined로 초기화합니다
          mutate(values, { onSuccess: () => reset() })
        )}
      >
        <div>
          <label htmlFor="firstName">이름</label>
          <Controller
            name="firstName"
            control={control}
            render={({ field }) => (
              <input
                {...field}
                value={field.value ?? data.firstName}
              />
            )}
          />
        </div>
        <input type="submit" />
      </form>
    )
  }
 
  return '로딩 중...'
}
 
javascript

이렇게 하면 React Query와 폼을 효과적으로 통합하여 사용할 수 있습니다. 서버 상태와 클라이언트 상태를 적절히 관리하면서 사용자 경험을 향상시킬 수 있습니다. 각 접근 방식의 장단점을 고려하여 프로젝트에 가장 적합한 방식을 선택하세요.