이번 편의 결과물: 폼 제출 후 제목 입력창에 자동으로 포커스가 돌아가고, 책을 추가하면 새 카드가 보이도록 목록이 자동 스크롤됩니다. 다루는 개념: useRef로 DOM 노드 참조, .focus()/.scrollIntoView() 호출, state와 ref의 차이(리렌더 여부)
이 편에서 만드는 파일
bookshelf/src/
├── components/
│ └── BookForm.jsx ~ titleInputRef 추가, 제출 후 focus 호출
├── components/
│ └── BookList.jsx ~ 목록 끝에 스크롤 대상 sentinel 추가
└── App.jsx ~ books.length 변경 시 스크롤 effect 추가개념 정리
useState로 관리하는 값이 바뀌면 컴포넌트가 리렌더됩니다. 하지만 “입력창에 포커스를 준다”, “특정 위치로 스크롤한다”처럼 DOM을 직접 조작하는 작업은 화면에 무엇을 그릴지와 무관합니다. 이럴 때 useRef를 씁니다.
useState | useRef | |
|---|---|---|
| 값이 바뀌면 | 리렌더된다 | 리렌더되지 않는다 |
| 값을 읽는 법 | 변수 자체 | .current 프로퍼티 |
| 용도 | 화면에 보여줄 데이터 | DOM 노드 참조, 렌더링과 무관한 값 저장 |
useRef(null)로 만든 ref를 JSX 요소의 ref 속성에 연결하면, React가 마운트 후 그 DOM 노드를 ref.current에 넣어 줍니다. 이후 ref.current.focus(), ref.current.scrollIntoView()처럼 브라우저 DOM API를 직접 호출할 수 있습니다.
useRef는 “리렌더링을 유발하지 않는 상자”이기도 합니다. 렌더링 결과에 영향을 주지 않는 값(예: 이전 값 기억, 타이머 id)을 저장할 때도 쓰지만, 이번 편에서는 DOM 접근 용도로만 사용합니다.
실습
1. 파일 열기
BookForm.jsx, BookList.jsx, App.jsx를 엽니다.
2. 코드 작성
BookForm에서 제목 입력창에 ref를 연결하고, 제출이 끝나면 그 입력창으로 포커스를 되돌립니다.
// src/components/BookForm.jsx
import { useState, useRef } from 'react';
const COVER_COLORS = ['#2563eb', '#16a34a', '#f59e0b', '#db2777', '#7c3aed', '#0891b2'];
function BookForm({ onAddBook }) {
const [title, setTitle] = useState('');
const [author, setAuthor] = useState('');
const [pages, setPages] = useState('');
const [memo, setMemo] = useState('');
const titleInputRef = useRef(null);
function handleSubmit(e) {
e.preventDefault();
if (title.trim() === '' || author.trim() === '') {
return;
}
const newBook = {
id: crypto.randomUUID(),
title: title.trim(),
author: author.trim(),
status: 'wish',
rating: 0,
pages: pages === '' ? 0 : Number(pages),
coverColor: COVER_COLORS[Math.floor(Math.random() * COVER_COLORS.length)],
startedAt: null,
finishedAt: null,
memo: memo.trim(),
};
onAddBook(newBook);
setTitle('');
setAuthor('');
setPages('');
setMemo('');
titleInputRef.current.focus();
}
return (
<form className="book-form" onSubmit={handleSubmit}>
<h2>책 추가</h2>
<label htmlFor="book-title">제목</label>
<input
id="book-title"
ref={titleInputRef}
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="예: 클린 코드"
/>
<label htmlFor="book-author">저자</label>
<input
id="book-author"
type="text"
value={author}
onChange={(e) => setAuthor(e.target.value)}
placeholder="예: 로버트 C. 마틴"
/>
<label htmlFor="book-pages">쪽수</label>
<input
id="book-pages"
type="number"
min="0"
value={pages}
onChange={(e) => setPages(e.target.value)}
placeholder="예: 584"
/>
<label htmlFor="book-memo">메모</label>
<textarea
id="book-memo"
value={memo}
onChange={(e) => setMemo(e.target.value)}
placeholder="선택 입력"
/>
<button type="submit">추가</button>
</form>
);
}
export default BookForm;스크롤은 조금 다릅니다. 새 카드가 어느 DOM 노드인지 폼은 알 수 없습니다. 대신 목록 맨 끝에 빈 sentinel(감시용) 요소를 하나 두고, 책 권수가 늘어날 때마다 그 요소로 스크롤합니다.
// src/components/BookList.jsx
import { forwardRef } from 'react';
import BookCard from './BookCard.jsx';
const BookList = forwardRef(function BookList(
{ books, onDelete, onRate, onToggleStatus },
scrollTargetRef
) {
if (books.length === 0) {
return <p className="empty-state">아직 등록된 책이 없습니다.</p>;
}
return (
<ul className="book-list">
{books.map((book) => (
<li key={book.id}>
<BookCard
book={book}
onDelete={onDelete}
onRate={onRate}
onToggleStatus={onToggleStatus}
/>
</li>
))}
<li ref={scrollTargetRef} className="scroll-sentinel" />
</ul>
);
});
export default BookList;App이 이 ref를 만들고, books.length가 바뀔 때마다 스크롤을 실행합니다.
// src/App.jsx (일부)
import { useState, useEffect, useRef } from 'react';
// ...기존 import 유지
function App() {
// ...기존 books 상태, useEffect 2개, 핸들러 함수 유지
const scrollTargetRef = useRef(null);
useEffect(() => {
scrollTargetRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}, [books.length]);
return (
<div className="app">
<h1>bookshelf</h1>
<p>총 {books.length}권</p>
<BookList
ref={scrollTargetRef}
books={books}
onDelete={handleDeleteBook}
onRate={handleRateBook}
onToggleStatus={handleToggleStatus}
/>
<BookForm onAddBook={handleAddBook} />
</div>
);
}
export default App;scrollTargetRef.current?.의 물음표는 아직 DOM에 연결되기 전(첫 렌더링 시점)에 current가 null일 수 있어 붙입니다. forwardRef는 함수 컴포넌트가 부모에게서 받은 ref를 자신이 아닌 내부 DOM 요소로 전달하고 싶을 때 씁니다.
3. 실행
npm run dev4. 확인
- 폼에 제목·저자를 입력하고 추가 버튼을 누르면, 입력창이 비워짐과 동시에 제목 입력창에 커서 깜빡임(포커스)이 보입니다.
- 책이 많아 목록이 화면 밖으로 넘칠 때 새 책을 추가하면 목록이 부드럽게 스크롤되어 맨 아래 sentinel 위치까지 이동합니다.
- 삭제나 별점 변경처럼
books.length가 그대로인 조작에서는 스크롤이 일어나지 않습니다.
직접 해보기
- Tab 키로 제목 입력창에 포커스를 준 상태에서 Enter로 제출해도 포커스가 유지되는지 확인해 보세요.
scrollIntoView의block옵션을'start'로 바꾸면 스크롤 위치가 어떻게 달라지는지 관찰해 보세요.
풀이 보기
titleInputRef.current.focus()는 제출이 끝난 뒤 항상 호출되므로 Enter로 제출해도 동일하게 포커스가 돌아갑니다. block: 'start'로 바꾸면 sentinel 요소가 뷰포트 맨 위에 오도록 스크롤되어, 기본값인 'nearest'보다 스크롤 거리가 길어질 수 있습니다.
자주 하는 실수
| 증상 | 원인 | 고치는 법 |
|---|---|---|
Cannot read properties of null (reading 'focus') 에러 | ref를 연결하기 전에 .current를 사용 | JSX의 ref 속성이 실제로 연결됐는지, 옵셔널 체이닝(?.)이 필요한지 확인 |
| 포커스가 전혀 안 돌아옴 | useRef(null)만 만들고 input의 ref 속성에 연결 안 함 | <input ref={titleInputRef} ...>처럼 요소에 직접 연결 |
| 별점만 바꿔도 스크롤됨 | 의존성 배열을 [books]로 둠 | 스크롤은 권수 변화에만 반응해야 하므로 [books.length] 사용 |
| ref 값이 바뀌어도 화면이 안 바뀜 | 당연한 동작 | useRef는 리렌더를 유발하지 않는다는 것이 정상 동작 |