React Hook Form 에러 메시지 표시 문제 해결: validation이 안 보일 때 체크리스트

2026.05.09·수정 2026.09.12·약 33분·작성: 해비·블로그 소개

이 글에서 정리하는 내용

required나 minLength를 넣었는데 errors 객체가 비어 있거나 에러가 있는데 화면에 표시되지 않는 경우가 생깁니다.

validation이 안 보일 때는 에러가 없는지, 표시만 안 되는지 나눕니다

React Hook Form에서 에러 메시지가 안 보인다고 해서 항상 validation이 동작하지 않는 것은 아닙니다. errors 객체에는 값이 들어 있는데 화면 조건문이 잘못되어 표시만 안 될 수도 있고, input이 register에 연결되지 않아 validation 자체가 실행되지 않을 수도 있습니다. 먼저 개발 중에는 console.log(formState.errors)나 DevTools로 실제 errors가 생기는지 확인합니다. 이 한 단계만 거쳐도 문제 범위가 절반으로 줄어듭니다.

register가 input에 제대로 연결되어야 합니다

가장 흔한 원인은 input name과 register 이름이 맞지 않는 경우입니다. register(“email”)로 등록했는데 errors.userEmail을 보고 있으면 메시지는 나오지 않습니다. 커스텀 Input 컴포넌트를 쓸 때 ref나 {…register(“name”)}가 실제 input까지 전달되지 않는 경우도 있습니다. 겉으로는 입력창이 보이지만 React Hook Form 입장에서는 그 필드를 모르는 상태가 됩니다. 먼저 name, register, errors 경로가 같은지 확인합니다.

const { register, formState: { errors } } = useForm(); <input {...register('email', { required: '이메일을 입력해주세요' })} />
{errors.email?.message && <p>{errors.email.message}</p>}
폼 검증 흐름: register, validation, errors

validation rules는 submit 시점과 함께 봅니다

required, minLength, pattern 같은 rule을 넣어도 언제 검증할지 설정에 따라 보이는 타이밍이 달라집니다. 기본적으로 submit 이후에 에러가 보일 수 있고, mode를 onChange로 설정하면 입력 변경마다, onBlur로 설정하면 포커스를 잃을 때 검사합니다. 기본 mode는 onSubmit이며 제출 이후 오류 필드의 재검사는 기본 reValidateMode: 'onChange'로 처리됩니다. 사용자가 입력하는 즉시 메시지가 보여야 하는 폼인지, 제출 버튼을 눌렀을 때만 보여도 되는 폼인지에 따라 mode를 정해야 합니다. 문제를 해결하려고 무작정 trigger를 넣기 전에 검증 타이밍을 먼저 봅니다.

errors는 객체 구조에 맞게 안전하게 출력합니다

에러 메시지를 출력할 때는 errors.email?.message처럼 optional chaining을 사용합니다. 중첩 필드나 배열 필드는 경로가 더 복잡해질 수 있습니다. 메시지가 undefined로 보인다면 rule에 message가 들어 있는지도 확인해야 합니다. required: “이메일을 입력해주세요”처럼 메시지를 직접 넣어야 화면에 원하는 문구를 출력할 수 있습니다. 에러 객체 구조와 UI 조건문이 서로 맞아야 validation 결과가 사용자에게 보입니다.

Controller를 쓰는 UI 라이브러리는 연결 방식이 다릅니다

MUI, React Select 같은 controlled component는 단순 register만으로 연결이 어색할 수 있습니다. 이때는 Controller로 value와 onChange를 React Hook Form에 연결하고, onBlurref도 전달합니다. Controller를 쓰면서 field.onChange를 실제 컴포넌트에 넘기지 않으면 값이 폼 상태에 반영되지 않습니다. 최종 점검은 register 연결, rules, errors 경로, mode, Controller 연결 순서로 하면 됩니다.

폼 검증 흐름 확장: 입력 연결, 제출 검사, 메시지 출력

제출 이벤트와 에러 표시를 함께 점검합니다

폼은 onSubmit={handleSubmit(onValid, onInvalid)}로 연결하고 버튼은 type="submit"으로 지정합니다. onInvalid에서 오류가 확인되는데 화면이 비어 있다면 errors 경로와 조건부 UI를 봅니다. 이 실습의 noValidate는 브라우저 내장 검증이 제출을 먼저 차단하지 않게 하여 React Hook Form 에러를 관찰하는 설정입니다. 서버에서도 입력값을 검증해야 합니다.

같은 입력으로 수정 전후를 비교합니다

