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()] });
글 소개·학습 준비·전체 목차

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개
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.