Skip to Content
WebReactReact 기초12. useRef로 포커스·스크롤 다루기

이번 편의 결과물: 폼 제출 후 제목 입력창에 자동으로 포커스가 돌아가고, 책을 추가하면 새 카드가 보이도록 목록이 자동 스크롤됩니다. 다루는 개념: 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를 씁니다.

useStateuseRef
값이 바뀌면리렌더된다리렌더되지 않는다
값을 읽는 법변수 자체.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에 연결되기 전(첫 렌더링 시점)에 currentnull일 수 있어 붙입니다. forwardRef는 함수 컴포넌트가 부모에게서 받은 ref를 자신이 아닌 내부 DOM 요소로 전달하고 싶을 때 씁니다.

3. 실행

npm run dev

4. 확인

  • 폼에 제목·저자를 입력하고 추가 버튼을 누르면, 입력창이 비워짐과 동시에 제목 입력창에 커서 깜빡임(포커스)이 보입니다.
  • 책이 많아 목록이 화면 밖으로 넘칠 때 새 책을 추가하면 목록이 부드럽게 스크롤되어 맨 아래 sentinel 위치까지 이동합니다.
  • 삭제나 별점 변경처럼 books.length가 그대로인 조작에서는 스크롤이 일어나지 않습니다.

직접 해보기

  1. Tab 키로 제목 입력창에 포커스를 준 상태에서 Enter로 제출해도 포커스가 유지되는지 확인해 보세요.
  2. scrollIntoViewblock 옵션을 '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는 리렌더를 유발하지 않는다는 것이 정상 동작

확인 문제

문제 14지선다
useRef와 useState의 가장 중요한 차이는 무엇인가
문제 24지선다
JSX 요소의 ref 속성에 useRef로 만든 값을 연결하면 어떤 일이 일어나는가
문제 34지선다
이번 편에서 스크롤 effect의 의존성 배열을 books.length로 둔 이유는 무엇인가

참고 자료

Last updated on