본문 바로가기

FrontEnd

리액트 쿼리 : 폼

반응형

원문 : https://tkdodo.eu/blog/react-query-and-forms

form은 서버 상태와 클라이언트 상태의 경계가 모호지는 영역입니다.

해당 게시물에선 react-hook-form 과 React Query를 함께 사용하는 방법을 알아봅니다.


Server State vs. Client State

서버 상태

  • 대부분 비동기
  • 대부분 스냅샷에만 접근 가능
  • 대부분 우리가 소유하지 않은 상태

클라이언트 상태

  • 완전히 제어할 수 있음
  • 대부분 동기적
  • 항상 정확한 값을 알 고 있음

간단한 접근 방법

저는 상태를 props에 넣거나, 다른 상태관리 도구로 상태를 복사하는 방법을 좋아하지 않습니다만,

form은 예외가 될 수 있다고 생각합니다.

서버 상태를 초기값으로만 사용할 가능성이 높기 때문입니다.

function PersonDetail({ id }) {
  const { data } = useQuery(['person', id], () => fetchPerson(id))
  const { register, handleSubmit } = useForm()
  const { mutate } = useMutation((values) => updatePerson(values))

  if (data) {
    return (
      <form onSubmit={handleSubmit(mutate)}>
        <div>
          <label htmlFor="firstName">First Name</label>
          <input {...register('firstName')} defaultValue={data.firstName} />
        </div>
        <div>
          <label htmlFor="lastName">Last Name</label>
          <input {...register('lastName')} defaultValue={data.lastName} />
        </div>
        <input type="submit" />
      </form>
    )
  }

  return 'loading...'
}
이것은 매우 잘 작동합니다. 문제가 뭘까요?

데이터가 undefined 일 수 있습니다.

데이터는 첫번째 렌더링 주기에 undefined일 수 있습니다.

const { data } = useQuery(['person', id], () => fetchPerson(id))
// 🚨 this will initialize our form with undefined
const { register, handleSubmit } = useForm({ defaultValues: data })
useState에 복사하거나 제어되지 않는(uncontrolled) 폼을 사용할 때도 동일한 문제가 발생합니다.
(react-hook-form에 의해 상태가 제어되지 않는 폼)
이에 대한 가장 좋은 해결책은 폼을 자체 컴포넌트로 분할하는 것입니다.
function PersonDetail({ id }) {
  const { data } = useQuery(['person', id], () => fetchPerson(id))
  const { mutate } = useMutation((values) => updatePerson(values))

  if (data) {
    return <PersonForm person={data} onSubmit={mutate} />
  }

  return 'loading...'
}

function PersonForm({ person, onSubmit }) {
  const { register, handleSubmit } = useForm({ defaultValues: person })
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label htmlFor="firstName">First Name</label>
        <input {...register('firstName')} />
      </div>
      <div>
        <label htmlFor="lastName">Last Name</label>
        <input {...register('lastName')} />
      </div>
      <input type="submit" />
    </form>
  )
}

이것은 프레젠테이션 컴포넌트에서 서버에서 가져오는 데이터를 분리하기 때문에 그리 나쁘지 않습니다.

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

React Query의 목적은 UI를 서버와 동기화된 최신 상태로 유지하는 겁니다.
상태를 다른 곳으로 복사하면 React Query는 더 이상 동기화 작업을 수행할 수 없습니다.
백그라운드 리페치가 발생하고 새 데이터가 생성되어도 Form이 업데이트 되지 않을 것입니다.
 
우리가 해당 form 상태에서 작업하는 유일한 사람이라면 문제가 되지 않을 것입니다. (예: 나의 프로필 페이지의 form)
이 경우 높은 staleTime을 설정하여 백그라운드 업데이트를 비활성화 합니다.
// ✅ opt out of background updates
const { data } = useQuery(['person', id], () => fetchPerson(id), {
  staleTime: Infinity,
})​
 
이 접근 방식은 거대한 폼과 협업 환경에선, 문제가 될 수 있습니다.
폼이 클수록 사용자가 작성하는 데 더 오래 걸립니다.
 
여러 사람이 동일한 폼의 다른 필드에서 작업하는 경우 화면에 부분적으로 오래된 버전이 표시되기 때문에,
마지막으로 업데이트한 사람이 다른 사람이 변경한 값을 재정의할 수 있습니다.
form을 편집하는 동안 다른 사용자의 백그라운드 업데이트를 계속 반영하려면 어떻게 해야 할까요?

