React Hook Form 에러 메시지 표시 문제 해결 validation 체크리스트

··설명 약 6분·작성: 해비·블로그 소개

React Hook Form의 validation 오류 메시지가 보이지 않으면 무엇을 확인하나요?

React Hook Form의 오류 메시지가 안 보이면 검증 규칙, register의 필드 이름, formState.errors의 경로, 화면에 표시하는 조건을 차례로 맞춥니다. 빈 값·한 글자·공백·유효한 제목을 같은 폼에 제출해 비교합니다.

적용 조건·범위: required는 빈 문자열은 막지만 사용자가 공백만 입력한 경우까지 원하는 기준으로 막고 싶다면 validate를 추가할 수 있습니다.

본문 근거: React Hook Form

독립 실습: 필수·길이·공백 오류를 표시하는 최소 폼

잘못된 입력과 올바른 입력을 제출해 검증 결과를 비교하세요.

예제 크게 보기 (새 탭)

JavaScript 함수·배열과 TypeScript 객체 타입을 알고 시작합니다. 실행 환경은 React Vite 환경 준비의 React + TypeScript 프로젝트를 사용합니다. 이전 TaskFlow 코드는 필요 없습니다. 생성된 src/main.tsx는 유지하고 별도 연습 프로젝트의 src/App.tsx를 아래 전체 코드로 교체한 뒤 npm run dev로 여는 주소에서 확인합니다.

React Hook Form이 없는 연습 프로젝트에서는 npm install react-hook-form을 한 번 실행합니다.

src/App.tsx 전체 코드

import { useState } from 'react';
import { useForm } from 'react-hook-form';
type TaskFormValues = { title: string };
export default function App() {
  const [saved, setSaved] = useState('');
  const { register, handleSubmit, formState: { errors } } = useForm<TaskFormValues>();
  return (
    <form onSubmit={handleSubmit(values => setSaved(values.title))}>
      <label>제목
        <input {...register('title', {
          required: '제목을 입력하세요.',
          minLength: { value: 2, message: '2자 이상 입력하세요.' },
          validate: value => value.trim().length > 0 || '공백만 입력할 수 없습니다.',
        })} />
      </label>
      {errors.title && <p role="alert">{errors.title.message}</p>}
      <button type="submit">저장</button>
      <p>저장값: {saved}</p>
    </form>
  );
}

직접 확인: 빈 값·한 글자·공백 두 개를 각각 제출해 세 메시지를 확인합니다. 독서는 저장값에 나타나고 오류가 사라집니다.

변형 과제: errors.title 렌더링 부분만 없앴을 때 검증 실패와 오류 표시 누락을 구분합니다.

기본 validation

<input
  {...register('title', {
    required: '작업 제목을 입력해 주세요.',
    minLength: {
      value: 2,
      message: '2자 이상 입력해 주세요.',
    },
  })}
/>

검증에 실패하면 errors.title에 해당 필드 오류가 들어옵니다.

const {
  register,
  handleSubmit,
  formState: { errors },
} = useForm<TaskFormValues>();

오류가 있을 때 화면에 표시합니다.

{errors.title && (
  <p role="alert">
    {errors.title.message}
  </p>
)}

공백만 입력하는 경우

required는 빈 문자열은 막지만 사용자가 공백만 입력한 경우까지 원하는 기준으로 막고 싶다면 validate를 추가할 수 있습니다.

validate: (value) =>
  value.trim().length > 0 ||
  '공백만 입력할 수 없습니다.'

validation 메시지가 안 보일 때 체크리스트

1. register 이름과 errors 이름이 같은가

register('title')
errors.title

두 이름이 일치해야 합니다.

2. formState에서 errors를 꺼냈는가

formState: { errors }

3. 실제로 검증 규칙을 등록했는가

register만 하고 required 같은 규칙이 없다면 errors가 생기지 않습니다.

4. handleSubmit을 거치고 있는가

<form onSubmit={handleSubmit(onSubmit)}>

5. 메시지를 렌더링하고 있는가

errors가 있어도 JSX에서 사용하지 않으면 화면에는 보이지 않습니다.

자주 발생하는 오류

  • field name 오타
  • register를 다른 input에 붙임
  • errors.title 대신 다른 이름 확인
  • submit 버튼이 form 바깥에 있음
  • type=”button”이라 submit이 발생하지 않음
  • validation message가 없는 boolean 규칙만 사용해 화면 메시지를 기대함

