React에서 Form을 다루다보면 생각보다 손이 많이 갈 수밖에 없다.
상태를 어떻게 관리할 지(controlled, uncontrolled)부터 시작해서.. 초기값 적용을 위한 transform 과정, 제출 직전 값을 body 구조에 맞게 변경하는 작업, 유효성 체크 등등등..
Form만 전문적으로 다루는 라이브러리들도 많이 나오고 있다.
이번에도 Form 상태를 관리하기 위해 무언가를 채택할까 했는데, Ant Design의 Form이 UI뿐만 아니라 꽤나 다양한 기능을 제공한다는 걸 듣고, 별도의 라이브러리를 설치하기보단 이를 적극 활용해보기로 했다.
유저 정보 수정 페이지를 개발하며 useForm
, rule
, Form.Item
등 내부 요소를 활용하고 커스텀 합성 컴포넌트로 래핑한 경험을 공유하려 한다.
function DetailForm() {
const { id } = useParams();
const { data: userData } = useGetUserQuery(Number(id));
/*
userData = {
name: "최원빈",
id: 43,
student_number: 2019136133,
...
*/
const [form] = Form.useForm();
return (
<div>
{userData && (
<Form form={form} initialValues={userData}>
<Form.Item name="id" label="유저 ID">
<Input />
</Form.Item>
<Form.Item name="name" label="유저명">
<Input />
</Form.Item>
</Form>
)}
</div>
)
}
데이터를 initialValues
나, fields
라는 프로퍼티에 담아주면 자동으로 name
에 해당하는 값에 매핑된다.
두 프로퍼티에 차이는, initialValues
는 uncontrolled에 가깝고(최초 마운트 이후 초기값으로만 의미를 가짐), fields
는 controlled에 가깝다.(fields의 변경을 감시한다)
이번 프로젝트의 경우, RTK-Query를 서버 상태 관리 라이브러리로 사용했는데, 다른 redux store가 갱신되면 userData
로 만든 entries의 참조가 새로 만들어져 fields가 변경됐다고 판단하고, 수정해둔 필드들이 전부 초기화되는 문제가 발생했다.
이러한 초기화가 필요한 구조라면 fields
를, 아니라면 initialValues
를 목적에 맞게 채택하면 될 것 같다.
useForm
이 반환하는 form을<Form>
에 연결하면,FormInstance
가 제공하는 다양한 메소드를 사용할 수 있다.
주로 Get, Set과 연관이 있고, 이후에form.getFieldValue(name)
메소드를 활용할 예정이다.
그런데 작성하다보면 Form.Item
을 활용하는 부분이 중복으로 작성되고, 공통으로 사용할 스타일링도 적용시킬 필요가 있어 이를 모아둔 래핑 컴포넌트를 만들어 쓰기로 했다.
import { Form, Input, InputProps } from 'antd';
import * as S from './CustomForm.style';
interface FormItemProps {
label: string;
name: string;
disabled?: boolean;
rules?: Rule[];
}
function CustomInput({
label, name, rules, disabled, ...args
}: FormItemProps & InputProps) {
return (
<S.FormItem label={label} name={name} rules={rules}>
<S.StyledInput disabled={disabled} {...args} />
</S.FormItem>
);
}
const CustomForm = Object.assign(Form, {
Button: CustomButton,
Input: CustomInput,
TextArea: CusctomTextArea,
Upload: CustomUpload,
Checkbox: CustomCheckbox,
Select: CustomSelect,
});
export default CustomForm;
이렇게 합성 컴포넌트로 모아두면, 사용하는 쪽에서 Import할 파일의 수가 줄고, Form.Item
로 매번 감쌀 필요가 사라져 코드를 간소화할 수 있다.
거기에 통일된 스타일링을 유지할 수 있는 것은 덤.
function DetailForm() {
const { id } = useParams();
const { data: userData } = useGetUserQuery(Number(id));
const [form] = CustomForm.useForm();
return (
<div>
{userData && (
<CustomForm form={form} initialValues={userData}>
<CustomForm.Input name="id" label="유저 ID"/>
<CustomForm.Input name="name" label="유저명"/>
</CustomForm>
)}
</div>
)
}
벌써부터 코드 구조가 상당히 깔끔해진 것이 마음에 든다.
<Form />
의 onFinish
props에 제출 함수를 담아주면 submit을 갖는 버튼과 연결된다.
컴포넌트 내부에서의 선언적인 코드를 유지하기 위해 mutation에 해당하는 로직을 분리한 useUserMutation
훅을 만들어 분리했다.
export default function useUserMutation() {
const [updateUserRequest] = useUpdateUserMutation();
const navigate = useNavigate();
const updateUser = (formData: UserDetail) => {
if (formData) {
updateUserRequest(formData)
.unwrap()
.then(() => {
makeToast('success', '정보 수정이 완료되었습니다.');
navigate(-1);
})
.catch(({ data }) => {
makeToast('error', data.error.message);
});
}
};
return { updateUser };
}
추후에 추가할 삭제, 추가도 해당 훅에서 제공함으로써, 관심사의 분리를 명확히 할 수 있을 것이다.
function DetailForm() {
const { id } = useParams();
const { data: userData } = useGetUserQuery(Number(id));
const [form] = CustomForm.useForm();
const { updateUser } = useUserMutation();
return (
<div>
{userData && (
<CustomForm
form={form}
initialValues={userData}
onFinish={updateUser}>
Form.Item
은 rules
props를 통해 validation을 제공한다.
정규표현식 pattern
을 적거나, 빈 값이 들어올 수 없는 필드에는 require
를 추가하는 것만으로 간단한 유효성 검사가 가능하다.
또한 type: "email"
을 추가하기만 해도 이메일 정규표현식을 검사해준다.
지금 만드는 유저 상세 정보 수정 페이지는 닉네임 필드를 변경해서 PUT요청을 보낼 수 있지만, 먼저 중복 확인 과정이 필요했다.
rules
에 validator를 추가함으로써 이 문제를 해결할 수 있을 것이라 생각했다.
validator는 Promise.resolve() / reject()
를 반환하는 함수를 넣어줘야 한다.
지금 내 경우는 닉네임이라는 필드에 대한 관리가 필요하기 때문에, 이번에도 선언적인 문맥 유지를 위해 hook으로 분리했다.
export default function useNicknameCheck(form: FormInstance) {
// 유효 상태를 관리할 변수
const [nicknameChecked, setNicknameChecked] = useState(true);
const [checkNickname] = useGetNicknameCheckMutation();
// nicknameChecked가 수정될 때마다 유효성 검증을 강요
useEffect(() => {
form.validateFields(['nickname']);
}, [nicknameChecked, form]);
// 닉네임이 변경되면, 유효성 검증이 실패하도록 값을 수정
const handleNicknameChange = () => {
setNicknameChecked(false);
};
const checkDuplicateNickname = () => {
checkNickname(form.getFieldValue('nickname'))
.unwrap()
.then(() => {
makeToast('success', '사용 가능한 닉네임입니다.');
// 닉네임 중복검사 요청이 성공했다면, 유효 상태를 true로 변경
setNicknameChecked(true);
})
.catch(({ data }) => {
makeToast('error', data.error.message);
setNicknameChecked(false);
});
};
// validator는 유효 상태에 따라 resolve를 반환
const validator = () => (nicknameChecked ? Promise.resolve() : Promise.reject(new Error('닉네임 중복을 확인해주세요')));
return { handleNicknameChange, checkDuplicateNickname, validator };
}
핵심인 validator
와 닉네임이 변경될 때에 적용될 handleNicknameChange
, 중복검증을 일으킬 버튼에 담아줄 checkDuplicateNickname
까지 반환했으니 알맞게 넣기만 하면 된다.
function DetailForm() {
const { updateUser } = useUserMutation();
const { handleNicknameChange, checkDuplicateNickname, validator } = useNicknameCheck(form);
return (
<div>
{userData && (
<CustomForm
form={form}
initialValues={userData}
onFinish={updateUser}>
//(...)
<CustomForm.Input label="닉네임" name="nickname" onChange={handleNicknameChange} rules={[{ validator }]} />
<CustomForm.Button onClick={checkDuplicateNickname}>중복확인</CustomForm.Button>
추후에는 해당 기능이 ID중복검사 등 다른 곳에서도 사용할 일이 생긴다면 nickname뿐 아니라 다른 필드에 대해서도 사용할 수 있게끔 추상화할 수 있겠지만, 당장 어드민에서 생각난 기능에는 없다고 판단해 일단은 넘어갔다.
Ant Design의 Form이 생각보다 매우 편리했다.
Form관리에 있어서 신경쓸 많은 부분들을 별도의 라이브러리 설치 없이 관리할 수 있다는 점이 너무 좋았다.(Antd 자체는 이미 UI라이브러리로 선정해서 설치했으니..)
직접 관리하려면 복잡했을 UI 상태지만, Form을 래핑하고 이에 의존하여 작성하는 부분에 있어선 정말 간단하게 짤 수 있었다.
UI를 제외한 다른 로직을 무려 5줄만으로 분리해 작성할 수 있었다.
기존의 폼 상태를 다루던 방식인..
setState({
...state
[e.target.name]: e.target.value,
})
뭐... 이런 코드나.. useRef
를 덕지덕지 사용하던 코드를 벗어나 깔끔하게 작성할 수 있었다는 점이 만족스러웠다.