React Hook Form 설치 사용법 첫 폼 만들기

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

React Hook Form으로 첫 폼의 입력 등록과 제출, 포커스를 어떻게 연결하나요?

React Hook Form은 입력 필드를 register로 연결하고 제출과 검증 상태를 관리합니다. 별도 폼에서 값을 입력해 제출하면 제출값을 표시하고 입력칸을 비운 뒤 포커스를 돌려봅니다.

적용 조건·범위: reset은 폼을 defaultValues 기준으로 되돌리고, setFocus는 등록된 입력의 참조로 포커스를 이동합니다. 아래 두 줄을 곧바로 연속 호출하는 방식은 주의해야 합니다. reset이 입력 참조를 해제할 수 있으므로 공식 문서는 reset 직후 setFocus 호출을 피하도록 안내합니다. 독립 전체 예제는 setValue로 제목만 비운 뒤 setFocus를 호출합니다.

본문 근거: React Hook Form

독립 실습: 제출·입력 초기화·포커스

폼을 제출하고 오류 메시지와 유효한 입력 결과를 비교하세요.

예제 크게 보기 (새 탭)

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

먼저 npm install react-hook-form을 실행합니다.

src/App.tsx 전체 코드

import { useState } from 'react';
import { useForm } from 'react-hook-form';
type FormValues = { title: string };
export default function App() {
  const [submitted, setSubmitted] = useState('');
  const { register, handleSubmit, setValue, setFocus } = useForm<FormValues>({ defaultValues: { title: '' } });
  function onSubmit(values: FormValues) {
    setSubmitted(values.title);
    setValue('title', '');
    setFocus('title');
  }
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <label>
        제목
        <input {...register('title')} />
      </label>
      <button type="submit">제출</button>
      <p role="status">제출값: {submitted}</p>
    </form>
  );
}

예상 결과와 직접 확인: 독서를 입력해 Enter로 제출하면 제출값: 독서가 보이고 입력칸은 비워지며 같은 입력칸에 포커스가 남습니다. 버튼 제출도 비교합니다.

변형 과제: useForm 반환값 구조 분해에 reset을 추가합니다. defaultValues의 title을 초깃값으로 바꾸고 setValue 줄을 reset()으로 교체한 뒤, 제출 후 빈 문자열과 초깃값의 차이를 확인합니다. 이 비교에서는 바로 다음 setFocus 호출을 잠시 빼고 값 복구만 관찰한 뒤 원래 코드로 돌립니다.

설치

프로젝트에서 다음 명령을 실행합니다.

npm install react-hook-form

설치 후 package.json에 의존성이 추가됐는지 확인합니다.

첫 useForm

이 절의 짧은 코드는 개념을 분해한 부분 코드입니다. 아래 독립 실습에는 import·타입·컴포넌트·이벤트 연결을 모두 포함한 App.tsx 전체 코드가 있습니다.

useForm 호출은 컴포넌트 함수 내부에 둡니다. 여기서는 반환값과 JSX 연결을 나누어 설명하며, 그대로 파일 최상단에서 Hook을 호출하는 예제가 아닙니다.

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

type FormValues = {
  title: string;
};

const {
  register,
  handleSubmit,
} = useForm<FormValues>();

register는 input을 React Hook Form에 등록합니다.

<input {...register('title')} />

제출 처리

handleSubmit이 폼 값을 모아서 onSubmit 함수에 전달합니다.

const onSubmit = (values: FormValues) => {
  console.log(values.title);
};

<form onSubmit={handleSubmit(onSubmit)}>

직접 event.preventDefault를 작성하지 않아도 React Hook Form이 제출 흐름을 관리합니다.

defaultValues

초기값은 useForm 옵션으로 지정합니다.

useForm<FormValues>({
  defaultValues: {
    title: '',
  },
});

reset과 setFocus

reset은 폼을 defaultValues 기준으로 되돌리고, setFocus는 등록된 입력의 참조로 포커스를 이동합니다. 아래 두 줄을 곧바로 연속 호출하는 방식은 주의해야 합니다. reset이 입력 참조를 해제할 수 있으므로 공식 문서는 reset 직후 setFocus 호출을 피하도록 안내합니다. 독립 전체 예제는 setValue로 제목만 비운 뒤 setFocus를 호출합니다.

reset();
setFocus('title');

이전에 useRef로 직접 DOM focus를 다뤘다면 폼 라이브러리가 제공하는 API로 같은 사용자 경험을 구현할 수 있습니다.

자주 발생하는 오류와 해결

  • register를 하지 않은 필드는 제출 값에 없음
  • field 이름 오타
  • handleSubmit을 거치지 않고 onSubmit을 직접 연결
  • defaultValues와 실제 폼 구조 불일치

학습 완료 체크리스트

  • react-hook-form을 설치할 수 있습니다.
  • useForm generic 타입을 지정할 수 있습니다.
  • register로 input을 등록할 수 있습니다.
  • handleSubmit으로 제출 값을 받을 수 있습니다.
  • setValue와 setFocus를 사용할 수 있습니다.

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

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

