이 글은 Udemy의 【한글자막】 React 완벽 가이드 2025 with React Router & Redux 를 수강하고 정리한 내용입니다.
👉 useRef를 이용하여 만든 Timer Challenge 방문하기
https://an0401na.github.io/React_Study/08-Refs-Portals/
Refs & Portals
The Almost Final Countdown Stop the timer once you estimate that time is (almost) up
an0401na.github.io
🎈Refs (참조) 란?
Refs(References)는 React에서 특정 DOM 요소나 클래스형 컴포넌트의 인스턴스에 직접 접근할 수 있도록 도와주는 기능입니다.
🎈 상태 vs 참조 (useState vs useRef)
✅ 상태(useState)를 사용해야 할 경우:
- UI에 즉시 반영되어야 하는 값
- 상태 변경 시 컴포넌트를 다시 렌더링해야 하는 경우
✅ 상태를 사용하지 말아야 할 경우:
- UI에 영향을 주지 않는 값 (ex. 타이머 ID, 이전 값 저장 등)
- 내부적으로만 관리되며, 렌더링과 관계없는 값
✅ 참조(useRef)를 사용해야 할 경우:
- 컴포넌트가 다시 실행되지 않도록 해야 하는 값
- 참조 값 변경 → 컴포넌트 리렌더링 없음
- DOM 요소에 직접 접근해야 할 때 (ex. focus(), scrollTo() 등)
🎈 useRef (참조)를 사용하면 왜 리렌더링 되지 않을까?
React에서는 상태가 변경되면 리렌더링이 트리거됩니다. 이때 useState는 상태를 저장하고 관리하는 데 사용되며, 상태 값이 변경되면 컴포넌트를 리렌더링합니다. 반면, useRef는 상태 값을 저장하지 않고 단순히 값을 참조하는 용도로 사용됩니다.
useRef로 관리되는 값은 렌더링과 관계없는 값입니다. 예를 들어, layerName.current.value처럼 current는 참조 객체로, 그 안의 value는 값이 됩니다. 여기서 value가 변경되더라도 React는 이를 감지하지 않습니다. 즉, useRef는 값이 변해도 리렌더링을 트리거하지 않기 때문에 UI에 변화가 반영되지 않습니다.
useRef는 주로 DOM 요소에 직접 접근하거나, 렌더링과 관련 없는 값을 저장하는 데 유용합니다. 예를 들어, 타이머 ID, 폼 입력 값 등과 같이 컴포넌트 리렌더링 없이 유지해야 하는 값들을 useRef로 관리하는 것이 적합합니다.
🎈useRef 훅으로 JSX 요소 연결하기
useRef 훅은 리렌더링 없이 특정 DOM 요소에 접근할 때 사용됩니다.
이 예제에서는 useRef를 사용하여 <input> 요소를 참조하고, 버튼 클릭 시 입력된 값을 가져와 useRef를 업데이트하는 방식으로 동작합니다.
import { useState, useRef } from "react";
export default function Player() {
// useRef를 사용하여 input 요소에 대한 참조 생성
const playerName = useRef();
// 플레이어 이름을 상태로 관리 (null 초기값)
const [enteredPlayerName, setEnteredPlayerName] = useState(null);
function handleClick() {
// useRef를 통해 input 요소의 현재 값 가져오기
setEnteredPlayerName(playerName.current.value);
}
return (
<section id="player">
{/* enteredPlayerName이 있으면 표시, 없으면 'unknown entity' 출력 */}
<h2>Welcome {enteredPlayerName ?? "unknown entity"}</h2>
<p>
{/* input 요소에 ref 속성 연결 */}
<input ref={playerName} type="text" />
{/* 버튼 클릭 시 handleClick 함수 실행 */}
<button onClick={handleClick}>Set Name</button>
</p>
</section>
);
}
1️⃣ useRef로 참조 생성
- const playerName = useRef();
- playerName은 특정 DOM 요소를 참조하는 객체가 됩니다.
2️⃣ JSX에서 ref 속성을 사용하여 요소와 연결
<input ref={playerName} type="text" />
- 이 input 요소는 이제 playerName.current를 통해 접근할 수 있습니다.
3️⃣ 버튼 클릭 시 input 값을 가져와 상태 업데이트
function handleClick() {
setEnteredPlayerName(playerName.current.value);
}
- playerName.current.value를 읽어 enteredPlayerName 상태로 저장합니다.
🎈useRef 으로 타이머 제어하기
시작한 타이머를 멈추려면 setTimeout의 반환값(타이머 ID)이 필요하기 때문에 clearTimeout(timerId)와 같이 호출하면 해당 타이머가 취소할 수 있습니다. 그러나 이때 변수를 사용하게 되면 문제가 발생하게 되는데 이런 경우를 아래 예제를 통해 알아보도록 합시다.
1️⃣ 변수(let timer 사용)의 문제점
import { useState } from "react";
let timer; // ❌ 전역 변수 사용 (문제 발생 가능)
function TimerChallenge() {
const [timerExpired, setTimerExpired] = useState(false);
function handleStart() {
timer = setTimeout(() => {
console.log("타이머 끝!");
setTimerExpired(true);
}, 5000);
}
function handleStop() {
clearTimeout(timer);
}
return (
<div>
<button onClick={handleStart}>Start</button>
<button onClick={handleStop}>Stop</button>
</div>
);
}
문제 발생:
🚨 let timer가 전역 변수라 여러 개의 타이머를 관리하기 여렵다는 문제가 발생합니다.
- TimerChallenge 컴포넌트가 여러 개 생기면, 모든 인스턴스가 같은 timer 변수를 공유됩니다.
- 하나의 컴포넌트에서 handleStart를 실행하고, 다른 컴포넌트에서 handleStop을 실행하면 엉뚱한 타이머가 멈출 수 있습니다.
🚨 컴포넌트가 재렌더링되면 timer 값이 유지되지 않는 문제가 발생합니다.
- setTimerExpired(true)가 실행되면 상태가 변경(setState)됨 → 리렌더링 발생
- 리렌더링 시 let timer 변수는 초기화 됩니다.
- handleStop에서 clearTimeout(timer)을 호출해도 이전에 저장한 timer와 다를 수 있습니다. (타이머가 멈추지 않을 가능성이 있음)
2️⃣ useRef를 사용한 해결 방법
useRef는 렌더링을 발생시키지 않고 값을 유지할 수 있습니다.
import { useRef, useState } from "react";
function TimerChallenge() {
const timer = useRef(null); // ✅ useRef를 사용하여 유지되는 값 저장
const [timerExpired, setTimerExpired] = useState(false);
function handleStart() {
timer.current = setTimeout(() => {
console.log("타이머 끝!");
setTimerExpired(true);
}, 5000);
}
function handleStop() {
clearTimeout(timer.current);
}
return (
<div>
<button onClick={handleStart}>Start</button>
<button onClick={handleStop}>Stop</button>
</div>
);
}
✅ 각 컴포넌트 인스턴스마다 독립적인 timer 값을 가집니다.
- useRef를 사용하면, 컴포넌트별로 개별적인 timer 값을 유지합니다.
- 첫 번째 코드처럼 let timer가 전역적으로 공유되지 않음 → 여러 개의 TimerChallenge 인스턴스가 독립적으로 작동 가능합니다.
✅ 리렌더링이 발생해도 timer.current 값이 유지될 수 있습니다.
- useRef는 상태 변경(setState)으로 인해 컴포넌트가 리렌더링 되어도 값이 초기화되지 않습니다.
- setTimerExpired(true)로 인해 리렌더링이 일어나더라도 기존 타이머 ID가 유지됨 → handleStop이 정확한 타이머를 중지할 수 있습니다.
🎈커스텀 컴포넌트로 Refs(참조) 전달
import { useState, useRef } from "react";
function TimerChallenge() {
const timer = useRef();
const dialog = useRef();
const [timerExpired, setTimerExpired] = useState(false);
function handleStart() {
timer.current = setTimeout(() => {
console.log("타이머 끝!");
setTimerExpired(true);
dialog.current.showModal(); // 타이머 끝나면 모달 표시 / 내장된 dialog 요소에 showModal 메소드 존재
}, 5000);
}
function handleStop() {
clearTimeout(timer.current);
}
return (
<>
{/* ResultModal 컴포넌트에 ref 전달 */}
{<ResultModal ref={dialog} targetTime={targetTime} result="lost"/>}
<div>
<button onClick={handleStart}>Start</button>
<button onClick={handleStop}>Stop</button>
</div>
</>
);
}
import {forwardRef} from "react";
const ResultModal = forwardRef(function ResultModal ({ result, targetTime }, ref){
return (
<dialog ref={ref} className="result-modal">
<h2>You {result}</h2>
<p>
target time : {targetTime}
</p>
<p>
You stopped the timer with <strong>X seconds left.</strong>
</p>
<form method="dialog">
<button>Close</button>
</form>
</dialog>
);
});
export default ResultModal;
- ResultModal은 forwardRef를 통해 ref를 받을 수 있도록 설정되어 있음.
- 부모 컴포넌트 TimerChallenge에서 useRef()를 사용하여 dialog라는 ref를 생성.
- ResultModal ref={dialog}로 전달하면, dialog.current가 <dialog> 요소를 직접 가리킴.
- dialog.current.showModal()을 호출하면 <dialog>이 표시됨.
리액트 19 이후에서는 아래와 같이 forwardRef를 사용하지 않고 간단하게 ref 를 자식 컴포넌트의 props를 통해 전달 받을 수 있습니다.
export default function ResultModal ({ ref, result, targetTime }){
return (
<dialog ref={ref} className="result-modal" open={true}>
<h2>You {result}</h2>
<p>
target time : {targetTime}
</p>
<p>
You stopped the timer with <strong>X seconds left.</strong>
</p>
<form method="dialog">
<button>Close</button>
</form>
</dialog>
);
}
🎈userImperativeHandle 훅으로 컴포넌트 API 노출
리액트에서 useImperativeHandle 훅은 컴포넌트 내부에서 사용할 수 있는 메서드나 프로퍼티를 외부로 노출할 때 유용하게 사용됩니다. 일반적으로 ref는 DOM 요소나 컴포넌트 인스턴스를 참조하는 데 사용되지만, useImperativeHandle을 사용하면 외부에서 ref를 통해 특정 메서드를 호출할 수 있도록 설정할 수 있습니다.
import { useState, useRef } from "react";
function TimerChallenge() {
const timer = useRef();
const dialog = useRef();
const [timerExpired, setTimerExpired] = useState(false);
// 타이머 시작
function handleStart() {
timer.current = setTimeout(() => {
console.log("타이머 끝!");
setTimerExpired(true);
dialog.current.open(); // 타이머 끝나면 모달을 열기 위해 open 메서드 호출
}, 5000);
}
// 타이머 멈추기
function handleStop() {
clearTimeout(timer.current);
}
return (
<>
{/* ResultModal에 ref를 전달하여 외부에서 open을 호출할 수 있도록 함 */}
<ResultModal ref={dialog} targetTime={5} result="lost" />
<div>
<button onClick={handleStart}>Start</button>
<button onClick={handleStop}>Stop</button>
</div>
</>
);
}
import { forwardRef, useImperativeHandle, useRef } from "react";
const ResultModal = forwardRef(function ResultModal({ result, targetTime }, ref) {
const dialog = useRef();
// 외부에서 호출할 수 있도록 open 메서드 노출
useImperativeHandle(ref, () => {
return {
open() {
dialog.current.showModal(); // dialog를 열기 위한 메서드
}
};
});
return (
<dialog ref={dialog} className="result-modal">
<h2>You {result}</h2>
<p>Target time: {targetTime}</p>
<p>
You stopped the timer with <strong>X seconds left.</strong>
</p>
<form method="dialog">
<button>Close</button>
</form>
</dialog>
);
});
export default ResultModal;
- TimerChallenge 컴포넌트는 버튼 클릭 시 타이머를 시작하고, 타이머가 끝나면 ResultModal을 열기 위해 dialog.current.open()을 호출합니다.
- ResultModal 컴포넌트는 forwardRef와 useImperativeHandle을 사용하여 부모 컴포넌트에서 ref를 통해 접근할 수 있는 open 메서드를 노출합니다. 이 메서드는 dialog DOM 요소를 열기 위해 사용됩니다.
- ResultModal의 ref는 TimerChallenge에서 전달되며, 이 ref는 useImperativeHandle을 통해 외부에서 호출 가능한 메서드를 노출하게 됩니다.
💡 이 패턴의 장점
- 캡슐화 및 재사용성: ResultModal 내부 구현에 대한 정보를 숨기고, 외부에서는 open 메서드만을 호출하여 모달을 열 수 있습니다.
- 컴포넌트 독립성: ResultModal 컴포넌트는 외부에서 open 메서드를 통해 호출되므로, TimerChallenge는 ResultModal의 내부 구조에 의존하지 않습니다.
- 유연성: open 메서드는 언제든지 ResultModal 내부에서 변경할 수 있으며, 외부 코드에는 영향을 주지 않습니다.
🎈React의 포털 (Portal)
포털(Portal)은 React에서 제공하는 기능 중 하나로, 컴포넌트의 렌더링 위치를 DOM 트리 내의 다른 위치로 옮길 수 있는 기능입니다. React 애플리케이션 내에서 특정 UI 요소(예: 모달, 팝업 등)가 페이지의 다른 위치에 렌더링되어야 할 때 유용하게 사용됩니다. 이를 통해 UI를 더 깔끔하게 관리할 수 있으며, 예를 들어 모달, 드롭다운, 툴팁 등 페이지 상단이나 다른 위치에 띄워야 할 UI에 유용합니다.
- 컴포넌트 렌더링 위치 변경: 포털을 사용하면, 예를 들어 모달이나 대화 상자처럼 중요한 UI 요소를 페이지 내 다른 위치에 렌더링할 수 있습니다.
- DOM 트리 외부에 렌더링: 보통 모달 같은 UI는 페이지 내 다른 요소들과 겹치지 않도록 페이지의 최상위에 렌더링됩니다.
- 접근성 향상: 포털을 활용하면 HTML 요소의 위치를 의미적으로 더 적절한 곳으로 옮길 수 있어 접근성을 높일 수 있습니다.
💡 예제
- ResultModal 컴포넌트에서 createPortal을 사용하여 모달을 별도의 DOM 노드로 렌더링합니다.
- TimerChallenge 컴포넌트에서 타이머 만료 시 모달을 열도록 합니다.
import { forwardRef, useImperativeHandle, useRef } from "react";
import { createPortal } from "react-dom"; // 포털을 사용하려면 react-dom에서 가져와야 함
const ResultModal = forwardRef(function ResultModal({ result, targetTime }, ref) {
const dialog = useRef();
useImperativeHandle(ref, () => {
return {
open() {
dialog.current.showModal();
}
};
});
// 포털을 사용하여 모달을 'modal'이라는 id를 가진 div로 렌더링
return createPortal(
<dialog ref={dialog} className="result-modal">
<h2>You {result}</h2>
<p>Target time: {targetTime}</p>
<p>
You stopped the timer with <strong>X seconds left.</strong>
</p>
<form method="dialog">
<button>Close</button>
</form>
</dialog>,
document.getElementById('modal') // 모달을 ID가 'modal'인 DOM 요소로 렌더링
);
});
export default ResultModal;
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Refs & Portals</title>
</head>
<body>
<div id="modal"></div>
<div id="content">
<header>
<h1>The <em>Almost</em> Final Countdown</h1>
<p>Stop the timer once you estimate that time is (almost) up</p>
</header>
<div id="root"></div>
</div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
포털을 사용하면, #modal이라는 div에 모달을 렌더링합니다.
'Programming > React' 카테고리의 다른 글
GitHub Pages에 리액트 프로젝트(React + Vite) 배포하기 (0) | 2025.03.23 |
---|---|
[React] 컴포넌트 속성에 컴포넌트 전달하기 (0) | 2025.03.18 |
[React] React 완벽 가이드 - 섹션 5: React로 투자 계산기 만들기 | 개발 회고 (0) | 2025.03.18 |
[React]스프레드 연산자를 통한 속성 전달 방식- id 전달 (0) | 2025.03.12 |
[React] 함수형 컴포넌트의 반환 return 값 조건 & Fragment(프래그먼트) <>...</> (0) | 2025.03.11 |