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
이기 때문에 useQuery
와 useForm
의 defaultValues
기능을 하나의 같은 컴포넌트에서 수행할 수 없다. (아닌데? 라고 생각할 수 있는데, 이 글이 쓰인 당시는 Suspense
가 정식 기능이 아니었다)
const { data } = useQuery({
queryKey: ['person', id],
queryFn: () => fetchPerson(id),
})
// 🚨 Form의 input들이 undefined로 초기화 되어버린다!
const { register, handleSubmit } = useForm({ defaultValues: data })
꼭 useForm
이 아니고 useState
에 query
를 초기값으로 설정할 때도 같은 문제가 발생한다.
해결 방법은 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이어야 한다
- react-hook-form의 api를 사용하면 uncontrolled input으로도 구현할 수 있다.
- 상태 도출이 어려울 수 있다
- 얕은 상태의 경우 서버 상태로 쉽게 돌아갈 수 있지만, 상태가 중첩된 객체인경우
??
연산자로 병합하기 어려울 수 있다. - 백그라운드에서 폼 값만 변경하는 것이 과연 UX가 좋은 것인가? 라고 의문을 가질 수 있다.
- 더 좋은 아이디어는 서버 상태와 동기화되지 않은 값을 강조 표시하고 사용자가 수행할 작업을 결정하도록 하는 것이다
(마지막 거는 이해 못 함. 그냥 해석한 거)
4. 추가 팁과 트릭
React Query와 폼을 함께 다루는 방법 두 가지를 알아보았다.
- 서버 상태를 초기 데이터로만 설정
- 서버 상태의 백그라운드 업데이트를 유지하기
이 두 가지 주요 방법 외에도, 폼과 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...'
}
폼 제출 이후 최신 서버 상태를 보여주기 위해서
- 캐시 무효화를 하여 서버 상태를 동기화하고
- 클라이언트 상태를 다시
undefined
로 초기화해 주는 것이 핵심이다.
Reference
https://tkdodo.eu/blog/react-query-and-forms
'React Query' 카테고리의 다른 글
6. React Query가 필요한 이유 (0) | 2024.07.21 |
---|---|
5. React Query의 Status (0) | 2024.02.21 |
4. 효율적으로 React Query Key 선언하기 (5) | 2024.02.14 |
3. React Query와 에러핸들링 (2) | 2024.02.08 |
1. 실용적인 React Query (0) | 2024.01.10 |