학습 완료 체크리스트

  • register에 validation 규칙을 추가할 수 있습니다.
  • errors에서 필드 오류를 읽을 수 있습니다.
  • 오류 메시지를 화면에 표시할 수 있습니다.
  • validation이 동작하지 않을 때 순서대로 확인할 수 있습니다.

선택 실습: 할 일 앱에 적용하기

TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 React로 화면과 상태를 이어서 만듭니다.

TaskForm에 required·minLength·공백 검증을 추가하고 오류 메시지를 표시합니다.

선택: 연재 프로젝트와 전체 자료

연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.

TaskFlow 프로젝트 적용

TaskFlow 22단계 실행

시작 상태와 완성 결과를 바꾸어 본문에서 적용한 화면과 동작을 비교하세요.

예제 크게 보기 (새 탭)

시작 체크포인트: 이전: React Hook Form 설치 사용법 첫 폼 만들기의 완료 상태에서 이어갑니다.

이전 회차 완료 ZIP으로 시작하기. 새 폴더에 풀어 현재 회차 완료본과 나란히 비교합니다.

이전 완료본과 달라진 파일: package.json, src/components/TaskForm.tsx. 아래 변경 설명을 따라 수정하고 전체 코드의 같은 파일과 대조합니다.

변경 순서: src/components/TaskForm.tsx의 register 규칙을 추가하고 같은 title 키로 formState.errors를 렌더링합니다. 아래 완료 코드와 비교한 뒤 기존 실행 확인 절차를 수행합니다.

독자 질문과 직접 확인

질문: 유효성 검사에 걸렸는데 오류 메시지가 보이지 않는다면 무엇부터 확인하나요?

컴포넌트 실습: step-22-form-validation-fix에서 빈 값, 한 글자, 공백만 있는 값, 유효한 제목을 차례로 제출합니다. 필드 이름과 formState.errors의 조회 경로를 비교합니다.

예상 화면·상태: 앞의 세 경우에는 각각 해당 오류 안내가 보이고 유효한 제목은 목록에 추가된 뒤 입력값이 초기화되어야 합니다. 이는 독자가 확인할 기준이며 이번 편집에서 실행해 관측한 결과가 아닙니다.

오류 점검: 오류가 없으면 register 규칙·trim 검증·handleSubmit 연결을, 오류 객체만 있으면 메시지를 표시하는 조건부 UI를 점검합니다. 빌드·브라우저 검증은 이번 편집에서 미확인입니다.

다음 학습: 23회에서 검색어와 완료 상태 필터를 결합합니다.

완료 코드 ZIP 다운로드 — step-22-form-validation-fix

이전 체크포인트

step-21-react-hook-form

이번 목표

TaskForm에 required·minLength·공백 검증을 추가하고 오류 메시지를 표시합니다.

오류 재현

다음처럼 errors 필드 이름을 일부러 잘못 바꿔 봅니다.

{errors.taskTitle && (
  <p>{errors.taskTitle.message}</p>
)}

FormValues에는 taskTitle이 없으므로 TypeScript 단계에서도 문제를 발견할 수 있습니다. 이름을 title로 일치시키면 해결됩니다.

실행 확인

  • 빈 값 제출 → 필수 메시지
  • 한 글자 제출 → 길이 메시지
  • 공백만 제출 → 공백 메시지
  • 정상 값 제출 → 작업 추가 후 input 초기화

완료 체크포인트:

step-22-form-validation-fix

전체 코드 뷰어의 모든 파일은 다운로드 ZIP과 같습니다. ZIP은 독립 실행할 수 있는 완료 시점의 코드입니다. Node.js 22.12 이상에서 npm install 후 npm run dev로 실행하고, npm run build로 검사합니다.

전체 파일·코드 보기

실습 프로젝트 전체 코드

index.html

<!doctype html>
<html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>TaskFlow</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>

package.json

{
  "name": "taskflow-step-22-form-validation-fix",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "engines": {
    "node": ">=22.12.0"
  },
  "scripts": {
    "dev": "vite --host 127.0.0.1",
    "build": "tsc --noEmit && vite build",
    "preview": "vite preview --host 127.0.0.1"
  },
  "dependencies": {
    "react": "19.3.0",
    "react-dom": "19.3.0",
    "react-hook-form": "7.88.0"
  },
  "devDependencies": {
    "typescript": "7.0.2",
    "vite": "8.3.0",
    "@vitejs/plugin-react": "6.1.1",
    "@types/react": "19.3.0",
    "@types/react-dom": "19.3.0",
    "@types/node": "26.6.2"
  }
}

src/App.tsx

