리액트 생태계에서 typescript
, formik
과 react-query
는 아주 대중적인 라이브러리들입니다. formik
은 공식문서에 등장할 정도로 대중적이고 인정받은 폼 처리 라이브러리이고, react-query
는 server state
라는 패러다임을 제시하면서 server state manager 의 대표주자로 굳건히 자리를 잡았습니다. typescript
야 뭐 더 말할 것도 없이 자바스크립트 생태계를 모두 먹어가는 중입니다.
formik | react-query | |
---|---|---|
npm trends |
오늘은 이 두 라이브러리를 통해 서버에서 받은 정보를 form 으로 처리하는 저만의 방법을 소개하려 합니다.
편의를 위해 "내 프로필 수정 페이지" 를 가정하겠습니다. 프로필 정보에는 이름
, 성별
, 나이
, 웹사이트
이렇게 네 가지 항목이 있다고 하겠습니다. 그리고 그 중 "이름"은 바꾸지 못한다고 가정할게요.
편의상 import
문은 생략하겠습니다 XD
아래 내용들을 신경써서 만들었습니다.
그럼 이제 코드를 하나하나 살펴보겠습니다 :)
react-query 는 서버 상태에 대한 로직을 아예 분리하여 선언적으로 관리하는 것을 권장합니다. 따라서 저는 이를 파일로 분리하여, 아래와 같은 컴포넌트 파일 구조를 선호합니다.
MyProfile/
|- MyProfile.tsx # 컴포넌트 파일
|- MyProfile.types.ts # 복잡한 타입의 경우 이 파일로 분리
|- MyProfile.queries.ts # server 와 소통하는 파일
|- MyProfile.module.scss # 스타일 파일, CSS-in-JS 라면 MyProfile.styles.ts 등등
MyProfile.types.ts
이 파일에는 필요한 타입들을 정의합니다. 리스폰스 타입과 폼 타입을 분리했는데, 이렇게 두 타입을 분리하면 서버 리스폰스 타입이 변경될 때 대응하기 편하다는 장점도 있고, 폼의 타입을 더 엄밀하게 지정할 수 있습니다.
// Profile.types.ts
interface MyProfile {
name: string; // 이름
gender: Gender; // 성별 (어딘가 전역적으로 정의되어 있고 import한 타입)
age: number; // 나이
website: string; // 웹사이트
}
// 서버에서 넘어오는 data type
type MyProfileResponseDto = MyProfile;
// 수정할 때 서버에 넘겨주는 data type
type PatchMyProfileRequestDto = Partial<Omit<MyProfile, 'name'>>;
// Formik 에 이용할 폼 타입
type MyProfileForm = Partial<Omit<MyProfile, 'name'>>;
MyProfile.queries.ts
이 파일에는 서버와 소통하기 위한 함수들 + react-query
로직을 분리하기 위한 custom hook 이 정리되어 있습니다. 서버 데이터에 대한 data transformation 때문에 select
옵션을 써야 한다거나, id값에 따라 enabled
옵션을 결정하는 등등 많은 내용을 이 파일에서 처리할 수 있습니다. 실제로 이런 로직은 server state manager 의 책임인 것이 좋기 때문에 tkdodo님 권장사항 처럼 파일 자체를 분리해 버렸습니다.
아래 usePatchMyProfile
의 케이스처럼, 성공 시 해당 데이터를 다시 fetch 하는 로직은 server state manager 의 책임이라고 보면 깔끔해집니다.
// Profile.queries.ts
// 정보 받아오기
const getProfile = () => axios.get<MyProfileResponseDto>('/api/me');
export const useMyProfile = () => useQuery(['myProfile'], getProfile);
// 정보 수정하기
const patchProfile = (data: PatchMyProfileRequestDto) => axios.patch('/api/me', data);
export const usePatchMyProfile = () => {
const queryClient = useQueryClient();
return useMutation(patchProfile, {
onSuccess: () => queryClient.invalidateQueries(['myProfile']), // 성공 시 refetch
})
};
MyProfile.module.scss
넘어가겠습니다 :D
MyProfile.tsx
먼저 일반적인 formik
의 사용방식대로면 서버에서 받은 값을 formik
의 initialValue 로 넘겨줘야 합니다. 하지만 react-query 와 함께 사용하고 있기 때문에 이런 방식의 처리는 local copy 를 만들어냅니다. 즉 불필요한 중복되는 state가 생기는 거죠. 저는 state를 명확하게 정의하고자 했습니다.
export const MyProfile = () => {
const { data } = useMyProfile();
const { mutate } = usePatchMyProfile();
const { values, handleSubmit, handleChange } = useFormik<ProfileForm>({
initialValues: {},
onSubmit: mutate,
});
return (
<form onSubmit={handleSubmit}>
<label>
이름
<input value={data.name} disabled />
</label>
<label>
성별
<select value={values.gender ?? data.gender ?? ''} name="gender" onChange={handleChange}>
<option value={Gender.MALE}>남</option>
<option value={Gender.FEMALE}>여</option>
</select>
</label>
<label>
나이
<input type="number" value={values.age ?? data.age ?? ''} name="age" onChange={handleChange} />
</label>
<label>
웹사이트
<input value={values.website ?? data.website ?? ''} name="website" onChange={handleChange} />
</label>
<button type="submit">저장</button>
</form>
);
}
코드에서 볼 수 있다시피, formik 의 초기값을 과감하게 비워줬고, 각 인풋의 value 에 react-query 가 fetch 한 데이터를 보여주도록 구현했습니다. 즉 포믹에는 "수정된 값"만 저장하고, 수정되지 않은 값은 server state 의 선언적인 판단에 맡기겠다는 철학입니다. 이걸 위해서 ProfileForm
타입을 Partial
로 처리했습니다.