useForm 확장 - validation 검증 실패 시 Focusing Component

장세진·2024년 11월 15일

React

목록 보기
3/4
post-thumbnail

들어가며

form 의 내용이 많아서 스크롤이 길어지면 유효성 검사에서 문제가 생겼을 때 사용자는 어디서 오류가 났는지 쉽게 알기 어렵습니다. 등록이나 수정 버튼을 눌렀을 때 에러에 의해서 다음 플로우로 진행이 되지 않고 가만히 멈춰있는다고 해도 사용자는 에러가 나서 다음 플로우로 진행이 됐다는 것을 바로 알아차리기 어렵습니다.

이런 문제를 해결하기 위해, react-hook-form을 기반으로 한 커스텀 훅 useExForm을 만들었습니다. 이 훅은 폼 검사 중 에러가 발생하면 해당 필드로 자동으로 포커스를 이동시켜 줍니다. 덕분에 사용자는 즉시 문제를 인지하고 수정할 수 있어, 폼을 작성하는 과정이 훨씬 직관적이고 매끄러워집니다. 개발자 입장에서도 사용자 피드백을 개선할 수 있어, 애플리케이션의 전반적인 품질을 높이는 데 도움이 됩니다.

useExForm은 react-hook-form의 기능을 확장하여, 에러가 난 필드로의 포커스 이동을 자동화합니다. 이를 통해 사용자는 폼을 제출할 때의 혼란을 줄이고, 즉각적인 피드백을 통해 입력 오류를 쉽게 해결할 수 있습니다.

useExForm 코드

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, // 유효성 검사 및 에러 포커스 기능을 포함하여 반환
  };
};

사용 예시

기존에 사용하던 useForm 을 useExform 으로 교체한 후 triggerWithFocusError 를 불러옵니다.

// const methods = useForm()
// const { trigger } = methods 

const methods = useExForm()
const { triggerWithFocusError } = methods 

기존에 사용하던 trigger 를 triggerWithFocusError 로 교체합니다.

// if (!(await trigger())) return;

if (!(await triggerWithFocusError())) return;⚠️

Controller 또는 register 의 ref 를 컴포넌트와 연결해줍니다.

⚠️ 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;

사용 결과

profile
4년차 프론트엔드 개발자 장세진

0개의 댓글