Keeping background updates on

한 가지 접근 방식은 상태를 엄격하게 분리하는 것입니다.
React Query에 서버 상태를 유지하고
클라이언트 상태는 변경 사항만 추적합니다.
우리가 사용자에게 표시하는 진실 원천은 두 가지 상태에서 파생됩니다.
  • 사용자가 필드를 변경한 경우 클라이언트 상태를 표시합니다.
  • 그렇지 않은 경우 서버 상태로 폴백합니다.
function PersonDetail({ id }) {
  const { data } = useQuery(['person', id], () => fetchPerson(id))
  const { control, handleSubmit } = useForm()
  const { mutate } = useMutation((values) => updatePerson(values))

  if (data) {
    return (
      <form onSubmit={handleSubmit(mutate)}>
        <div>
          <label htmlFor="firstName">First Name</label>
          <Controller
            name="firstName"
            control={control}
            render={({ field }) => (
              // ✅ derive state from field value (client state)
              // and data (server state)
              <input {...field} value={field.value ?? data.firstName} />
            )}
          />
        </div>
        <div>
          <label htmlFor="lastName">Last Name</label>
          <Controller
            name="lastName"
            control={control}
            render={({ field }) => (
              <input {...field} value={field.value ?? data.lastName} />
            )}
          />
        </div>
        <input type="submit" />
      </form>
    )
  }

  return 'loading...'
}​

이 방식을 사용하면 변경되지 않은 필드의 백그라운드 업데이트를 계속 유지할 수 있습니다.

항상 그렇듯이 여기에도 주의 사항이 있습니다.

통제된(controlled) 필드가 필요합니다

내가 아는 한 제어되지 않는 필드(폼 내부 상태가 없는 필드)를 사용하여 이를 달성하는 방법은 없습니다.

그래서 위의 예에서 제어된 필드를 사용했습니다. 놓치고 있는 부분이 있으면 알려주세요.


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

이 접근 방식은 nullish 병합을 사용하여 서버 상태로 쉽게 폴백할 수 있는 얕은 폼에 가장 적합하지만
중첩된 객체와 제대로 병합하기는 어려울 수 있습니다.
 
때로는 백그라운드에서 양식 값을 변경하는 것이 의심스러운 사용자 경험일 수도 있습니다.
더 나은 아이디어는 서버 상태와 동기화되지 않은 값을 강조 표시하고
사용자가 수행할 작업을 결정하도록 하는 것입니다.

어떤 방법을 선택하든 각 접근 방식이 가져오는 장점 / 단점을 인식하도록 노력하십시오.

Tips and Tricks

이중 서브밋 방지

양식이 두 번 제출되는 것을 방지하기 위해 useMutation에서 반환된 isLoading prop을 사용할 수 있습니다.
뮤테이션이 실행되는 동안 true이기 때문입니다.
form 자체를 비활성화하려면 submit 버튼을 비활성화하기만 하면 됩니다.
const { mutate, isLoading } = useMutation((values) => updatePerson(values))
<input type="submit" disabled={isLoading} />​

변이 후 캐시 무효화 및 리셋

폼 제출 직후 다른 페이지로 리다이렉트 하지 않는다면 무효화 완료 후 폼을 초기화 하는 것이 좋을 수 있습니다.
mutate의 onSuccess 콜백에서 그렇게 할 수 있습니다.
 
서버 상태를 분리된 상태로 유지하는 경우에도 reset으로 원래 필드를 undefined로 초기화하면 됩니다
function PersonDetail({ id }) {
  const queryClient = useQueryClient()
  const { data } = useQuery(['person', id], () => fetchPerson(id))
  const { control, handleSubmit, reset } = useForm()
  const { mutate } = useMutation(updatePerson, {
    // ✅ return Promise from invalidation
    // so that it will be awaited
    onSuccess: () => queryClient.invalidateQueries(['person', id]),
  })

  if (data) {
    return (
      <form
        onSubmit={handleSubmit((values) =>
          // ✅ rest client state back to undefined
          mutate(values, { onSuccess: () => reset() })
        )}
      >
        <div>
          <label htmlFor="firstName">First Name</label>
          <Controller
            name="firstName"
            control={control}
            render={({ field }) => (
              {/* 필드가 undefined이면 서버 값으로 초기화*/}
              <input {...field} value={field.value ?? data.firstName} />
            )}
          />
        </div>
        <input type="submit" />
      </form>
    )
  }

  return 'loading...'
}
반응형