폼 입력 관리 책임을 React Hook Form으로 옮깁니다. 이번 회차에서는 복잡한 validation보다 설치와 기본 제출 흐름에 집중합니다.

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

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

TaskFlow 프로젝트 적용

TaskFlow 21단계 실행

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

예제 크게 보기 (새 탭)

시작 체크포인트: 이전: React 컴포넌트 props 타입 지정 부모 자식 값 구조의 완료 상태에서 이어갑니다.

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

변경 위치와 순서: package.json에는 react-hook-form을 설치하고 src/components/TaskForm.tsx의 입력값·제출 핸들러를 useForm으로 교체합니다. TaskProvider의 dispatch는 유지하며 등록된 title을 task/added 액션에 전달합니다. 완료본은 setValue로 비우므로 defaultValues를 복구하는 reset과 구분합니다.

독자 질문과 직접 확인

질문: 기존 TaskForm을 React Hook Form으로 바꾼 뒤 제출·초기화·포커스가 그대로 동작하나요?

컴포넌트 실습: step-21-react-hook-form에서 제목을 입력해 제출하고 같은 동작을 Enter와 버튼으로 반복합니다. register, handleSubmit, setValue, setFocus가 각각 어느 시점에 쓰이는지 본문 코드와 대조합니다.

예상 화면·상태: 유효한 제목은 한 번 추가되고 입력값이 비워진 다음 포커스가 돌아오는 것이 목표입니다. 이는 독자가 확인할 기준이며 이번 편집에서 실행해 관측한 결과가 아닙니다.

오류 점검: 제출이 안 되면 form의 onSubmit에 handleSubmit을 연결했는지, 포커스가 안 오면 등록한 필드 이름과 setFocus 인자가 같은지 확인합니다. 빌드·브라우저 검증은 이번 편집에서 미확인입니다.

다음 학습: 22회에서 필수·길이·공백 검증과 오류 메시지를 확인합니다.

완료 코드 ZIP 다운로드 — step-21-react-hook-form

이전 체크포인트

step-20-props-type-design

현재 상태

TaskForm이 useState, onChange, useRef를 직접 사용해 title과 focus를 관리합니다.

이번 목표

폼 입력 관리 책임을 React Hook Form으로 옮깁니다. 이번 회차에서는 복잡한 validation보다 설치와 기본 제출 흐름에 집중합니다.

변경 파일

package.json
src/components/TaskForm.tsx

나머지 TaskProvider, reducer, TaskList, TaskItem은 이전 구조를 유지합니다.

Before / After

Before
useState + onChange + useRef + 직접 submit 처리

After
useForm + register + handleSubmit + setValue + setFocus

실행 확인

  • 작업 제목을 입력하고 제출합니다.
  • 새 작업이 목록에 추가됩니다.
  • input이 초기화됩니다.
  • 포커스가 input으로 돌아옵니다.
npm run build

완료 체크포인트:

step-21-react-hook-form

전체 코드 뷰어의 모든 파일은 다운로드 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-21-react-hook-form",
  "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,
  } = useForm<TaskFormValues>({
    defaultValues: {
      title: '',
    },
  });

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

    if (!title) {
      setFocus('title');
      return;
    }

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

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

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

      <input
        id="task-title"
        placeholder="할 일을 입력하세요"
        {...register('title')}
      />

      <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 Hook Form 설치 사용법 첫 폼 만들기 학습 표지

React · 21/32 · TaskFlow 누적 실습

React Hook Form 설치 사용법 첫 폼 만들기

React · 21 / 32 · 필수 학습 · 라이브러리 최초 도입

React Hook Form은 폼 값을 등록하고 제출·검증 상태를 관리하는 데 사용하는 폼 라이브러리입니다. 이번 글은 React Hook Form이 React 과정에서 처음 등장하는 글이므로 설치부터 최소 동작 확인까지 모두 포함합니다.

별도 Vite React + TypeScript 프로젝트에서 필드를 등록하고, 제출한 값을 표시한 뒤 입력을 비우고 포커스를 돌리는 첫 폼을 만듭니다. 하단에서는 TaskFlow의 기존 제어 폼을 React Hook Form으로 교체합니다.

이번 글에서 배우는 내용

  • React Hook Form 설치
  • useForm
  • register
  • handleSubmit
  • defaultValues
  • setValue와 setFocus
  • 제어 컴포넌트 방식과 비교

목차

다음 학습

다음 글에서는 required, minLength 같은 validation을 추가하고 formState.errors에 오류가 나타나지 않을 때 확인할 항목을 정리합니다.

공식 문서

이 글이 도움이 되었나요?

조회 중

읽음 기록

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

이 글을 쓴 해비

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

새 글 받아보기

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

RSS 피드 구독하기

댓글 남기기