import TaskSummary from './components/TaskSummary';
import NetworkStatus from './components/NetworkStatus';
import StateResetDemo from './components/StateResetDemo';
import { useEffect, useState } from 'react';
import Header from './components/Header';
import TaskForm from './components/TaskForm';
import TaskList from './components/TaskList';
import { useTaskStats } from './hooks/useTaskStats';

function App() {

  const {
    totalCount,
    completedCount,
    remainingCount,
  } = useTaskStats();

  useEffect(() => {
    document.title = `TaskFlow (${remainingCount})`;
  }, [remainingCount]);


  return (
    <main className="app">
      <Header />

      <NetworkStatus />

      <TaskSummary total={totalCount} completed={completedCount} remaining={remainingCount} />

      <TaskForm />
      <TaskList />
    <StateResetDemo />
    </main>
  );
}

export default App;

src/components/Header.tsx

export default function Header() {
  return (
    <header>
      <p className="eyebrow">React Learning Project</p>
      <h1>TaskFlow</h1>
      <p>오늘 할 일을 하나씩 정리해 보세요.</p>
    </header>
  );
}

src/components/NetworkStatus.tsx

import { useState } from 'react';
import { useNetworkStatus } from '../hooks/useNetworkStatus';
export default function NetworkStatus() {
 const [showDebugMessage, setShowDebugMessage] = useState(false);
 const isOnline = useNetworkStatus(showDebugMessage);
 return <section aria-label="네트워크 상태"><p>네트워크: {isOnline ? '온라인' : '오프라인'}</p>
 <label><input type="checkbox" checked={showDebugMessage} onChange={e => setShowDebugMessage(e.target.checked)} />네트워크 로그 표시</label></section>;
}

src/components/StateResetDemo.tsx

import { useState } from 'react';
import TaskEditor from './TaskEditor';
import type { Task } from '../types/task';
const examples: Task[] = [
 { id: 'example-1', title: '첫 번째 작업', completed: false },
 { id: 'example-2', title: '두 번째 작업', completed: false },
];
export default function StateResetDemo() {
 const [selectedId, setSelectedId] = useState(examples[0].id);
 const selectedTask = examples.find(task => task.id === selectedId)!;
 return <details><summary>state 초기화 실습</summary>
 <p>입력값은 저장되지 않습니다. 작업을 바꾸면 key가 달라져 입력값이 초기화됩니다.</p>
 {examples.map(task => <button type="button" key={task.id} onClick={() => setSelectedId(task.id)}>{task.title} 선택</button>)}
 <TaskEditor key={selectedTask.id} task={selectedTask} />
 </details>;
}

src/components/TaskCard.tsx

import type { ReactNode } from 'react';

type TaskCardProps = {
  children: ReactNode;
  actions?: ReactNode;
};

export default function TaskCard({
  children,
  actions,
}: TaskCardProps) {
  return (
    <li className="task-card">
      <div className="task-card__content">
        {children}
      </div>
      {actions ? (
        <div className="task-card__actions">
          {actions}
        </div>
      ) : null}
    </li>
  );
}

src/components/TaskEditor.tsx

import { useState } from 'react';
import type { Task } from '../types/task';

type TaskEditorProps = {
  task: Task;
};

export default function TaskEditor({
  task,
}: TaskEditorProps) {
  const [title, setTitle] = useState(task.title);

  return (
    <section>
      <h2>작업 편집</h2>
      <label htmlFor="editor-title">편집 입력 연습</label>
      <input id="editor-title"
        value={title}
        onChange={(event) => setTitle(event.target.value)}
      />
      <p>선택한 작업 ID: {task.id}</p>
    </section>
  );
}

src/components/TaskForm.tsx

import { useForm } from 'react-hook-form';
import { useTaskContext } from '../context/TaskContext';

type TaskFormValues = {
  title: string;
};

export default function TaskForm() {
  const { dispatch } = useTaskContext();

  const {
    register,
    handleSubmit,
    setValue,
    setFocus,
    formState: { errors },
  } = useForm<TaskFormValues>({
    defaultValues: {
      title: '',
    },
  });

  const onSubmit = (values: TaskFormValues) => {
    const title = values.title.trim();

    dispatch({
      type: 'task/added',
      task: {
        id: crypto.randomUUID(),
        title,
        completed: false,
      },
    });

    setValue('title', '');
    setFocus('title');
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} noValidate>
      <label htmlFor="task-title">
        새 작업
      </label>

      <input
        id="task-title"
        aria-invalid={Boolean(errors.title)}
        aria-describedby={
          errors.title ? 'task-title-error' : undefined
        }
        {...register('title', {
          setValueAs: (value: string) => value.trim(),
          required: '작업 제목을 입력해 주세요.',
          minLength: {
            value: 2,
            message: '작업 제목은 2자 이상 입력해 주세요.',
          },
          validate: (value) =>
            value.trim().length > 0 ||
            '공백만 입력할 수 없습니다.',
        })}
      />

      {errors.title && (
        <p id="task-title-error" role="alert">
          {errors.title.message}
        </p>
      )}

      <button type="submit">
        추가
      </button>
    </form>
  );
}

