React Query

2. Form에서 React Query 잘 활용하기

yoxxin 2024. 1. 22. 11:34

1. 들어가기

Form은 회원가입, 개인정보수정 등 데이터를 업데이트해야 하는 상황에서 자주 쓰인다.

데이터를 가져오는 것(Query), 수정하는 것(Mutation)를 편하게 하기 위해 React Query를 사용한다면, Form과의 통합은 피할 수 없다.

데이터를 변경하고 싶을 때 서버 상태와 클라이언트 상태의 경계가 약간 모호해지기 시작하고, 여기서 복잡하다고 느낄 수 있다.

2. 서버 상태와 클라이언트 상태 비교

서버 상태는 클라이언트가 소유하지 않은 상태로, 비동기 스냅샷 데이터이다.

여기서 비동기는 서버와 통신을 해야 한다는 의미이고, 스냅샷은 서버로부터 응답을 받은 그 시점의 데이터를 의미한다.

클라이언트 상태는 프론트엔드가 모든 권한을 가지고 있고, 동기 정확 데이터이다.

useState를 호출해서 받은 상태는 프론트엔드에서 제어할 수 있고, 또 프론트엔드에 위치하기 때문에 항상 정확한 데이터이다.

어떤 유저의 정보를 표시할 때 사용하는 데이터는 당연 서버 상태일 것이다.

하지만 유저 정보 수정 페이지에 들어갔을 때 각 항목(이름, 거주지 등) input에 들어가 있는 상태는 어떻게 정의할 수 있을까?

서버 상태가 이제 클라이언트 상태가 되는 걸까?

아니면 중첩된 상태로 봐야 할까?

Form에서의 React Query 활용 방법을 알아보자.

3. Form에서 React Query 활용하는 법

3.1. 간단한 접근방식: 서버 상태를 초기 데이터로만 설정할 때

지난 글에서도 언급했듯이 props로 받은 값을 state로 설정하거나, React Query로 받은 값을 state로 설정하는 걸 좋아하지 않는다.

하지만 장단점을 알고 의도적으로 사용한다면 괜찮을 수 있다.

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">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...'
}

위 예시는 서버상태를 input의 defaultValue 으로만 사용하고 있고 잘 동작한다.

단점은 무엇일까?

단점 1: Data(query)가 undefined일 때 파생되는 문제

react-hook-form의 useForm은 전체 폼에 대해 defaultValues 를 설정할 수 있다.

다만 조건부로 훅을 호출할 수 없고 첫 번째 렌더링 때 query가 undefined 이기 때문에 useQueryuseFormdefaultValues 기능을 하나의 같은 컴포넌트에서 수행할 수 없다. (아닌데? 라고 생각할 수 있는데, 이 글이 쓰인 당시는 Suspense가 정식 기능이 아니었다)

const { data } = useQuery({
  queryKey: ['person', id],
  queryFn: () => fetchPerson(id),
})
// 🚨 Form의 input들이 undefined로 초기화 되어버린다!
const { register, handleSubmit } = useForm({ defaultValues: data })

useForm이 아니고 useStatequery를 초기값으로 설정할 때도 같은 문제가 발생한다.

해결 방법은 Form 컴포넌트를 나누는 것이다.

function PersonDetail({ id }) {
  const { data } = useQuery({
	queryKey: ['person', id],
	queryFn: () => fetchPerson(id),
  })
  const { mutate } = useMutation({
	mutationFn: (values) => updatePerson(values),
  })
	
	// data가 있을때만(query에 값이 들어갔을때만) PersonForm을 호출한다.
  if (data) {
	return <PersonForm person={data} onSubmit={mutate} />
  }

  return 'loading...'
}

// 즉 "person" query가 undefined이 될 수 없다.
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>
  )
}

조건부로 query의 응답 data가 있을 때만 PersonForm 를 호출하기 때문에 defaultValues 를 안전하게 사용할 수 있다.

+

다만 React18의 Suspense 를 이용하면 굳이 나누지 않아도 되고, 더불어 react-query v5의 useSuspenseQuery 를 이용하면 타입도 문제없을 것 같다.

단점 2: 백그라운드 업데이트가 없다

서버 상태는 항상 최신 상태를 유지해야 한다.

즉 유저에게 항상 정확한 데이터를 보여주어야 한다.

그래서 React Query는 background refetch를 이용해 데이터를 서버에 재요청한다.

 

그런데 위 코드 예시들에서 어떤 이유로든 폼 초기값 서버상태가 갱신되어도 클라이언트의 폼 상태는 업데이트되지 않는다는 문제가 있다(최신 상태가 아니다)

 