먼저 빈 값 제출, 잘못된 이메일, 올바른 이메일을 차례로 확인합니다. 중첩 등록명 user.email은 errors.user?.email로 읽습니다. errors[“user.email”]처럼 점이 들어간 문자열을 그대로 읽는 방식과 다릅니다. rule에 메시지를 주지 않으면 오류 type만 있고 원하는 문구가 없을 수 있습니다.

커스텀 입력과 재검증 규칙을 기록합니다

register가 반환한 name·onChange·onBlur·ref를 실제 입력 요소까지 전달하는지 확인합니다. {…register(…)} 뒤에 별도 onChange를 써서 기존 핸들러를 덮어쓰지 않도록 합니다. Controller로 등록한 필드에 register를 다시 겹쳐 사용하지 않습니다. defaultValues를 제공하고 빈 controlled 값에는 undefined 대신 빈 문자열을 사용합니다. 에러 문구는 role=”alert”, input은 aria-invalid와 aria-describedby로 연결하면 시각 표시와 보조 기술의 안내가 함께 맞춰집니다.

직접 실행하는 최소 재현과 수정 실습

실습 ZIP 다운로드 · 3399번 글의 완성 프로젝트입니다. Node.js 22.12 이상에서 압축을 풀고 아래 명령을 실행합니다. HTML, CSS, JSX가 별도 파일이며 외부 API는 사용하지 않습니다.

npm install
npm run dev

# 결과 검증
npm test
npm run build
npm run preview

1. 원인을 확인하는 최소 오류 코드

<input {...register('user.email', { required: '이메일을 입력하세요' })} />
{errors.email?.message && <p>{errors.email.message}</p>}

오류 조각은 user.email로 등록한 중첩 필드를 errors.email에서 읽습니다. 검증이 실행되어도 이 경로에는 메시지가 없습니다. 수정본은 errors.user?.email을 읽고 이메일 input과 에러를 aria-describedby로 연결합니다. Controller 예제는 field의 name·value·onChange·onBlur·ref를 모두 select에 전달합니다. 네이티브 select는 보통 register로 충분하지만 여기서는 controlled 연결을 작게 관찰하기 위해 Controller를 사용합니다.

2. App.jsx 수정본

실습 파일

파일 경로를 확인하고 같은 프로젝트 안에 저장하세요. 이미지 등 소스 목록에 없는 파일과 실행 안내는 실습 ZIP에 포함되어 있습니다.

이 실습은 오류를 재현하는 시작본과 설명, 검증된 완성본을 차례로 비교합니다. 빈 폼을 제출하세요. 시작본은 이메일 오류가 보이지 않지만 완성본은 두 필드 오류를 표시하고 올바른 입력 뒤 모두 해제합니다.

수정 전

App.jsx

import React, { useState } from 'react';
import { Controller, useForm } from 'react-hook-form';

export default function App() {
  const [submitted, setSubmitted] = useState(null);
  const {
    register,
    control,
    handleSubmit,
    formState: { errors },
  } = useForm({
    defaultValues: { user: { email: '' }, topic: '' },
    mode: 'onSubmit',
    reValidateMode: 'onChange',
  });
  const emailError = errors.email;
  return (
    <main>
      <h1>폼 에러를 사용자에게 표시하기</h1>
      <form noValidate onSubmit={handleSubmit(setSubmitted)}>
        <label htmlFor="email">이메일</label>
        <input
          id="email"
          type="email"
          aria-invalid={!!emailError}
          aria-describedby={emailError ? 'email-error' : undefined}
          {...register('user.email', {
            required: '이메일을 입력하세요',
            pattern: {
              value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
              message: '이메일 형식을 확인하세요',
            },
          })}
        />
        {emailError && (
          <p id="email-error" role="alert">
            {emailError.message}
          </p>
        )}
        <Controller
          name="topic"
          control={control}
          rules={{ required: '관심 주제를 선택하세요' }}
          render={({ field, fieldState }) => (
            <div>
              <label htmlFor="topic">관심 주제</label>
              <select
                id="topic"
                {...field}
                aria-invalid={!!fieldState.error}
                aria-describedby={fieldState.error ? 'topic-error' : undefined}
              >
                <option value="">선택하세요</option>
                <option value="react">React</option>
              </select>
              {fieldState.error && (
                <p id="topic-error" role="alert">
                  {fieldState.error.message}
                </p>
              )}
            </div>
          )}
        />
        <button type="submit">제출</button>
      </form>
      {submitted && (
        <p role="status">
          접수: {submitted.user.email} / {submitted.topic}
        </p>
      )}
    </main>
  );
}

App.test.jsx