src/components/TaskItem.tsx

import TaskCard from './TaskCard';
import { useTaskContext } from '../context/TaskContext';
import type { Task } from '../types/task';

type TaskItemProps = {
  task: Task;
};

export default function TaskItem({
  task,
}: TaskItemProps) {
  const { dispatch } = useTaskContext();

  return (
    <TaskCard>
      <button
        type="button"
        onClick={() =>
          dispatch({
            type: 'task/toggled',
            id: task.id,
          })
        }
      >
        {task.completed ? '완료 취소' : '완료'}
      </button>

      <span>{task.title}</span>

      <button
        type="button"
        onClick={() =>
          dispatch({
            type: 'task/deleted',
            id: task.id,
          })
        }
      >
        삭제
      </button>
    </TaskCard>
  );
}

src/components/TaskList.tsx

import { useTaskContext } from '../context/TaskContext';
import TaskItem from './TaskItem';

export default function TaskList() {
  const { tasks } = useTaskContext();

  if (tasks.length === 0) {
    return <p>등록된 작업이 없습니다.</p>;
  }

  return (
    <ul>
      {tasks.map((task) => (
        <TaskItem key={task.id} task={task} />
      ))}
    </ul>
  );
}

src/components/TaskSummary.tsx

type TaskSummaryProps = {
  total: number;
  completed: number;
  remaining: number;
  label?: string;
};

export default function TaskSummary({
  total,
  completed,
  remaining,
  label = '작업 현황',
}: TaskSummaryProps) {
  return (
    <section aria-label={label}>
      <p>
        전체 {total} · 완료 {completed} · 남음 {remaining}
      </p>
    </section>
  );
}

src/context/TaskContext.tsx

import {
  createContext,
  useContext,
  useReducer,
  type Dispatch,
  type ReactNode,
} from 'react';
import { taskReducer } from '../reducers/taskReducer';
import type {
  Task,
  TaskAction,
} from '../types/task';

type TaskContextValue = {
  tasks: Task[];
  dispatch: Dispatch<TaskAction>;
};

const TaskContext = createContext<
  TaskContextValue | undefined
>(undefined);

const initialTasks: Task[] = [
  {
    id: 'task-1',
    title: 'Reducer와 Context 연결하기',
    completed: false,
  },
];

export function TaskProvider({
  children,
}: {
  children: ReactNode;
}) {
  const [tasks, dispatch] = useReducer(
    taskReducer,
    initialTasks,
  );

  return (
    <TaskContext.Provider
      value={{ tasks, dispatch }}
    >
      {children}
    </TaskContext.Provider>
  );
}

export function useTaskContext() {
  const value = useContext(TaskContext);

  if (!value) {
    throw new Error(
      'useTaskContext는 TaskProvider 안에서 사용해야 합니다.',
    );
  }

  return value;
}

src/hooks/useNetworkStatus.ts

import {
  useEffect,
  useEffectEvent,
  useState,
} from 'react';

export function useNetworkStatus(
  showDebugMessage: boolean,
) {
  const [isOnline, setIsOnline] = useState(
    navigator.onLine,
  );

  const onOnline = useEffectEvent(() => {
    if (showDebugMessage) {
      console.log('TaskFlow가 온라인 상태입니다.');
    }

    setIsOnline(true);
  });

  const onOffline = useEffectEvent(() => {
    if (showDebugMessage) {
      console.log('TaskFlow가 오프라인 상태입니다.');
    }

    setIsOnline(false);
  });

  useEffect(() => {
    const handleOnline = () => onOnline();
    const handleOffline = () => onOffline();

    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);

    return () => {
      window.removeEventListener(
        'online',
        handleOnline,
      );
      window.removeEventListener(
        'offline',
        handleOffline,
      );
    };
  }, []);

  return isOnline;
}

src/hooks/useTaskStats.ts

import { useTaskContext } from '../context/TaskContext';