만약 유저 정보 수정 페이지처럼 해당 폼에서 작업하는 사람이 한 명인 경우에는 문제가 되지 않을 수 있다.

그렇다면 최소한 staleTime(서버 상태를 fresh 로 간주할 시간)을 높게 설정하여 불필요한 백그라운드 업데이트를 비활성화하자.

UI에 반영되지 않는 서버상태를 굳이 계속 재요청할 필요가 없기 때문이다.

const { data } = useQuery({
  queryKey: ['person', id],
  queryFn: () => fetchPerson(id),
  staleTime: Infinity, // 재요청을 하지 않는다.
})

다만 이 방법은 협업 환경에서는 문제가 될 수 있다.

어떤 사람이 동일한 폼을 수정한다면, 그 순간 같이 폼을 보고 있던 다른 사람들은 오래된 데이터를 보고 있고, 재정의 할 수도 있다.

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

3.2 폼에서 서버 상태의 백그라운드 업데이트 유지하기

폼 value를 변경할 때 이용하는 클라이언트 상태와 서버 상태의 업데이트도 유지하려면 두 상태를 확실히 분리해야 한다.

유저가 value를 변경한 경우 클라이언트 상태를 표시하고, 아니면 서버 상태를 표시하는 식으로 사용자에게 보여줄 수 있다.

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">First Name</label>
		  <Controller
			name="firstName"
			control={control}
			render={({ field }) => (
			  // input 필드 값 (클라이언트 상태)
			  // 또는 쿼리 (서버 상태)
			  <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...'
}

이렇게 하면 손대지 않은 input value의 백그라운드 업데이트를 유지할 수 있다.

다만 유의할 점 몇 가지가 있다.

  • controlled input이어야 한다
  • 상태 도출이 어려울 수 있다
    • 얕은 상태의 경우 서버 상태로 쉽게 돌아갈 수 있지만, 상태가 중첩된 객체인경우 ?? 연산자로 병합하기 어려울 수 있다.
  • 백그라운드에서 폼 값만 변경하는 것이 과연 UX가 좋은 것인가? 라고 의문을 가질 수 있다.
    • 더 좋은 아이디어는 서버 상태와 동기화되지 않은 값을 강조 표시하고 사용자가 수행할 작업을 결정하도록 하는 것이다

(마지막 거는 이해 못 함. 그냥 해석한 거)

4. 추가 팁과 트릭

React Query와 폼을 함께 다루는 방법 두 가지를 알아보았다.

  1. 서버 상태를 초기 데이터로만 설정
  2. 서버 상태의 백그라운드 업데이트를 유지하기

이 두 가지 주요 방법 외에도, 폼과 React Query를 통합할 때 중요한 몇 가지 사항을 알아보자.

4.1. 중복 제출 방지

폼이 두 번 이상 제출되는 것을 방지하려면, useMutation이 반환하는 isLoading 프로퍼티를 활용하자.

폼을 비활성화하려면 제출 버튼을 비활성화하기만 하면 된다.

const { mutate, isLoading } = useMutation({
  mutationFn: (values) => updatePerson(values)
})
<input type="submit" disabled={isLoading} /> // mutation 상태에 따라 활성여부 결정

4.2. mutation 이후 쿼리 무효화로 상태 동기화하기

폼 제출 이후에 다른 페이지로 리다이렉트 하지 않는다면, 폼 input 상태 값을 동기화해 주자.

useMutation의 onSuccess 콜백, mutate의 onSuccess 콜백에서 이 작업을 하면 된다.(두번째 방식(3.2) 기준)

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,
	// mutation이 성공하면 캐시 무효화하여 서버 상태 페칭
	onSuccess: () =>
	  queryClient.invalidateQueries({ queryKey: ['person', id] }),
  })

  if (data) {
	return (
	  <form
		onSubmit={handleSubmit((values) =>
		  // ✅ 클라이언트 상태를 undefined로 초기화(reset)하여 UI에서 서버 상태 보여주기
		  mutate(values, { onSuccess: () => reset() })
		)}
	  >
		<div>
		  <label htmlFor="firstName">First Name</label>
		  <Controller
			name="firstName"
			control={control}
			render={({ field }) => (
			  <input
				{...field}
				value={field.value ?? data.firstName}
			  />
			)}
		  />
		</div>
		<input type="submit" />
	  </form>
	)
  }

  return 'loading...'
}

폼 제출 이후 최신 서버 상태를 보여주기 위해서

  1. 캐시 무효화를 하여 서버 상태를 동기화하고
  2. 클라이언트 상태를 다시 undefined로 초기화해 주는 것이 핵심이다.

Reference

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