- Published on
리액트 공식문서 스터디 7-1 주차
- Authors

- Name
- junyeol kim
Referencing Values with Refs
- 컴포넌트가 렌더링에는 필요 없지만 내부적으로 사용해야 하는 값을 저장해야 할 때
- 그리고 그 값이 바뀌어도 리렌더링을 일으키고 싶지 않을 때 ref를 사용한다.
Ref 사용방법
import { useRef } from 'react'
const ref = useRef(0)
- useRef Hook을 React에서 import한 뒤, 컴포넌트 안에서 useRef(초기값) 형태로 호출해서 ref 객체 를 만든다.
- useRef(0) 처럼 사용하면
{ current: 0 }형태의 객체가 반환되고, current 프로퍼티에 초기값이 들어 있다.
ref.current 특징
- ref가 갖고있는 current 값은 읽고 쓸 수 있도록 intentionally mutable 하게 설계되어있다.
- 또한, ref는 React가 추적하지 않는다.
예시코드를 통한 state와 ref 사용방식
예시코드를 통해 state와 ref의 사용방식을 알아보자.
사용자가 버튼을 클릭했을 때 시작과 정지를 하는 스톱워치를 만든다고 생각해보자.
그렇다면, 첫 번째로 사용자가 Start 버튼을 클릭했을 때, 시간이 얼마나 지났는지 보여주게 하려면 Start 버튼을 누른 시각 과 현재 시각 을 알고 있어야한다.
이러한 정보들은 화면에 렌더링되어야하는 요소이므로 아래와 같이 state로 관리하게 된다.
const [startTime, setStartTime] = useState(null) // Start 버튼을 누른 시각
const [now, setNow] = useState(null) // 현재 시각
- 그리고 Start 버튼을 클릭했을 때 이 state들을 세팅하고, 일정주기로 now를 업데이트하는 handler를 만든다.
function handleStart() {
// Start를 누른 시각과 현재 시각을 모두 지금 시각으로 설정
setStartTime(Date.now())
setNow(Date.now())
// 이후 10ms마다 now를 현재 시각으로 업데이트
setInterval(() => {
setNow(Date.now())
}, 10)
}
이 다음 Stop 버튼을 클릭했을 때 스톱워치를 멈추게 하려면 setInterval로 만든 타이머를 clearInterval로 취소해야한다.
clearInterval를 호출하려면 setInterval이 반환하는 interval ID가 필요하고 동시에 저장을 해야한다.
interval ID 값은 렌더링에 필요하지않고 오직 handler에서만 필요하기 때문에 여기서 ref가 필요하다.
import { useRef , useState } from "react";
...
const intervalRef = useRef(null); // setInterval이 반환한 interval ID를 저장
이후 handler를 아래와 같이 수정할 수 있다.
function handleStart() {
setStartTime(Date.now())
setNow(Date.now())
// 기존 interval이 있다면 먼저 정리
clearInterval(intervalRef.current)
// 새 interval을 만들고, 반환된 ID를 ref에 저장
intervalRef.current = setInterval(() => {
setNow(Date.now())
}, 10)
}
function handleStop() {
// ref에 저장해 둔 ID로 interval 정지
clearInterval(intervalRef.current)
}
state와 ref의 차이점
| 구분 | ref | state |
|---|---|---|
| 선언 | useRef(initialValue)는 { current: initialValue } 형태의 객체를 반환한다. | useState(initialValue)는 현재 값과 setter 함수인 [value, setValue]를 반환한다. |
| 값 변경 시 리렌더링 | current를 바꿔도 리렌더링이 일어나지 않는다. | 값을 업데이트하면 컴포넌트가 리렌더링된다. |
| 가변성 | 가변(mutable)이라 렌더링 과정 밖에서 current 값을 마음대로 읽고 쓸 수 있다. | 불변(immutable) 취급이라 반드시 setter 함수를 통해서만 변경을 요청한다. |
| 읽기 시점 | 렌더링 중에는 ref.current를 읽거나 쓰지 않는 것이 권장된다. | 언제든 읽을 수 있지만, 각 렌더는 그 시점의 state 스냅샷을 보는 식으로 동작한다. |
useRef의 내부동작 방식
- React 내부에서 useRef는 아래와 같이 구현되어 있다고 생각할 수 있다.
function useRef(initialValue) {
const [ref, unused] = useState({ current: initialValue })
return ref
}
첫 렌더 때
{ current: initialValue }객체를 한 번 만들고, state에 저장한 뒤 이후 렌더에서도 동일한 객체를 그대로 반환한다.state 업데이트 함수는 사용하지 않고, 컴포넌트 쪽에서 ref.current = ... 처럼 직접 필드를 바꿔 리렌더링 없이 값만 바뀌는 저장소 역할을 한다.
언제 refs를 사용해야할까?
- 값을 저장해야할 때, 그 값이 바뀌어도 렌더링 로직이나 화면에 영향을 주지 않을때 ref를 사용한다.
refs를 잘 사용하는 방법
- ref는 escape hatch처럼 사용해야한다.
- 외부 시스템이나 브라우저 API(DOM, 타이머 등)와 상호작용할 때만 최소한으로 ref를 쓰고, 애플리케이션의 주요 로직과 데이터 흐름은 state/props에 두는 것이 좋다.
- 렌더링 중에는 ref.current를 읽거나 쓰지 않는다.
- 렌더링에 필요한 정보라면 ref가 아니라 state를 사용해야 한다. ref.current가 언제 바뀌는지 React는 모르기 때문에, 렌더 과정에서 이 값에 의존하면 컴포넌트 동작을 예측하기 어려워진다.
- state에 적용되는 제약은 ref에는 없다.
- state는 렌더마다 스냅샷처럼 동작하고, 업데이트도 비동기 큐에 쌓이지만, ref는 그냥 JS 객체라 ref.current = 5; console.log(ref.current); 하면 즉시 5가 찍힌다. 또한 ref는 “불변성 유지”를 신경 쓸 필요가 없다. 단, 그 객체가 렌더링(화면) 계산에 사용되지 않을 때에만 마음대로 mutate해도 된다.
ref와 DOM
ref는 어떤 값이든 가리킬 수 있지만, 가장 흔한 사용처는 DOM 요소 를 가리키는 것이다.
JSX에서
<div ref={myRef}>처럼 ref 속성에 ref를 넘기면, React가 해당 DOM 요소를 찾아서 myRef.current 에 넣어 준다.나중에 그 요소가 DOM에서 제거되면, React가 myRef.current를 자동으로 null로 바꿔 준다.
Manipulating the DOM with Refs
React는 렌더링 결과물에 맞춰 DOM 변경을 자동으로 하기 때문에 컴포넌트에서 DOM을 조작할 필요가 많이 없다.
하지만 특정 노드에 포커스를 옮기거나, 스크롤 위치를 옮기거나, 위치와 크기를 측정 하기 위해 React가 관리하는 DOM 요소에 접근해야 할 때가 있다.
이러한 DOM 에 접근하기 위해 필요한 방법이 이전 챕터에서 배운 Ref 이다.
Ref로 Node 가져오기
- 아래와 같이 useRef로 ref 객체를 만들고 컴포넌트의 ref 속성에 연결하면, 해당 DOM Node가 렌더 후 ref.currnet에 들어가서 브라우저 API 등을 직접 호출할 수 있다.
import { useRef } from 'react'
const myRef = useRef(null)
;<div ref={myRef}>...</div> // <div>의 DOM Node를 myRef.current에 넣어달라는 의미
myRef.current.scrollIntoView() // 브라우저 API를 직접 호출할 수 있다
Ref Callback으로 Ref list 관리하기
- Hook 규칙으로 useRef는 map안에서 사용하지 못하는데, 이러한 동적으로 변하는 리스트의 DOM들을 한 번에 관리하기 위해서
ref callback + Map 을 사용한다.
다른 컴포넌트의 DOM nodes 접근하는 방법
커스텀 컴포넌트에 ref를 직접 달아도 곧바로 DOM을 가리키지 않고, 기본적으로 null이거나 컴포넌트 인스턴스를 가리킨다.
부모에서 만든 ref를 자식에게 props처럼 내려보낸 뒤, 자식에서 그 ref를 실제 내장 컴포넌트(
<input>, <div>등)의 ref에 다시 전달해야 최종적으로 부모의 ref.current가 해당 DOM 노드를 가리키게 된다.
When React attaches the refs
React의 업데이트 과정은 총 2단계이다.
- Render : 컴포넌트를 호출하여 화면에 무엇을 나타낼지 계산한다.
- Commit : 변경사항을 DOM에 적용한다.
Render 단계에서는 DOM이 아직 없거나 업데이트 전이라 ref.current가 null이거나 오래된 값일 수 있으므로, 이 시점에는 ref를 읽지 않는 것이 좋다.
Commit 단계에서 React는 DOM을 바꾸기 직전에 관련된 ref.current들을 null로 초기화한 뒤 DOM을 실제로 변경하고, 곧바로 새 DOM 노드들로 각 ref.current를 다시 채운다.
그래서 ref를 이용한 DOM 접근·조작은 보통 클릭 같은 이벤트 핸들러나, 특정 이벤트 없이 커밋 이후에 실행되는 Effect 안에서 수행해야 안전하다.
Flushing state updates synchronously with flushSync
- flushSync는 특정 state 업데이트를 바로 DOM까지 반영한 뒤, 그 다음 코드를 실행하고 싶을 때 그 업데이트만 동기적으로 flush 해 주는 함수다.
Best practices for DOM manipulation with refs
focus, scroll 등 브라우저 API 호출처럼 렌더링에 영향이 없는 것은 Ref로 한다.
node를 제거하거나, child를 추가, 삭제하는 등 렌더링에 영향을 주는 것은 컴포넌트 + state로 하는 것이 좋다.
Synchronizing with Effects
What are Effects and how are they different from events?
- 렌더링 이후 React가 DOM 업데이트를 끝낸 후 실행되는 코드를 Effect라고 한다.
- Rendering code는 순수 함수처럼 DOM과 무관하게 계산만 수행해야 한다.
- Event handler는 사용자의 명시적 인터랙션 ( 클릭, 입력 등 ) 으로 발생하지만, Effect는 렌더링 결과에 의해 자동으로 발생하는 side effect이다.
You might not need an Effect
- Effect는 렌더링 결과를 외부 시스템( 브라우저 API, 서드 파티 위젯, 네트워크 )과 동기화할 필요가 있을 때만 필요
- Effect가 단순히 다른 상태에 기반하여 일부 상태를 조정하는 경우에는 Effect가 필요하지 않을 수 있다.
How to write an Effect
Step 1 : Declare an Effect
import { useEffect } from 'react'
function MyComponent() {
useEffect(() => {
// 렌더링 이후 실행할 코드
})
return <div />
}
- 기본적으로 모든 렌더링 이후에 실행된다. (의존성 배열이 없을 경우)
- DOM/외부 API 접근같은 side effect를 작성할 때 사용한다.
Step 2 : Specify the Effect dependencies
useEffect(() => {
// effect body
}, [dep1, dep2])
| 의존성 배열 | 실행 시점 |
|---|---|
| 없음 | 모든 렌더링 후 실행 |
[] (빈 배열) | 마운트 시 1회 실행 |
[dep1, dep2] | dep가 변경될 때만 실행 |
- 의존성 배열을 통해 불필요한 실행을 막을 수 있다.
- 의존성 누락은 lint 오류가 발생하고, React가 해당 오류에 대한 수정사항을 자동으로 제안한다.
Step 3 : Add cleanup if needed
useEffect(() => {
const conn = createConnection()
conn.connect()
return () => {
conn.disconnect()
}
}, [])
Subscription, Connection, Timer 같은 경우는 cleanup과 같은 정리코드가 필요하다.
cleanup 같은 경우 Effect가 다시 실행되기 전 또는 컴포넌트 언마운트시에 실행된다.
How to handle the Effect firing twice in development?
React는 Strict Mode가 활성화된 개발 환경에서, effect 관련 버그를 조기에 발견하기 위해 컴포넌트를 의도적으로 다시 마운트하며, 그 결과 Effect가 두 번 실행되는 것처럼 보인다.
이러한 경우의 일반적인 해결책은 cleanup 함수를 구현하는 것이다.
Effect가 두 번 실행되는 것을 막기 위해 useRef로 실행을 제한하는 것은 버그를 숨길 뿐 해결하지 못하므로 피해야 한다.
Controlling non-React widgets
useEffect(() => {
const map = mapRef.current
map.setZoomLevel(zoomLevel)
}, [zoomLevel])
- React로 작성되지 않은 외부 UI 위젯(지도, 차트 등)을 제어할 때도 Effect를 사용한다.
- 동일한 값을 여러 번 설정해도 문제가 없는 API라면 cleanup이 필요 없다.
- 개발 모드에서 Effect가 두 번 실행되어도 setZoomLevel을 두 번 호출하는 것은 안전하다.
- 반대로, 연속 호출을 허용하지 않는 API라면 cleanup이 필요하다.
Subscribing to events
useEffect(() => {
function handleScroll(e) {
console.log(window.scrollX, window.scrollY)
}
window.addEventListener('scroll', handleScroll)
return () => window.removeEventListener('scroll', handleScroll)
}, [])
- Effect가 어떤 것을 subscription 한다면, cleanup 함수에서 반드시 unsubscribe 해야 한다.
Triggering animations
useEffect(() => {
const node = ref.current
node.style.opacity = 1
return () => {
node.style.opacity = 0
}
}, [])
- Effect가 요소의 애니메이션을 트리거한다면, cleanup 함수에서 초기 상태로 되돌려야 한다.
Fetching data
useEffect(() => {
let ignore = false
async function startFetching() {
const json = await fetchTodos(userId)
if (!ignore) {
setTodos(json)
}
}
startFetching()
return () => {
ignore = true
}
}, [userId])
Effect가 데이터를 가져온다면, cleanup에서는 fetch를 중단(abort) 하거나 결과를 무시(ignore) 해야 한다.
이미 시작된 네트워크 요청은 취소할 수 없지만, 더 이상 관련 없는 응답이 state를 변경하지 않도록 보장해야 한다.
개발 모드에서는 네트워크 요청이 두 번 보일 수 있으나, cleanup 덕분에 첫 번째 요청 결과는 무시된다.
Sending analytics
useEffect(() => {
logVisit(url)
}, [url])
개발 환경에서는 분석 이벤트가 두 번 전송될 수 있다.
이는 문제가 아니며, 개발 환경에서는 로그가 실제 지표에 영향을 주지 않아야 한다.
빌드 환경에서는 중복 로그가 발생하지 않는다.
분석 디버깅이 필요하다면
빌드 모드에서 실행하거나
Strict Mode를 일시적으로 끌 수 있다.
또는 Effect 대신 라우트 변경 이벤트에서 분석을 보낼 수도 있다.
Not an Effect: Initializing the application
if (typeof window !== 'undefined') {
checkAuthToken()
loadDataFromLocalStorage()
}
애플리케이션 시작 시 단 한 번만 실행되어야 하는 로직은 Effect에 넣지 않는다.
컴포넌트 외부에 두면, 브라우저 로드 시 한 번만 실행됨이 보장된다.
Not an Effect: Buying a product
// 잘못된 예
useEffect(() => {
fetch('/api/buy', { method: 'POST' })
}, [])
제품 구매처럼 사용자 행동에 의해 발생해야 하는 로직은 Effect에 두면 안 된다.
Effect는 재마운트 시 다시 실행되므로, 사용자가 의도하지 않게 중복 구매가 발생할 수 있다.
이런 로직은 아래와 같이 이벤트 핸들러로 옮겨야 한다.
function handleClick() {
fetch('/api/buy', { method: 'POST' })
}