
form 의 내용이 많아서 스크롤이 길어지면 유효성 검사에서 문제가 생겼을 때 사용자는 어디서 오류가 났는지 쉽게 알기 어렵습니다. 등록이나 수정 버튼을 눌렀을 때 에러에 의해서 다음 플로우로 진행이 되지 않고 가만히 멈춰있는다고 해도 사용자는 에러가 나서 다음 플로우로 진행이 됐다는 것을 바로 알아차리기 어렵습니다.
이런 문제를 해결하기 위해, react-hook-form을 기반으로 한 커스텀 훅 useExForm을 만들었습니다. 이 훅은 폼 검사 중 에러가 발생하면 해당 필드로 자동으로 포커스를 이동시켜 줍니다. 덕분에 사용자는 즉시 문제를 인지하고 수정할 수 있어, 폼을 작성하는 과정이 훨씬 직관적이고 매끄러워집니다. 개발자 입장에서도 사용자 피드백을 개선할 수 있어, 애플리케이션의 전반적인 품질을 높이는 데 도움이 됩니다.
useExForm은 react-hook-form의 기능을 확장하여, 에러가 난 필드로의 포커스 이동을 자동화합니다. 이를 통해 사용자는 폼을 제출할 때의 혼란을 줄이고, 즉각적인 피드백을 통해 입력 오류를 쉽게 해결할 수 있습니다.
import { useEffect, useRef, useState } from "react";
import {
DeepRequired,
FieldErrorsImpl,
FieldValues,
Path,
UseFormProps,
UseFormTrigger,
useForm,
} from "react-hook-form";
// useExForm 커스텀 훅 정의
export const useExForm = <
TFieldValues extends FieldValues = FieldValues,
TContext = any,
TTransformedValues extends FieldValues | undefined = undefined
>(
props?: UseFormProps<TFieldValues, TContext>
) => {
// react-hook-form의 useForm 훅 사용
const form = useForm<TFieldValues, TContext, TTransformedValues>(props);
const {
getValues, // 현재 폼의 모든 값을 가져오는 함수
formState: { errors }, // 폼의 에러 상태
trigger, // 특정 필드의 유효성 검사를 수행하는 함수
} = form;
// 에러 발생 시 트리거 상태 관리
const [triggerErrors, setTriggerErrors] = useState(false);
// 유효성 검사할 필드의 이름을 저장
const nameRef = useRef<Path<TFieldValues> | Path<TFieldValues>[] | readonly Path<TFieldValues>[]>(
Object.keys(getValues()) as Path<TFieldValues>[]
);
// 유효성 검사를 수행하고 에러 발생 시 에러 필드로 포커스를 이동
const triggerWithFocusError: UseFormTrigger<TFieldValues> = async (name, options) => {
if (!(await trigger(name, options))) {
setTriggerErrors(!triggerErrors); // 에러가 발생하면 트리거 상태 변경
nameRef.current = name ?? (Object.keys(getValues()) as Path<TFieldValues>[]); // 에러 필드 이름 저장
return false;
}
return true;
};
// 에러 객체에서 실제 DOM 요소를 찾는 함수
const searchErrorFieldEl = (obj: any): any => {
if (!obj) return undefined;
if (obj.ref) {
return obj.ref; // ref 속성이 있으면 해당 요소 반환
} else if (Array.isArray(obj)) {
return searchErrorFieldEl(obj.find((item) => !!item)); // 배열인 경우 첫 번째 요소 탐색
} else if (typeof obj === "object" && obj !== null) {
const fieldKeyOrderMap = assignOrder(getValues(), 0); // 폼 필드 순서 매핑
const keys = Object.keys(obj);
let firstKey = keys[0]; // 첫 번째 키 초기화
let minOrder = Number.MAX_VALUE;
for (const key of keys) {
// 경로 기반의 키를 처리하기 위해 전체 경로를 탐색
const fullPath = Object.keys(fieldKeyOrderMap).find((path) => path.endsWith(key));
if (
fullPath &&
fieldKeyOrderMap[fullPath] !== undefined &&
fieldKeyOrderMap[fullPath] < minOrder
) {
minOrder = fieldKeyOrderMap[fullPath];
firstKey = key;
}
}
return searchErrorFieldEl(obj[firstKey]); // 가장 먼저 발생한 에러 필드 탐색
}
return undefined;
};
// 특정 에러 객체에서 에러가 발생한 DOM 요소를 찾는 함수
const findErrorFieldEl = (
error: FieldErrorsImpl<DeepRequired<TFieldValues>>[string] | undefined
) => {
if (!error) return undefined;
return searchErrorFieldEl(error);
};
// 폼 필드의 순서를 매핑하여 에러 필드 탐색 시 사용
const assignOrder = (obj: any, startIdx: number): { [key: string]: number } => {
const fieldKeyOrderMap: { [key: string]: number } = {};
const assignOrderRecursive = (obj: any, startIdx: number, path: string): number => {
let currentIdx = startIdx;
for (const key of Object.keys(obj)) {
const currentPath = path ? `${path}.${key}` : key;
fieldKeyOrderMap[currentPath] = currentIdx++;
if (typeof obj[key] === "object" && obj[key] !== null) {
currentIdx = assignOrderRecursive(obj[key], currentIdx, currentPath);
}
}
return currentIdx;
};
assignOrderRecursive(obj, startIdx, "");
return fieldKeyOrderMap;
};
// 가장 먼저 발생한 에러 필드로 포커스를 이동시키는 함수
const focusToError = () => {
const fieldKeyOrderMap = assignOrder(getValues(), 0);
const firstErrorKey = Object.keys(errors)
.filter((error) =>
Array.isArray(nameRef.current)
? nameRef.current.includes(error as Path<TFieldValues>)
: error === nameRef.current
)
.sort((a, b) => fieldKeyOrderMap[a] - fieldKeyOrderMap[b])[0];
const errorFieldEl = findErrorFieldEl(errors[firstErrorKey]);
errorFieldEl?.focus?.(); // 에러 필드로 포커스 이동
};
// triggerErrors 상태가 변경될 때마다 에러 필드로 포커스 이동
useEffect(() => {
focusToError();
}, [triggerErrors]);
return {
...form,
triggerWithFocusError, // 유효성 검사 및 에러 포커스 기능을 포함하여 반환
};
};
// const methods = useForm()
// const { trigger } = methods
const methods = useExForm()
const { triggerWithFocusError } = methods
// if (!(await trigger())) return;
if (!(await triggerWithFocusError())) return;⚠️
⚠️ ref 를 컴포넌트와 연결해주지 않으면 triggerWithFocusError 가 실행되더라도 컴포넌트에 focusing 이 되지 않습니다. ⚠️
import React from 'react';
import { useForm, Controller } from 'react-hook-form';
function MyForm() {
const { control, register, handleSubmit } = useForm();
return (
<form>
{/* register를 사용하는 input */}
<input {...register("firstName")} />
{/* Controller를 사용하는 input */}
<Controller
name="lastName"
control={control}
render={({ field }) => (
<input {...field} ref={field.ref} />
)}
/>
<button type="submit">Submit</button>
</form>
);
}
export default MyForm;