import React, { StrictMode } from 'react';
import { it, expect, afterEach } from 'vitest';
import { render, screen, cleanup, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import App from './App.jsx';
afterEach(cleanup);
it('shows nested and controlled errors then revalidates and submits valid data', async () => {
  const user = userEvent.setup();
  render(<App />);
  expect(screen.queryByRole('alert')).toBeNull();
  await user.click(screen.getByRole('button', { name: '제출' }));
  expect(await screen.findByText('이메일을 입력하세요')).toBeTruthy();
  expect(screen.getByText('관심 주제를 선택하세요')).toBeTruthy();
  await user.type(screen.getByLabelText('이메일'), 'reader@example.com');
  await user.selectOptions(screen.getByLabelText('관심 주제'), 'react');
  await waitFor(() => expect(screen.queryByRole('alert')).toBeNull());
  await user.click(screen.getByRole('button', { name: '제출' }));
  expect(await screen.findByRole('status')).toHaveProperty(
    'textContent',
    '접수: reader@example.com / react',
  );
});

index.html

<!doctype html>
<html lang="ko">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>React 실습 3399</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/main.jsx"></script>
  </body>
</html>

main.jsx

import React, { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import './styles.css';

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <App />
  </StrictMode>,
);

package.json

{
  "name": "react-practice-3399",
  "private": true,
  "version": "1.0.0",
  "type": "module",
  "engines": {
    "node": ">=22.12.0"
  },
  "scripts": {
    "dev": "vite --host 0.0.0.0",
    "build": "vite build",
    "preview": "vite preview --host 0.0.0.0",
    "test": "vitest run"
  },
  "dependencies": {
    "react": "19.3.0",
    "react-dom": "19.3.0",
    "react-hook-form": "7.88.0"
  },
  "devDependencies": {
    "vite": "8.3.0",
    "vitest": "4.1.11",
    "jsdom": "30.0.1",
    "@testing-library/react": "16.3.3",
    "@testing-library/user-event": "14.6.7"
  }
}

styles.css

* {
  box-sizing: border-box;
}
body {
  margin: 0;
  color: #222;
  background: #fff;
  font-family: system-ui, sans-serif;
  line-height: 1.7;
}
main {
  max-width: 760px;
  margin: 40px auto;
  padding: 24px;
}
h1 {
  font-size: 1.7rem;
}
label {
  display: block;
  margin: 12px 0 6px;
}
input,
select,
button {
  font: inherit;
  color: #222;
  background: #fff;
  border: 1px solid #777;
  padding: 8px 12px;
}
input:not([type='checkbox']),
select {
  max-width: 100%;
}
button {
  margin: 8px 8px 8px 0;
  cursor: pointer;
}
button:disabled {
  color: #777;
  cursor: default;
}
[aria-pressed='true'] {
  background: #222;
  color: #fff;
}
:focus-visible {
  outline: 2px solid #222;
  outline-offset: 3px;
}
li {
  margin: 12px 0;
}
[role='alert'] {
  font-weight: 700;
}

vite.config.js

import { defineConfig } from 'vite';
export default defineConfig({ base: './', test: { environment: 'jsdom' } });

수정 방법

App.jsx

수정 위치: register 이름은 user.email인데 errors.email에서 오류를 찾는 부분을 확인하세요.

이유: React Hook Form의 errors 구조는 필드 경로와 같으므로 중첩 필드는 errors.user?.email로 읽어야 합니다. 그래야 메시지, aria-invalid, aria-describedby가 같은 실제 오류를 반영합니다.

재현과 기대 결과: 빈 폼을 제출하세요. 시작본은 이메일 오류가 보이지 않지만 완성본은 두 필드 오류를 표시하고 올바른 입력 뒤 모두 해제합니다.

App.jsx에서 아래 차이가 있는 위치를 수정하세요. -는 삭제할 줄, +는 추가할 줄이며 표시는 실제 코드에 넣지 않습니다.

본문의 확인 절차로 변경 결과를 확인합니다.

--- 수정 전/App.jsx
+++ 수정 후/App.jsx
@@ -13,7 +13,7 @@
     mode: 'onSubmit',
     reValidateMode: 'onChange',
   });
