요약
화면에 표시할 값은 state로, 렌더링에 필요 없는 기억이나 실제 DOM 요소는 ref로 다룹니다. ref.current를 바꿔도 새 렌더링을 요청하지 않습니다.
독립 실습: 버튼에서 input으로 포커스 보내기
이름을 입력하고 버튼으로 입력란에 초점을 이동하세요.
예제 크게 보기 (새 탭)JavaScript 함수·배열과 TypeScript 객체 타입을 알고 시작합니다. 실행 환경은 React Vite 환경 준비의 React + TypeScript 프로젝트를 사용합니다. 이전 TaskFlow 코드는 필요 없습니다. 생성된 src/main.tsx는 유지하고 별도 연습 프로젝트의 src/App.tsx를 아래 전체 코드로 교체한 뒤 npm run dev로 여는 주소에서 확인합니다.
src/App.tsx 전체 코드
import { useRef, useState } from 'react';
export default function App() {
const inputRef = useRef<HTMLInputElement>(null);
const [name, setName] = useState('');
return (
<main>
<input ref={inputRef} aria-label="이름" value={name} onChange={e => setName(e.target.value)} />
<button onClick={() => inputRef.current?.focus()}>입력으로 이동</button>
<p>입력한 이름: {name}</p>
</main>
);
}
직접 확인: 버튼을 클릭한 다음 바로 글자를 입력하면 input과 문단에 같은 이름이 나타납니다. ref는 DOM 접근, name state는 화면 값을 담당합니다.
변형 과제: 버튼 동작에 setName('')을 추가해 초기화와 포커스 이동을 각각 담당하는 줄을 구분합니다.
useRef 기본 구조
import { useRef } from 'react';
const inputRef = useRef<HTMLInputElement>(null);
JSX 요소의 ref 속성에 전달합니다.
<input ref={inputRef} />
React가 DOM 요소를 만든 뒤 inputRef.current에 실제 input 노드를 연결합니다.
이후 이벤트 핸들러에서 focus를 호출할 수 있습니다.
inputRef.current?.focus();
useRef와 state는 언제 구분하나요?
값이 바뀔 때 화면도 다시 그려야 하면 state를 사용합니다. DOM에 포커스를 보내거나 렌더링 결과에 쓰이지 않는 값을 기억하려면 ref를 사용합니다. ref.current를 바꿔도 React는 새 렌더링을 요청하지 않습니다.
| 질문 | 선택 | 이 글의 예 |
|---|---|---|
| 값 변경을 화면에 보여야 하나요? | state | 입력한 이름을 문단에 표시 |
| 실제 DOM 요소에 접근하나요? | ref | inputRef.current?.focus() |
| 입력값을 비우고 포커스도 옮기나요? | 역할을 나누어 사용 | setName('')로 값 변경 후 ref로 포커스 이동 |
적용 환경은 이 글의 React + TypeScript 실습입니다. React 공식 useRef 문서에서 API 동작을 확인할 수 있습니다. 실제 입력·포커스 동작은 아래 독립 실습에서 확인하세요.
state:
– 화면 출력에 사용되는 값
– 변경하면 다시 렌더링
– JSX 결과에 영향을 주는 데이터에 적합
ref:
– 렌더링 결과와 직접 관련 없는 값
– ref.current 변경은 재렌더링을 일으키지 않음
– DOM 접근, 타이머 id, 이전 값 보관 등에 적합
화면에 표시해야 하는 숫자를 ref에 넣으면 값은 바뀌어도 화면이 다시 렌더링되지 않습니다.
렌더링 중 ref를 수정하지 않습니다
일반적으로 다음 패턴은 피합니다.
function Example() {
const valueRef = useRef(0);
valueRef.current += 1;
return <p>{valueRef.current}</p>;
}
렌더링은 같은 입력에 대해 같은 결과를 만드는 순수한 계산으로 유지하는 것이 기본입니다. ref 읽기·수정은 이벤트 핸들러나 Effect 같은 위치에서 처리합니다.
자주 발생하는 오류와 해결
ref.current가 null입니다
DOM이 아직 만들어지기 전이거나 해당 요소가 조건부로 렌더링되지 않는 상태일 수 있습니다.
이벤트 시점에서 optional chaining을 사용해 안전하게 접근합니다.
inputRef.current?.focus();
화면에 표시할 값을 ref로 관리했습니다
화면이 바뀌어야 한다면 state를 사용합니다.
학습 완료 체크리스트
- ref와 state의 차이를 설명할 수 있습니다.
- DOM input에 ref를 연결할 수 있습니다.
- 이벤트 핸들러에서 focus를 호출할 수 있습니다.
- 렌더링 중 ref 접근을 피해야 하는 이유를 설명할 수 있습니다.
선택 실습: 할 일 앱에 적용하기
TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 React로 화면과 상태를 이어서 만듭니다.
작업 추가가 끝나면 입력창에 다시 포커스를 이동해 연속으로 작업을 입력하기 쉽게 만듭니다.
선택: 연재 프로젝트와 전체 자료
연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.
TaskFlow 프로젝트 적용
TaskFlow 12단계 실행
시작 상태와 완성 결과를 바꾸어 본문에서 적용한 화면과 동작을 비교하세요.
예제 크게 보기 (새 탭)시작 체크포인트: 이전: React 제어 컴포넌트 폼과 상태 끌어올리기 첫 실습 가이드의 완료 상태에서 이어갑니다.
이전 회차 완료 ZIP으로 시작하기. 새 폴더에 풀어 현재 회차 완료본과 나란히 비교합니다.
이전 완료본과 달라진 파일: package.json, src/App.tsx, src/components/TaskForm.tsx. 아래 변경 설명을 따라 수정하고 전체 코드의 같은 파일과 대조합니다.
변경 순서: src/components/TaskForm.tsx에서 inputRef를 선언하고 input의 ref에 연결한 뒤 성공 제출 처리 마지막에 focus()를 호출합니다. 아래 완료 코드와 비교한 뒤 기존 실행 확인 절차를 수행합니다.
독자 질문과 직접 확인
질문: 작업 추가 뒤 입력 칸으로 커서를 돌릴 때 왜 state보다 ref가 알맞나요?
컴포넌트 실습: step-12-ref에서 입력 후 Enter로 작업을 추가하고 바로 키보드로 다음 작업 제목을 입력합니다. input ref의 focus() 호출과 값 초기화를 분리해 확인합니다.
예상 화면·상태: 제출 뒤 목록에 작업이 추가되고 입력값은 비워지며 커서는 다시 input에 있어야 합니다. 포커스 이동은 화면에 표시할 데이터가 아니므로 ref를 사용합니다. 이 내용은 독자가 확인할 기준이며 이번 편집에서 앱을 실행하거나 브라우저 화면을 관찰한 결과가 아닙니다.
오류 점검: 커서가 이동하지 않으면 ref가 실제 input에 연결됐는지와 focus()가 제출 처리 뒤에 호출되는지 확인합니다. 빌드·브라우저 검증은 이번 편집에서 미확인입니다.
다음 학습: 13회에서는 미완료 작업 수를 document.title과 동기화합니다.
이전 체크포인트
step-11-controlled-form
현재 상태
TaskForm에서 입력 후 작업이 추가되고 input 값은 빈 문자열로 초기화됩니다.
이번 목표
작업 추가가 끝나면 입력창에 다시 포커스를 이동해 연속으로 작업을 입력하기 쉽게 만듭니다.
실행 확인
- input에 작업을 입력합니다.
- Enter 또는 추가 버튼으로 제출합니다.
- 작업이 목록에 추가됩니다.
- input 값이 비워집니다.
- 커서가 다시 input에 위치합니다.
완료 체크포인트:
step-12-ref
전체 코드 뷰어의 모든 파일은 다운로드 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-12-ref",
"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"
},
"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 StateResetDemo from './components/StateResetDemo';
import { useState } from 'react';
import Header from './components/Header';
import TaskForm from './components/TaskForm';
import TaskList from './components/TaskList';
import type { Task } from './types/task';
const initialTasks: Task[] = [
{
id: 'task-1',
title: 'useRef로 포커스 이동하기',
completed: false,
},
];
function App() {
const [tasks, setTasks] = useState<Task[]>(initialTasks);
const handleAdd = (title: string) => {
setTasks((previousTasks) => [
...previousTasks,
{
id: crypto.randomUUID(),
title,
completed: false,
},
]);
};
const handleToggle = (id: string) => {
setTasks((previousTasks) =>
previousTasks.map((task) =>
task.id === id
? { ...task, completed: !task.completed }
: task,
),
);
};
const handleDelete = (id: string) => {
setTasks((previousTasks) =>
previousTasks.filter((task) => task.id !== id),
);
};
return (
<main className="app">
<Header />
<TaskForm onAdd={handleAdd} />
<TaskList
tasks={tasks}
onToggle={handleToggle}
onDelete={handleDelete}
/>
<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/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 {
useRef,
useState,
type FormEvent,
} from 'react';
type TaskFormProps = {
onAdd: (title: string) => void;
};
export default function TaskForm({
onAdd,
}: TaskFormProps) {
const [title, setTitle] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
const handleSubmit = (
event: FormEvent<HTMLFormElement>,
) => {
event.preventDefault();
const trimmedTitle = title.trim();
if (!trimmedTitle) {
inputRef.current?.focus();
return;
}
onAdd(trimmedTitle);
setTitle('');
inputRef.current?.focus();
};
return (
<form onSubmit={handleSubmit}>
<label htmlFor="task-title">
새 작업
</label>
<input
ref={inputRef}
id="task-title"
value={title}
onChange={(event) =>
setTitle(event.target.value)
}
placeholder="할 일을 입력하세요"
/>
<button type="submit">추가</button>
</form>
);
}
src/components/TaskItem.tsx
import type { Task } from '../types/task';
import TaskCard from './TaskCard';
type TaskItemProps = {
task: Task;
onToggle: (id: string) => void;
onDelete: (id: string) => void;
};
export default function TaskItem({
task,
onToggle,
onDelete,
}: TaskItemProps) {
return (
<TaskCard
actions={
<>
<button
type="button"
onClick={() => onToggle(task.id)}
>
{task.completed ? '완료 취소' : '완료'}
</button>
<button
type="button"
onClick={() => onDelete(task.id)}
>
삭제
</button>
</>
}
>
<span>{task.title}</span>
</TaskCard>
);
}
src/components/TaskList.tsx
import TaskItem from './TaskItem';
import type { Task } from '../types/task';
type TaskListProps = {
tasks: Task[];
onToggle: (id: string) => void;
onDelete: (id: string) => void;
};
export default function TaskList({
tasks,
onToggle,
onDelete,
}: TaskListProps) {
if (tasks.length === 0) {
return <p>등록된 작업이 없습니다.</p>;
}
return (
<ul>
{tasks.map((task) => (
<TaskItem
key={task.id}
task={task}
onToggle={onToggle}
onDelete={onDelete}
/>
))}
</ul>
);
}
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';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
src/types/task.ts
export type Task = {
id: string;
title: string;
completed: boolean;
};
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 · 12/32 · TaskFlow 누적 실습
React useRef 실습 입력 포커스와 state 역할 나누기
React · 12 / 32 · 필수 학습 · useRef
state는 화면에 보여야 하는 값을 관리할 때 사용합니다. 반면 렌더링에는 필요하지 않지만 컴포넌트가 기억해야 하는 값이나 실제 DOM 요소에 접근해야 할 때는 ref가 더 적합할 수 있습니다.
React 공식 문서에서도 useRef는 렌더링에 필요하지 않은 값을 참조하는 Hook으로 설명하며, ref.current를 바꿔도 컴포넌트는 다시 렌더링되지 않습니다.
이번 글에서 배우는 내용
- useRef가 반환하는 ref 객체
- ref.current
- ref와 state의 차이
- DOM 요소에 ref 연결
- input.focus() 호출
- 렌더링 중 ref를 읽거나 수정하면 안 되는 이유
목차
다음 학습
다음 글에서는 React 바깥의 시스템과 상태를 맞출 때 사용하는 useEffect를 배웁니다. TaskFlow에서는 미완료 작업 수를 브라우저 document.title과 동기화합니다.
공식 문서
이 글이 도움이 되었나요?
읽음 기록
읽음 — / 전체 33개 · 필수 27개
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.