← 전체 글로 돌아가기

Next.js

React 폼의 onChange 검증을 너무 많이 넣었을 때의 실수

폼의 매 입력마다 검증을 하면 사용자 경험이 나빠진다. 올바른 검증 시점을 찾아야 한다.

React 폼을 만들 때 사용자 입력의 검증은 중요한데, onChange에서 매번 검증하면 예상 밖의 문제가 생긴다.

onChange에서 매번 검증할 때의 문제들

// 나쁜 예
function SignupForm() {
  const [email, setEmail] = useState('');
  const [error, setError] = useState('');

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const value = e.target.value;
    setEmail(value);

    // 매번 검증
    if (!value.includes('@')) {
      setError('Invalid email');
    } else if (value.length < 5) {
      setError('Email too short');
    } else {
      setError('');
    }
  };

  return (
    <div>
      <input value={email} onChange={handleChange} />
      {error && <span>{error}</span>}
    </div>
  );
}

이렇게 하면:

  • 사용자가 입력하는 순간마다 "Invalid email" 에러가 뜬다
  • 아직 안 된 입력에 자꾸만 빨간 에러 메시지가 나타난다
  • 사용자 경험이 매우 불쾌하다

올바른 검증 시점들

1. onBlur (필드 떠날 때)

function SignupForm() {
  const [email, setEmail] = useState('');
  const [touched, setTouched] = useState(false);
  const [error, setError] = useState('');

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setEmail(e.target.value);
  };

  const handleBlur = () => {
    setTouched(true);
    validateEmail(email);
  };

  const validateEmail = (value: string) => {
    if (!value.includes('@')) {
      setError('Invalid email');
    } else if (value.length < 5) {
      setError('Email too short');
    } else {
      setError('');
    }
  };

  return (
    <div>
      <input
        value={email}
        onChange={handleChange}
        onBlur={handleBlur}
        aria-invalid={touched && !!error}
      />
      {touched && error && <span>{error}</span>}
    </div>
  );
}

2. 제출할 때 (submit)

function SignupForm() {
  const [formData, setFormData] = useState({ email: '', password: '' });
  const [errors, setErrors] = useState<Record<string, string>>({});

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const { name, value } = e.target;
    setFormData(prev => ({ ...prev, [name]: value }));
  };

  const validateForm = () => {
    const newErrors: Record<string, string> = {};

    if (!formData.email.includes('@')) {
      newErrors.email = 'Invalid email';
    }
    if (formData.password.length < 8) {
      newErrors.password = 'Password too short';
    }

    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  };

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (validateForm()) {
      // 서버로 전송
      submitForm(formData);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        name="email"
        value={formData.email}
        onChange={handleChange}
        aria-invalid={!!errors.email}
      />
      {errors.email && <span>{errors.email}</span>}
      <button type="submit">Sign up</button>
    </form>
  );
}

실시간 피드백이 필요한 경우

아이디 중복 확인처럼 서버와 통신이 필요한 경우는 디바운싱을 써서 과도한 요청을 줄인다.

function UsernameField() {
  const [username, setUsername] = useState('');
  const [isAvailable, setIsAvailable] = useState<boolean | null>(null);
  const debounceTimer = useRef<NodeJS.Timeout>();

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const value = e.target.value;
    setUsername(value);

    // 이전 타이머 취소
    if (debounceTimer.current) {
      clearTimeout(debounceTimer.current);
    }

    // 500ms 후 서버 확인
    debounceTimer.current = setTimeout(async () => {
      if (value.length >= 3) {
        const response = await fetch(`/api/check-username?q=${value}`);
        const { available } = await response.json();
        setIsAvailable(available);
      }
    }, 500);
  };

  return (
    <div>
      <input value={username} onChange={handleChange} />
      {isAvailable === true && <span>✓ Available</span>}
      {isAvailable === false && <span>✗ Already taken</span>}
    </div>
  );
}

검증 라이브러리 사용

복잡한 폼은 react-hook-form이나 Formik 같은 라이브러리를 쓰면 더 간단하다.

import { useForm } from 'react-hook-form';

function SignupForm() {
  const { register, handleSubmit, formState: { errors } } = useForm();

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input
        {...register('email', {
          required: 'Email is required',
          pattern: {
            value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
            message: 'Invalid email address',
          },
        })}
      />
      {errors.email && <span>{errors.email.message}</span>}
      <button type="submit">Sign up</button>
    </form>
  );
}

onChange에서 매번 검증하는 건 문제해결이 아니라 사용자에게 불필요한 좌절감을 준다. 검증 시점을 신중하게 선택하자.