-  const emailError = errors.email;
+  const emailError = errors.user?.email;
   return (
     <main>
       <h1>폼 에러를 사용자에게 표시하기</h1>

수정 후 탭의 전체 파일과 비교한 뒤 본문의 확인 절차를 실행하세요.

App.test.jsx

이 파일은 변경하지 않습니다. 다른 파일을 수정하는 동안 기존 내용을 유지하세요.

index.html

이 파일은 변경하지 않습니다. 다른 파일을 수정하는 동안 기존 내용을 유지하세요.

main.jsx

이 파일은 변경하지 않습니다. 다른 파일을 수정하는 동안 기존 내용을 유지하세요.

package.json

이 파일은 변경하지 않습니다. 다른 파일을 수정하는 동안 기존 내용을 유지하세요.

styles.css

이 파일은 변경하지 않습니다. 다른 파일을 수정하는 동안 기존 내용을 유지하세요.

vite.config.js

이 파일은 변경하지 않습니다. 다른 파일을 수정하는 동안 기존 내용을 유지하세요.

수정 후

App.jsx

import React, { useState } from 'react';
import { Controller, useForm } from 'react-hook-form';

export default function App() {
  const [submitted, setSubmitted] = useState(null);
  const {
    register,
    control,
    handleSubmit,
    formState: { errors },
  } = useForm({
    defaultValues: { user: { email: '' }, topic: '' },
    mode: 'onSubmit',
    reValidateMode: 'onChange',
  });
  const emailError = errors.user?.email;
  return (
    <main>
      <h1>폼 에러를 사용자에게 표시하기</h1>
      <form noValidate onSubmit={handleSubmit(setSubmitted)}>
        <label htmlFor="email">이메일</label>
        <input
          id="email"
          type="email"
          aria-invalid={!!emailError}
          aria-describedby={emailError ? 'email-error' : undefined}
          {...register('user.email', {
            required: '이메일을 입력하세요',
            pattern: {
              value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
              message: '이메일 형식을 확인하세요',
            },
          })}
        />
        {emailError && (
          <p id="email-error" role="alert">
            {emailError.message}
          </p>
        )}
        <Controller
          name="topic"
          control={control}
          rules={{ required: '관심 주제를 선택하세요' }}
          render={({ field, fieldState }) => (
            <div>
              <label htmlFor="topic">관심 주제</label>
              <select
                id="topic"
                {...field}
                aria-invalid={!!fieldState.error}
                aria-describedby={fieldState.error ? 'topic-error' : undefined}
              >
                <option value="">선택하세요</option>
                <option value="react">React</option>
              </select>
              {fieldState.error && (
                <p id="topic-error" role="alert">
                  {fieldState.error.message}
                </p>
              )}
            </div>
          )}
        />
        <button type="submit">제출</button>
      </form>
      {submitted && (
        <p role="status">
          접수: {submitted.user.email} / {submitted.topic}
        </p>
      )}
    </main>
  );
}

App.test.jsx

import React, { StrictMode } from 'react';
import { it, expect, afterEach } from 'vitest';
import { render, screen, cleanup, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import App from './App.jsx';
afterEach(cleanup);
it('shows nested and controlled errors then revalidates and submits valid data', async () => {
  const user = userEvent.setup();
  render(<App />);
  expect(screen.queryByRole('alert')).toBeNull();
  await user.click(screen.getByRole('button', { name: '제출' }));
  expect(await screen.findByText('이메일을 입력하세요')).toBeTruthy();
  expect(screen.getByText('관심 주제를 선택하세요')).toBeTruthy();
  await user.type(screen.getByLabelText('이메일'), 'reader@example.com');
  await user.selectOptions(screen.getByLabelText('관심 주제'), 'react');
  await waitFor(() => expect(screen.queryByRole('alert')).toBeNull());
  await user.click(screen.getByRole('button', { name: '제출' }));
  expect(await screen.findByRole('status')).toHaveProperty(
    'textContent',
    '접수: reader@example.com / react',
  );
});

index.html

<!doctype html>
<html lang="ko">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>React 실습 3399</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/main.jsx"></script>
  </body>
</html>

main.jsx

import React, { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import './styles.css';

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <App />
  </StrictMode>,
);

package.json

{
  "name": "react-practice-3399",
  "private": true,
  "version": "1.0.0",
  "type": "module",
  "engines": {
    "node": ">=22.12.0"
  },
  "scripts": {
    "dev": "vite --host 0.0.0.0",
    "build": "vite build",
    "preview": "vite preview --host 0.0.0.0",
    "test": "vitest run"
  },
  "dependencies": {
    "react": "19.3.0",
    "react-dom": "19.3.0",
    "react-hook-form": "7.88.0"
  },
  "devDependencies": {
    "vite": "8.3.0",
    "vitest": "4.1.11",
    "jsdom": "30.0.1",
    "@testing-library/react": "16.3.3",
    "@testing-library/user-event": "14.6.7"
  }
}

styles.css

* {
  box-sizing: border-box;
}
body {
  margin: 0;
  color: #222;
  background: #fff;
  font-family: system-ui, sans-serif;
  line-height: 1.7;
}
main {
  max-width: 760px;
  margin: 40px auto;
  padding: 24px;
}
h1 {
  font-size: 1.7rem;
}
label {
  display: block;
  margin: 12px 0 6px;
}
input,
select,
button {
  font: inherit;
  color: #222;
  background: #fff;
  border: 1px solid #777;
  padding: 8px 12px;
}
input:not([type='checkbox']),
select {
  max-width: 100%;
}
button {
  margin: 8px 8px 8px 0;
  cursor: pointer;
}
button:disabled {
  color: #777;
  cursor: default;
}
[aria-pressed='true'] {
  background: #222;
  color: #fff;
}
:focus-visible {
  outline: 2px solid #222;
  outline-offset: 3px;
}
li {
  margin: 12px 0;
}
[role='alert'] {
  font-weight: 700;
}

vite.config.js

import { defineConfig } from 'vite';
export default defineConfig({ base: './', test: { environment: 'jsdom' } });

App.jsx 전체 코드 보기

3. 따라 해볼 순서와 완료 기준

빈 폼 제출 → 두 에러 확인 → 올바른 이메일과 React 선택 → 에러 해제 → 제출 → 접수 결과 확인. 이메일을 잘못 입력하면 형식 에러가 나옵니다.

ZIP의 main.jsx에서 React 루트와 StrictMode를 구성하고 styles.css에서 흑백 UI를 적용합니다. App.test.jsx는 실제 입력·클릭 뒤 화면 결과를 확인합니다. 오류 코드는 broken-example.md에 설명용으로 보관되어 자동 실행되지 않습니다. 테스트는 jsdom에서 검증하며 브라우저의 시각적 배치까지 검증한 것은 아닙니다.

공식 참고 자료

이 글이 도움이 되었나요?

조회 중

React 학습 순서

필수 19개 · 전체 23개

읽음 기록 관리

전체 과정 목차 (23개)
  1. 필수 길잡이 · React 학습 순서: 컴포넌트·props·state부터 상태관리까지
  2. 필수 학습 · React Vite 사용법: Vite 8 프로젝트 생성·실행·빌드
  3. 필수 학습 · React 컴포넌트 개념 정리: UI 재사용 구조 잡기
  4. 필수 학습 · React JSX 문법 사용법: 조건부 렌더링과 리스트 처리
  5. 필수 학습 · React props 사용법: 부모에서 자식으로 데이터 전달하는 구조
  6. 필수 학습 · React useState 사용법: state와 객체 배열 업데이트 기준
  7. 선택 참고 · React state 업데이트 안됨 문제 해결
  8. 필수 선수 · React 리스트 key 경고 해결 기준: index key를 피해야 하는 이유
  9. 필수 학습 · React 제어 컴포넌트 폼과 상태 끌어올리기 첫 실습 가이드
  10. 필수 학습 · React useRef 실습: 입력 포커스와 state의 역할 나누기
  11. 필수 선수 · React useEffect 두 번 실행되는 이유: StrictMode·API 중복 해결
  12. 선택 참고 · React Maximum update depth exceeded 오류 해결: 무한 렌더링 원인 찾기
  13. 선택 참고 · React Cannot update 오류 해결: 렌더링 중 setState 원인
  14. 필수 학습 · React useReducer와 Context 실습: 작업 목록 상태를 여러 컴포넌트에서 공유하기
  15. 필수 학습 · React 컴포넌트 props 타입 지정하기: 부모와 자식 사이의 값 구조 잡기
  16. 선택 참고 · React Hook Form 에러 메시지 표시 문제 해결: validation이 안 보일 때 체크리스트 현재 글
  17. 필수 길잡이 · React 라이브러리 조합 가이드: Zustand·TanStack Query·shadcn/ui 선택 기준
  18. 필수 학습 · Next.js 커스텀 훅 설계: 프론트엔드 상태 관리 구조 잡기
  19. 필수 학습 · React Compiler 기준: useMemo useCallback 언제 줄일까
  20. 필수 학습 · React·TypeScript 검색 필터 만들기: 상태와 결과 목록 연결
  21. 필수 학습 · React Router v7 실습: BrowserRouter부터 Layout·Outlet·상세 경로까지
  22. 필수 학습 · shadcn/ui 시작 실습: Vite 설정·components.json·입력 폼·Sonner
  23. 필수 학습 · shadcn/ui 복합 컴포넌트 실습: Dialog·Popover·Carousel과 접근성

새 글 받아보기

RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.

RSS 피드 구독하기

댓글 남기기