🔥 React Query와 Form

1303자
15분

주의사항

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


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

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

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

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

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

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

간단한 접근 방식

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

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

예제를 살펴봅시다:

javascript
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
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 '로딩 중...'
}
 

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

데이터가 undefined일 수 있습니다

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

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

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

javascript
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
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>
  )
}
 

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

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

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

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

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

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

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

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

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

javascript
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
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 '로딩 중...'
}
 

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

제어 필드가 필요합니다

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

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

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

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


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

팁과 요령

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

이중 제출 방지

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

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

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

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

javascript
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
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 '로딩 중...'
}
 

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

YouTube 영상

채널 보기
AI에서 고차원 임베딩이 무엇일까?
AI를 위한 선형대수, 데이터는 점구름이다
AI에서 선형대수학이 중요한 이유
AI는 데이터를 어떻게 분류할까? 벡터의 거리와 KNN 알고리즘 | 선형대수학
벡터의 정의와 덧셈 연산 | 선형대수학
트라이(Trie) 자료구조: 파이썬으로 삽입(Insert) 연산 구현하기 | Trie 자료구조 이야기
Trie 자료구조 파이썬 구현: Search와 Starts With 연산 | Trie 자료구조 이야기
숫자 하나가 AI 모델의 운명을 바꾼다? | 선형대수학