export function useTaskStats() {
  const { tasks } = useTaskContext();

  const totalCount = tasks.length;

  const completedCount = tasks.filter(
    (task) => task.completed,
  ).length;

  const remainingCount =
    totalCount - completedCount;

  return {
    totalCount,
    completedCount,
    remainingCount,
  };
}

src/index.css

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  font-family: Arial, sans-serif;
  color: #111;
  background: #f5f5f5;
}

.app {
  width: min(720px, calc(100% - 32px));
  margin: 48px auto;
}

.eyebrow {
  color: #666;
  font-size: 14px;
}

ul {
  padding-left: 20px;
}

button, input, select { min-height: 44px; font: inherit; }
button, input, select { border: 1px solid #aaa; padding: 8px 12px; }
button { cursor: pointer; }
button:disabled { cursor: wait; opacity: .6; }
ul { list-style: none; padding: 0; display: grid; gap: 12px; }
.task-card { border: 1px solid #ccc; padding: 16px; display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
.task-card__content, .task-card__actions { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
form, section { margin-block: 20px; }
input:not([type="checkbox"]), select { max-width: 100%; }
details { margin-block: 24px; } summary { cursor: pointer; min-height: 44px; }
body { overflow-wrap: anywhere; }
[role="dialog"], [data-radix-popper-content-wrapper] { --radius: 0; }

src/main.tsx

import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App';
import { TaskProvider } from './context/TaskContext';

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

src/reducers/taskReducer.ts

import type {
  Task,
  TaskAction,
} from '../types/task';

export function taskReducer(
  tasks: Task[],
  action: TaskAction,
): Task[] {
  switch (action.type) {
    case 'task/added':
      return [...tasks, action.task];

    case 'task/toggled':
      return tasks.map((task) =>
        task.id === action.id
          ? { ...task, completed: !task.completed }
          : task,
      );

    case 'task/deleted':
      return tasks.filter(
        (task) => task.id !== action.id,
      );

    default:
      return tasks;
  }
}

src/types/task.ts

export type TaskId = string;

export type Task = {
  id: TaskId;
  title: string;
  completed: boolean;
};

export type TaskAction =
  | {
      type: 'task/added';
      task: Task;
    }
  | {
      type: 'task/toggled';
      id: TaskId;
    }
  | {
      type: 'task/deleted';
      id: TaskId;
    };

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "lib": [
      "ES2022",
      "DOM",
      "DOM.Iterable"
    ],
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "jsx": "react-jsx",
    "strict": true,
    "skipLibCheck": true,
    "noEmit": true,
    "allowImportingTsExtensions": true,
    "resolveJsonModule": true,
    "esModuleInterop": true,
    "types": [
      "vite/client"
    ],
    "paths": {
      "@/*": [
        "./src/*"
      ]
    }
  },
  "include": [
    "src",
    "vite.config.ts"
  ]
}

vite.config.ts

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({ plugins: [react()] });

글 소개·학습 준비·전체 목차

폼 검증 흐름: register, validation, errors

React · 22/32 · TaskFlow 누적 실습

React Hook Form 에러 메시지 표시 문제 해결 validation 체크리스트

React · 22 / 32 · 선택 참고 · React Hook Form 오류 해결

React Hook Form을 설치하고 register까지 연결했는데도 오류 메시지가 보이지 않는 경우가 있습니다. 대부분은 validation 규칙, field 이름, formState.errors 접근, 메시지 렌더링 조건 중 하나가 맞지 않아서 발생합니다.

이 글은 TaskFlow를 모르는 독자도 validation 오류만 독립적으로 해결할 수 있게 체크리스트 중심으로 구성합니다.

이번 글에서 배우는 내용

  • register에 validation 규칙 추가
  • required와 minLength
  • validate로 공백 입력 막기
  • formState.errors 읽기
  • role=”alert”로 오류 메시지 표시
  • validation이 동작하지 않을 때 확인할 순서

목차

다음 학습

다음 글에서는 작업이 많아졌을 때 필요한 검색어와 완료 상태 필터를 추가합니다. 검색 결과는 별도 state에 저장하지 않고 원본 tasks에서 계산합니다.

공식 문서

이 글이 도움이 되었나요?

조회 중

읽음 기록

읽음 — / 전체 33개 · 필수 27개

이 글을 쓴 해비

웹사이트를 제작·운영하고, 개인 서비스와 자동화·AI 도구를 만듭니다. 직접 만들며 배운 내용과 문제 해결 과정을 BlogFlow에 기록합니다.

새 글 받아보기

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

RSS 피드 구독하기

댓글 남기기