[React 19] 공식문서 톺아보기 - 탈출구
목차
1. Ref로 값 참조하기
import { useRef } from 'react';
const ref = useRef(0);
// 반환되는 객체
// {
// current: 0 // useRef에 전달한 값
// }
import { useRef } from 'react';
const ref = useRef(0);
// 반환되는 객체
// {
// current: 0 // useRef에 전달한 값
// }
- 렌더링 간에 값을 기억하고 싶지만 값의 변화가 렌더링을 유발하지 않으려 할때
ref
를 사용한다. ref.current
프로퍼티를 통해 해당 ref의 current 값에 접근할 수 있고 변경할 수 있다.- React가 추적하지 않아 값의 변화가 발생해도 다시 렌더링 되지 않는다.
useState, useRef 비교
- 반환값
- useState : state 변수의 현재 값과 setter 함수
[value, setValue]
를 반환한다. - useRef :
{ current: initialValue }
을 반환한다.
- useState : state 변수의 현재 값과 setter 함수
- 렌더링 여부
- useState : setter 함수를 통해 state를 바꾸면 state 업데이트 큐에 추가되고 리렌더링이 발생한다.
- useRef : ref.current를 바꿔도 리렌더링이 되지 않는다.
- 사용목적
- useState : 렌더링과 직결된 상태 관리에 사용한다.
- useRef : DOM을 참조하거나 렌더링과 무관한 값에 대해서 사용한다.
useState를 이용한 useRef 표현
function useRef(initialValue) {
const [ref, unused] = useState({ current: initialValue });
return ref;
}
function useRef(initialValue) {
const [ref, unused] = useState({ current: initialValue });
return ref;
}
refs의 사용예시
- refs는 React의 일반적인 데이터 흐름에서 벗어나야 할 때만 사용해야 한다.
- DOM 요소에 직접 접근해야 할 때 (예: input 포커스, 스크롤 위치 조정)
- 외부 라이브러리(예: jQuery, D3)와 연동할 때
- 타이머(setInterval), 애니메이션 등 브라우저 API 사용 시
- 렌더링 중에
ref.current
를 읽거나 쓰지 말아야 한다.- ref는 state와 달리 React의 렌더링 주기와 동기화되지 않아 변경 시도 예측 불가능한 결과가 발생할 수 있다.
if (!ref.current) ref.current = new Thing()
과 같이 첫렌더도중 ref를 설정하는 코드는 허용한다.
왜 탈출구인가?
이 챕터의 이름이 '탈출구'인데 왜 탈출구라고 표현했는지에 대해 알아보았다.
- React의 일반적인 제어 흐름에서 벗어나야 할 때 사용하는 특별한 방법을 의미한다.
- React는 기본적으로 가상 DOM과 상태(state)를 통해 UI를 관리하는데 일부 상황에서 React의 시스템 밖(예: 실제 DOM, 외부 JS 라이브러리, 브라우저 API)과 직접 소통해야 할 때가 있는데 이때 refs를 사용하여 React의 통제를 "벗어난다(escape)"
function AutoFocusInput() {
const inputRef = useRef(null);
useEffect(() => {
inputRef.current.focus(); // React 세계를 "벗어나" 실제 DOM 조작
}, []);
return <input ref={inputRef} />;
}
function AutoFocusInput() {
const inputRef = useRef(null);
useEffect(() => {
inputRef.current.focus(); // React 세계를 "벗어나" 실제 DOM 조작
}, []);
return <input ref={inputRef} />;
}
- React는 가상 DOM을 쓰지만 focus()는 실제 DOM의 메서드이다.
- ref로 React 밖에 '실제 DOM' 요소에 접근해 조작한다.
즉시 변경된다
const [value, setValue] = useState(0);
setValue(5);
console.log(value); // 0
const [value, setValue] = useState(0);
setValue(5);
console.log(value); // 0
const ref = useRef(0);
ref.current = 5;
console.log(ref.current); // 5
const ref = useRef(0);
ref.current = 5;
console.log(ref.current); // 5
- state는 값이 snapshot처럼 동작한다. 값을 변경해도 업데이트 큐에 등록되어 다음 렌더링에 반영되고 같은 렌더링에서는 값이 변경되지 않는다. 그러나 ref의 current 값을 변조하면 다음과 같이 즉시 변경된다.
- ref 자체가 일반 자바스크립트 객체처럼 동작하기 때문이다.
- ref로 작업할 때 mutation 방지(값을 직접 변경)에 대해 걱정할 필요가 없다. React는 ref의 값변화를 추적하지 않기 때문이다.
2. Ref로 DOM 조작하기
리액트는 렌더링 결과물에 맞춰 DOM 변경을 자동으로 처리해주지만
- 특정 노드에 포커스를 이동
- 스크롤 위치를 이동
- 위치와 크기를 측정
등에 대해서 수행하지 않기에 DOM에 접근하기 위한 ref가 필요하다.
ref의 초기화 과정
const myRef = useRef(null);
return <div ref={myRef}>
const myRef = useRef(null);
return <div ref={myRef}>
- 초기에는
myRef.current
가null
로 초기화 된다. - React가
<div>
에 대한 DOM 노드를 생성할 때, React는 이 노드에 대한 참조를myRef.current
에 넣는다. - 이 DOM 노드를 이벤트 핸들러에서 접근하거나 노드에 정의된 내장 브라우저 API를 사용할 수 있다.
ref 콜백의 사용예시 : ref 리스트 관리하기
<ul>
{items.map((item) => {
// ❌ 작동하지 않는다.
const ref = useRef(null);
return <li ref={ref} />;
})}
</ul>
<ul>
{items.map((item) => {
// ❌ 작동하지 않는다.
const ref = useRef(null);
return <li ref={ref} />;
})}
</ul>
- 리스트 렌더링을 위와같이 구성하여 아이템마다 dom을 지정해야하는 상황이 있을 수 있다. 그러나 위와같이 호출할 경우, 훅은 최상단에서 호출되어야 하기에 동작하지 않는다. 해결할수 있는 여러가지 방법이 있지만 ref 콜백을 이용해보자.
<li
key={cat}
ref={(node) => {
const map = getMap(); // itemsRef.current = new Map();
map.set(cat, node);
return () => {
map.delete(cat);
};
}}
>
<img src={cat} />
</li>
<li
key={cat}
ref={(node) => {
const map = getMap(); // itemsRef.current = new Map();
map.set(cat, node);
return () => {
map.delete(cat);
};
}}
>
<img src={cat} />
</li>
- ref 콜백을 이용한다. ref 콜백은 DOM구조가 변경될때 실행된다. 렌더링될때마다 실행되는 useEffect와 비슷하면서 차이가 있다.
- 클린업 함수도 존재하는데 ref 연결이 끊어지는, DOM에서 제거될때와 같은 상황에서 실행된다.
- 위 예시에서 ref 콜백을 통해 자체 배열이나 Map을 유지하고, 인덱스나 특정 ID를 사용하여 어떤 ref에든 접근할 수 있다.
useImperativeHandle 을 이용하여 ref를 전달시 동작 제한하기
- 부모 컴포넌트에서 ref를 이용하여 자식 컴포넌트의 DOM을 조작할때 허용된 기능만 사용하도록
useImperativeHandle
를 이용하여 제한할 수 있다.
import { useImperativeHandle, useRef } from 'react';
export default function Form() {
const inputRef = useRef(null);
function handleClick() {
inputRef.current.focus();
}
return (
<>
<MyInput ref={inputRef} />
<button onClick={handleClick}>Focus the input</button>
</>
);
}
function MyInput({ ref }) {
const realInputRef = useRef(null);
useImperativeHandle(ref, () => ({
// ⚠️ 오직 focus만 노출하고 다른 메서드는 사용할 수 없다.
focus() {
realInputRef.current.focus();
},
}));
return <input ref={realInputRef} />;
}
import { useImperativeHandle, useRef } from 'react';
export default function Form() {
const inputRef = useRef(null);
function handleClick() {
inputRef.current.focus();
}
return (
<>
<MyInput ref={inputRef} />
<button onClick={handleClick}>Focus the input</button>
</>
);
}
function MyInput({ ref }) {
const realInputRef = useRef(null);
useImperativeHandle(ref, () => ({
// ⚠️ 오직 focus만 노출하고 다른 메서드는 사용할 수 없다.
focus() {
realInputRef.current.focus();
},
}));
return <input ref={realInputRef} />;
}
MyInput
내부의realInputRef
는 실제 input DOM 노드를 가지고 있다.- 이때 외부 컴포넌트에서
focus
에 대해서만 조작하도록 제한하고 싶다면useImperativeHandle
을 사용하여 React가 부모 컴포넌트에 직접 구성한 객체를 전달한다.inputRef.current
는foucs
메서드만 가지고 있다.
React는 ref.current를 커밋 단계에서 설정합니다. 와 같은 설명이 공식문서에 있었는데 커밋단계와 렌더링단계에 대해서 알아보았습니다.
앞부분 상호작용성 더하기 파트에서 잠깐 등장하지만 세부내용이 부족하여 다시 알아보았습니다.
React의 렌더링 단계와 커밋 단계
- 렌더링 단계 (Render Phase) → 가상 DOM 계산 (불변성 유지)
- 커밋 단계 (Commit Phase) → 실제 DOM 반영 (부수 효과 실행)
렌더링 단계
- 컴포넌트를 실행하고, 변경 사항을 가상 DOM에 계산하는 단계이다.
- 실제 DOM 조작이나 부수 효과(Side Effects)는 발생하지 않는다.
- 컴포넌트 함수 호출
render()
(클래스형) 또는 함수 컴포넌트 자체가 실행된다.- JSX → React Element 객체로 변환된다.
- 이 단계에서
useState
,useMemo
,useCallback
가 호출되고 계산된다. (useEffect
,useLayoutEffect
는 커밋후 실행된다.)
- Reconciliation (재조정)
- 이전 가상 DOM(
Fiber Tree
)과 새로운 가상 DOM을 비교한다. (Diffing 알고리즘). - 변경된 부분만 식별하여 효율적인 업데이트 계획을 세운다.
- Fiber 아키텍처에서 단방향 비교를 통해 이루어지며, 변경사항이 없으면 스킵되고 Key를 통해 요소의 재사용 여부를 결정한다.
- 이전 가상 DOM(
- Concurrent Mode
- React는 각 컴포넌트를 Fiber라는 단위로 관리하며, 이를 통해 업데이트를 쪼개서 처리하고, 우선순위 기반으로 스케줄링할 수 있다.
- 우선순위가 높은 업데이트(예: 사용자 입력)가 들어오면 현재 렌더링을 중단(Interruptible)하고 다시 시작할 수 있다.
- 불변성(Immutable): state에 대해서 이전 렌더 결과를 수정하지 않고, 새로운 객체를 생성한다.
- Side Effect 금지: 렌더링 중에는 DOM 접근, API 호출 등이 금지된다.
커밋 단계
- 렌더링 단계에서 계산된 변경 사항을 실제 DOM에 반영한다.
-
Before Mutation Phase
getSnapshotBeforeUpdate
(클래스 컴포넌트)가 실행된다.- 아직 DOM이 업데이트되지 않은 상태이다.
-
Mutation Phase
- 실제 DOM이 업데이트된다.
-
ref.current
가 업데이트 되고ref
콜백이 실행된다. (요소가 제거되면 null, 생성되면 DOM 요소 전달)
-
Layout Phase
useLayoutEffect
훅이 동기적으로 실행된다.- 이 시점에서는 DOM이 업데이트된 상태이다.
이후, 브라우저가 화면을 그리면(페인트 후):
- Passive Effects Phase:
useEffect
훅이 비동기적으로 실행된다.
여러 자료를 참고해서 작성했는데 여기서 중요한 점은 커밋단계에서 실제 DOM이 업데이트 된다는 점과 그 이후에 ref 콜백,
useLayoutEffect
이 실행된다는 점이다.useEffect
는 커밋단계 이후에 실행된다.
예제
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
console.log('Count updated:', count); // 커밋 단계에서 실행
}, [count]);
return (
<button onClick={() => setCount(count + 1)}>Clicked {count} times</button>
);
}
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
console.log('Count updated:', count); // 커밋 단계에서 실행
}, [count]);
return (
<button onClick={() => setCount(count + 1)}>Clicked {count} times</button>
);
}
1. 렌더링 단계
Counter
컴포넌트가 호출된다.useState
로 현재 상태(count
)를 읽고setCount
함수가 준비된다.- JSX가 React Element로 변환되고, 가상 DOM이 생성된다.
- (초기 렌더링이 아닌경우) 가상 DOM과 새로운 가상 DOM을 비교하여 변경사항을 계산한다.
- (초기 렌더링이 아닌경우) 변경 사항을 바탕으로 실제 DOM 업데이트 계획이 수립된다. (실제 DOM은 아직 아무 변화가 없다.)
2. 커밋 단계
- 변경 사항을 바탕으로 실제 DOM이 업데이트 된다.
- 버튼의 텍스트가
Clicked {n} times
로 바뀐다.
- 버튼의 텍스트가
- 커밋이 끝난 후
useEffect
가 실행되어 콘솔에 로그가 출력된다.
일반적으로 렌더링하는 중 ref에 접근하는 것을 원하지 않습니다
function BadExample() {
const ref = useRef(null);
ref.current?.focus(); // ❌ 렌더링 중 접근 (예측 불가능)
return <input ref={ref} />;
}
function BadExample() {
const ref = useRef(null);
ref.current?.focus(); // ❌ 렌더링 중 접근 (예측 불가능)
return <input ref={ref} />;
}
- 첫 렌더링에서 DOM 노드는 아직 생성되지 않아서
ref.current
는null
인 상태다. 따라서 원하는 동작이 수행되지 않을 가능성이 높다. - 리렌더링 상황일때도 새로운 DOM구조인지 그냥 state만 업데이트 하는것인지 확실하지 않은 상태라 신뢰하기 어렵다.
- 따라서 DOM이 완전히 업데이트 되고 실행되는
useEffect
나 이벤트 핸들러에서만ref
를 사용하는것이 권장된다.
flushSync로 state 변경을 동적으로 플러시하기
- useState와 useRef를 동시에 사용할때 업데이트 시점이 달라서 원하는 동작이 실행되지 않는 문제가 발생할 수 있다. 이를
flushSync
를 통해 해결할 수 있다.
setTodos([...todos, newTodo]);
listRef.current.lastChild.scrollIntoView();
setTodos([...todos, newTodo]);
listRef.current.lastChild.scrollIntoView();
- 위와 같은 코드에서 의도한 바는 마지막 요소를 다루는 것이다. 하지만
newTodo
의 값은 다음 렌더링에 업데이트 되므로 위 ref는 업데이트 전의 DOM의 마지막 요소를 가리키고 있다.
import { flushSync } from 'react-dom';
flushSync(() => {
setTodos([...todos, newTodo]);
});
listRef.current.lastChild.scrollIntoView();
import { flushSync } from 'react-dom';
flushSync(() => {
setTodos([...todos, newTodo]);
});
listRef.current.lastChild.scrollIntoView();
flushSync
를 통해 해결할수 있는데,flushSync
로 감싼 코드는 React가 동기적으로 DOM을 변경하도록 지시한다.- 즉, 다음 렌더링에 값이 변경되는게 아니라 즉시 변경하도록 한다.
flushSync의 동작방법
function handleClick() {
setName('Alice'); // 1️⃣ 배칭 대기 중
setCount((prev) => prev + 1); // 2️⃣ 배칭 대기 중
flushSync(() => {
setAge(30); // 3️⃣ ⚡ 즉시 실행 + 1️⃣, 2️⃣도 함께 플러시
});
setShowModal(true); // 4️⃣ 다음 배칭 주기로 대기
}
function handleClick() {
setName('Alice'); // 1️⃣ 배칭 대기 중
setCount((prev) => prev + 1); // 2️⃣ 배칭 대기 중
flushSync(() => {
setAge(30); // 3️⃣ ⚡ 즉시 실행 + 1️⃣, 2️⃣도 함께 플러시
});
setShowModal(true); // 4️⃣ 다음 배칭 주기로 대기
}
flushSync
는 "해당 코드만" 실행할까? 아니면 "거기까지의 모든setState
"를 실행할까?- ✅ 정답:
flushSync
는 자신의 콜백뿐만 아니라, 이전에 대기 중인 모든 상태 업데이트를 함께 플러시한다.
- ✅ 정답:
3. Effect로 동기화하기
컴포넌트 내부 2가지 로직 유형
- 렌더링 코드
- 컴포넌트의 최상단에 위치하며, props와 state를 적절히 변형해 결과적으로 JSX를 반환한다.
- 렌더링 코드 로직은 순수해야 한다. 수학 공식처럼 결과만 계산해야 하고, 그 외에는 아무것도 하지 말아야 한다.
- 이벤트 핸들러
- 단순한 계산 용도가 아닌 무언가를 하는 컴포넌트 내부의 중첩 함수이다.
- 이벤트 핸들러는 입력 필드를 업데이트하거나, 제품을 구입하기 위해 HTTP POST 요청을 보내거나, 사용자를 다른 화면으로 이동시킬 수 있다.
- 이벤트 핸들러에는 특정 사용자 작업(예: 버튼 클릭 또는 입력)으로 인해 발생하는 "부수 효과"(이러한 부수 효과가 프로그램 상태를 변경합니다.)를 포함할 수 있다.
- 위 두가지로 충분하지 않다. Effect는 렌더링 자체에 의해 발생하는 부수 효과를 특정하는 것으로, 특정 이벤트가 아닌 렌더링에 의해 직접 발생하는 경우 Effect를 통해 구현할 수 있다.
Effect가 단순히 다른 상태에 기반하여 일부 상태를 조정하는 경우에는 Effect가 필요하지 않을 수 있다. 최대한 Effect를 쓰지 않는것이 예측가능한 컴포넌트를 만들고 렌더링을 최소화 할 수 있다.
Effect 작성하기 : 1단계 Effect 선언하기
function MyComponent() {
useEffect(() => {
// 이곳의 코드는 *모든* 렌더링 후에 실행됩니다
});
return <div />;
}
function MyComponent() {
useEffect(() => {
// 이곳의 코드는 *모든* 렌더링 후에 실행됩니다
});
return <div />;
}
- 컴포넌트가 렌더링 될 때마다 React는 화면을 업데이트한 다음
useEffect
내부의 코드를 실행한다. - 다시 말해,
useEffect
는 화면에 렌더링이 반영될 때까지 코드 실행을 "지연"시킨다.
여기서 '코드 실행을 지연시킨다'에 관심을 가져보자. 일부 경우 코드가 빨리 실행되는것이 원치 않을 수 있다.
예를들면, DOM이 생성되기전에 ref를 통해 DOM에 접근하려고 하게되면 아무런 동작도 수행되지 않는다.
import { useEffect, useRef, useState } from 'react';
function VideoPlayer({ src, isPlaying }) {
const ref = useRef(null);
if (isPlaying) {
ref.current.play(); // 렌더링 중에 이를 호출하는 것이 허용되지 않습니다.
} else {
ref.current.pause(); // 역시 이렇게 호출하면 바로 위의 호출과 충돌이 발생합니다.
}
return <video ref={ref} src={src} loop playsInline />;
}
import { useEffect, useRef, useState } from 'react';
function VideoPlayer({ src, isPlaying }) {
const ref = useRef(null);
if (isPlaying) {
ref.current.play(); // 렌더링 중에 이를 호출하는 것이 허용되지 않습니다.
} else {
ref.current.pause(); // 역시 이렇게 호출하면 바로 위의 호출과 충돌이 발생합니다.
}
return <video ref={ref} src={src} loop playsInline />;
}
- 이 코드가 올바르지 않은 이유는 렌더링 중에 DOM 노드를 조작하려고 시도하기 때문이다.
- React에서는 렌더링이 JSX의 순수한 계산이어야 하며, DOM 수정과 같은 부수 효과를 포함해서는 안된다.
위 코드가 순수하지 않은 이유는, 처음 렌더링할때는 ref에 노드가 할당되지 않은 상태로 아무런 동작도 수행되지 않는 반면 리렌더링이 발생하게 되면 그때는 요소가 할당된 후라서 코드가 수행된다. 즉 컴포넌트가 항상 같은 결과를 보여주지 않는다.
function VideoPlayer({ src, isPlaying }) {
const ref = useRef(null);
useEffect(() => {
if (isPlaying) {
ref.current.play();
} else {
ref.current.pause();
}
}); // 의존성 배열에 대한 이야기는 뒤에 나옵니다.
return <video ref={ref} src={src} loop playsInline />;
}
function VideoPlayer({ src, isPlaying }) {
const ref = useRef(null);
useEffect(() => {
if (isPlaying) {
ref.current.play();
} else {
ref.current.pause();
}
}); // 의존성 배열에 대한 이야기는 뒤에 나옵니다.
return <video ref={ref} src={src} loop playsInline />;
}
- 부수 효과를 렌더링 연산에서 분리하기 위해
useEffect
로 감싸는 것입니다. - 코드 실행을 지연시킨다는 표현과 더불어 생각해보면, DOM이 할당될때까지 코드의 실행을 지연시키는 것이다.
반드시 위와 같은 경우에만 Effect를 사용하는건 아니다. 단지 코드 실행을 지연시킨다는 부분에서는 위와 같은 사용이 있을 수 있고 원래는 외부 시스템과 컴포넌트를 동기화 시키는 목적이 크다. 이때 의존성 배열을 통해 반응형 값의 변화를 감지하고 코드를 재실행한다.
Effect 작성하기 : 2단계 Effect의 의존성 지정하기
- Effect는 모든 렌더링 후에 실행된다.
- 모든 키 입력마다 채팅 서버에 다시 연결되는 일이 발생하거나, 모든 키 입력마다 fade-in 애니메이션을 트리거가 된다면 의존성을 지정하여 이를 해결할 수 있다.
- React에게 Effect를 불필요하게 다시 실행하지 않도록 지시하려면
useEffect
호출의 두 번째 인자로 의존성(dependencies) 배열을 지정한다.- 의존성 배열로
[isPlaying]
을 지정하면 React에게 이전 렌더링 중에isPlaying
이 이전과 동일하다면 Effect를 다시 실행하지 않는다. - 의존성 배열에는 여러 개의 종속성을 포함할 수 있다. React는 지정한 모든 종속성이 이전 렌더링의 그것과 정확히 동일한 값을 가진 경우에만 Effect를 다시 실행하지 않는다.
- 의존성은 "선택"할 수 없다. 의존성 배열에 지정한 종속성이 Effect 내부의 코드를 기반으로 React가 기대하는 것과 일치하지 않으면 린트 에러가 발생한다. (아직 안정화 버전에 추가되지 않은 훅이긴 하지만 useEffectEvent를 이용하여 일부 의존성을 의도적으로 배제할수 있다.)
- 의존성 배열로
ref를 의존성에서 생략해도 되는 이유
ref
객체가 안정된 식별성(stable identity)을 가지기 때문이다.- ref에 지정된 값이 변경될때는
ref.current
라는 속성값이 변하는 것이지 ref가 가리키는 객체의 주소값이 바뀌는건 아니다. - 따라서
ref
객체는 절대 변경되지 않으므로, 의존성 배열에 포함하든 안 하든 Effect 재실행에 영향을 주지 않는다. - React는 동일한
useRef
호출에서 항상 같은 객체를 얻을 수 있음을 보장한다.
- ref에 지정된 값이 변경될때는
- 안정된 식별성을 가진 의존성을 생략하는 것은 린터가 해당 객체가 안정적임을 알 수 있는 경우에만 허용된다.
- 예를 들어,
ref
가 부모 컴포넌트에서 전달되었다면, 의존성 배열에 명시해야한다. - 왜냐하면 부모 컴포넌트가 항상 동일한 ref를 전달하는지 또는 여러 ref 중 하나를 조건부로 전달하는지 알 수 없기 때문이다.
- 예를 들어,
그럼 의존성 배열에 ref.current를 추가하면 되는거 아닌가?
리액트는 ref.current의 값이 변경되어도 변화를 감지하지 못한다. 그래서 Effect는 리렌더링을 트리거하지 않는다. 따라서 렌더링간 값을 공유하면서 렌더링에 영향을 주고 싶다면 state로 선언해야한다.
Effect 작성하기 : 3단계 필요하다면 클린업을 추가하세요
useEffect(() => {
const connection = createConnection();
connection.connect();
}, []);
useEffect(() => {
const connection = createConnection();
connection.connect();
}, []);
- 문제점
- 컴포넌트가 마운트되고
connection.connect()
를 호출한다. - 그런 다음 사용자가 다른 화면으로 이동했다가 다시 뒤로가기를 통해 돌아왔을때 한번 더 호출된다.
- 이렇게 되면 두 번째 연결이 설정되지만 첫 번째 연결은 종료되지 않은 상태이다. 따라서 사용자가 앱을 탐색하는 동안 연결은 종료되지 않고 계속 쌓이게 된다.
- 클린업 함수를 통해 컴포넌트가 언마운트 될때 연결해제하는 로직을 구현할 수 있다.
- 컴포넌트가 마운트되고
- 클린업 함수
- 클린업 함수는 Effect가 수행하던 작업을 중단하거나 되돌리는 역할을 한다.
- 엄격모드를 통해 클린업 함수가 필요함에도 구현하지 못한 상황을 피할 수 있다.
- 엄격모드 (Strict Mode)
- 엄격모드는 개발 환경에서 초기 마운트 후 모든 컴포넌트를 한 번 다시 마운트한다.
- 이때 차이가 발생한다면 클린업 함수를 구현하여 차이게 없게 만들어야한다.
- 즉, 클린업을 잘 구현하여 Effect를 한 번 [실행]과 [실행 → 클린업 → 다시 실행]에 차이가 없어야 한다.
클린업 함수가 필요하지 않은 경우
useEffect(() => {
const map = mapRef.current;
map.setZoomLevel(zoomLevel);
}, [zoomLevel]);
useEffect(() => {
const map = mapRef.current;
map.setZoomLevel(zoomLevel);
}, [zoomLevel]);
- 위와 같이 멱등한 작업의 경우 (여러번 실행해도 결과가 같은 경우)는 클린업 함수를 작성하지 않아도 된다.
클린업 함수가 필요한 경우
-
구독
socket.addEventListener()
setInterval
-
애니메이션 트리거
useEffect(() => { const node = ref.current; node.style.opacity = 1; return () => { node.style.opacity = 0; }; }, []);
useEffect(() => { const node = ref.current; node.style.opacity = 1; return () => { node.style.opacity = 0; }; }, []);
- 애니메이션을 컴포넌트 마운트될때마다 실행하기 위해서 클린업 함수를 작성한다.
-
데이터 페칭
useEffect(() => { let ignore = false; async function startFetching() { const json = await fetchTodos(userId); if (!ignore) { setTodos(json); } } startFetching(); return () => { ignore = true; }; }, [userId]);
useEffect(() => { let ignore = false; async function startFetching() { const json = await fetchTodos(userId); if (!ignore) { setTodos(json); } } startFetching(); return () => { ignore = true; }; }, [userId]);
userId
가 빠르게 변하는 경우, 페이지를 이동했는데 뒤늦게fetchTodos
가 완료되는 경우 등을 피하기 위해 클린업 함수를 작성한다.
Effect를 이용한 데이터 요청의 단점
-
Effect는 서버에서 실행되지 않는다.
- 초기 서버 렌더링된 HTML은 데이터가 없는 상태이다. 모든 JavaScript를 다운로드하고 앱을 렌더링해야만 데이터를 로드되는데 이는 데이터 요청이 뒤늦게 이루어진다.
-
Effect 안에서 직접 가져오면 "네트워크 폭포"를 만들 수 있다.
- 부모 컴포넌트에서 useEffect를 통해 데이터를 요청하고, 그 데이터로 자식컴포넌트를 렌더링하는 경우, 그리고 자식컴포넌트에서도 useEffect로 데이터 요청을 하는 경우, 모든 요청이 순차적으로 진행되므로 완료되기까지 오랜 시간이 걸린다.
-
Effect 안에서 직접 가져오는 것은 일반적으로 데이터를 미리 로드하거나 캐시하지 않음을 의미한다.
- 예를 들어 컴포넌트가 마운트 해제되고 다시 마운트되면 데이터를 다시 가져와야 한다.
-
경쟁 조건과 같은 버그에 영향을 받지 않는 방식으로 작성하는 데 꽤 많은 보일러플레이트 코드가 필요하다.
-
경쟁조건
- 여러 작업이 동시에 실행될때 순서에 따라 결과가 달라지는 버그를 의미한다.
- 예를들어, a와 b를 순서대로 요청했는데 b a순으로 도착하여 최종 a가 보여져서 의도한 바와 달라진다.
-
경쟁 조건 방지하기 위한 보일러플레이트 코드
useEffect(() => { let ignore = false; // 🔴 플래그 변수로 경쟁 조건 방지 fetch(`/api/search?q=${query}`) .then((res) => res.json()) .then((data) => { if (!ignore) setResults(data); // ✅ 최신 요청만 반영 }); return () => { ignore = true; }; // 🛑 이전 요청 무시 }, [query]);
useEffect(() => { let ignore = false; // 🔴 플래그 변수로 경쟁 조건 방지 fetch(`/api/search?q=${query}`) .then((res) => res.json()) .then((data) => { if (!ignore) setResults(data); // ✅ 최신 요청만 반영 }); return () => { ignore = true; }; // 🛑 이전 요청 무시 }, [query]);
-
Effect와 로깅
useEffect(() => {
logVisit(url); // POST 요청을 보냄
}, [url]);
useEffect(() => {
logVisit(url); // POST 요청을 보냄
}, [url]);
- 개발환경에서 위 코드는 엄격모드에 의해 두번 실행된다.
- 리액트 개발진은 이 코드를 그대로 유지하는 것을 권장한다.
- 로그가 두번 남겨지는 문제에 대해서는 개발 환경에서 동작하지 않는 쪽으로 구현되어야한다고 말한다. 왜냐하면 개발 환경의 로그가 제품 지표를 왜곡시키지 않아야 하기 때문이다. 컴포넌트는 파일을 저장할 때마다 재마운트되므로 개발 환경에서는 추가적인 방문 기록을 로그에 남기게 된다.
- 더 좋은 구현 방법은 Effect 대신 라우트 변경 이벤트 핸들러에서 로깅 로직을 구현하거나 Intersection Observer를 사용하여 어떤 컴포넌트가 뷰포트에 있는지와 얼마나 오래 보이는지를 감지하여 로깅을 할 수 있다.
각각의 렌더링은 각각의 고유한 Effect를 갖는다
export default function ChatRoom({ roomId }) {
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
return () => connection.disconnect();
}, [roomId]);
return <h1>Welcome to {roomId}!</h1>;
}
export default function ChatRoom({ roomId }) {
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
return () => connection.disconnect();
}, [roomId]);
return <h1>Welcome to {roomId}!</h1>;
}
-
초기 렌더링
<ChatRoom roomId='general' /> // 첫 번째 렌더링의 의존성 (roomId = "general")
<ChatRoom roomId='general' /> // 첫 번째 렌더링의 의존성 (roomId = "general")
-
리렌더링
<ChatRoom roomId='general' /> // 두 번째 렌더링에 대한 의존성 (roomId = "general")
<ChatRoom roomId='general' /> // 두 번째 렌더링에 대한 의존성 (roomId = "general")
- React는 두 번째 렌더링에서의
['general']
를 첫 번째 렌더링에서의['general']
와 비교한다. 모든 의존성이 동일하므로 React는 두 번째 렌더링에서의 Effect를 무시한다. 해당 Effect는 호출되지 않는다.
- React는 두 번째 렌더링에서의
-
다른 의존성으로 렌더링
<ChatRoom roomId='travel' /> // 세 번째 렌더링에 대한 의존성 (roomId = "travel")
<ChatRoom roomId='travel' /> // 세 번째 렌더링에 대한 의존성 (roomId = "travel")
- 의존성이 변경된것을 감지한 React는 세 번째 렌더링의 Effect를 적용하기 전에 먼저 실행된 Effect를 정리해야 한다.
- 두 번째 렌더링의 Effect가 건너뛰어졌기 때문에, 무시하고 첫 번째 렌더링의 Effect를 정리한다.
- 첫번째 Effect의 클린업 함수 실행하여 연결해제한다.
- 그 후에 React는 세 번째 렌더링의 Effect를 실행한다.
'travel'
채팅방에 연결된다.
-
마운트 해제
- 마지막으로, 사용자가 다른 페이지로 이동하게 되어 컴포넌트가 마운트 해제된다.
- React는 마지막 Effect의 클린업 함수를 실행한다. (마지막 Effect는 세 번째 렌더링에서 온 것이다.)
번외) 새로운 값을 바로 반영을 위한 state 대신 ref
import { useRef, useState } from 'react';
export default function Chat() {
const [text, setText] = useState('');
function handleSend() {
setTimeout(() => {
alert('Sending: ' + text);
}, 3000);
}
return (
<>
<input value={text} onChange={(e) => setText(e.target.value)} />
<button onClick={handleSend}>Send</button>
</>
);
}
import { useRef, useState } from 'react';
export default function Chat() {
const [text, setText] = useState('');
function handleSend() {
setTimeout(() => {
alert('Sending: ' + text);
}, 3000);
}
return (
<>
<input value={text} onChange={(e) => setText(e.target.value)} />
<button onClick={handleSend}>Send</button>
</>
);
}
-
위 코드에서는 버튼을 누르고 값을 바꾸게 되면 alert창이 뜰때 버튼 누른 시점의 값이 표시된다.
-
이는 스냅샷처럼 state가 동작하기 때문이다.
-
이를 useRef를 이용하여 구현하면 alert창이 뜰때 최신값을 읽게 된다.
번외) 타이머와 useRef
let timeoutID;
return (
<button
onClick={() => {
clearTimeout(timeoutID);
timeoutID = setTimeout(() => {
onClick();
}, 1000);
}}
>
{children}
</button>
);
let timeoutID;
return (
<button
onClick={() => {
clearTimeout(timeoutID);
timeoutID = setTimeout(() => {
onClick();
}, 1000);
}}
>
{children}
</button>
);
-
timeoutID를 변수로 선언하게 되면 렌더링마다 새로 선언되므로 렌더링이 중간에 발생한다면 이전 타이머를 잃어버리게 된다.
-
따라서 렌더링을 기점으로 그 전 타이머는 클리어되지 않고 계속 유지된다.
const timeoutRef = useRef(null);
return (
<button
onClick={() => {
clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => {
onClick();
}, 1000);
}}
>
{children}
</button>
);
const timeoutRef = useRef(null);
return (
<button
onClick={() => {
clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => {
onClick();
}, 1000);
}}
>
{children}
</button>
);
- useRef를 이용하여 저장하면 리렌더링이 발생하더라도 그전 타이머를 잃지 않을 수 있다.
번외) 타이머에서 useRef를 쓰지않아도 되는 경우
import { useEffect, useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
function onTick() {
setCount((c) => c + 1);
}
const intervalId = setInterval(onTick, 1000);
return () => clearInterval(intervalId);
}, []);
return <h1>{count}</h1>;
}
import { useEffect, useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
function onTick() {
setCount((c) => c + 1);
}
const intervalId = setInterval(onTick, 1000);
return () => clearInterval(intervalId);
}, []);
return <h1>{count}</h1>;
}
intervalId
가 렌더링간 유지되어야하는 이유가 클리어하기 위해서이다. 근데 클린업 함수에 의해서 참조되고 있고 클로저에 의해 언마운트시 해당 값을 참조할 수 있으므로 useRef로 선언하지 않아도된다.- Effect 내부에서만 사용하기 때문에 ref로 선언하지 않아도 된다. 만약 타이머해제를 다른 함수에서 한다면 ref로 지정해야한다.
- useEffect의 의존성 배열이 비어있으므로 리렌더링 시에도 동일한
intervalId
가 유지된다.
4. Effect가 필요하지 않을 수도 있습니다
외부 시스템과 동기화하려면 필요하지만 그외 상황에는 반드시 필요하진 않다. 과도하게 Effect를 사용하고 있는 경우를 알아보고 최대한 줄여보자.
❌ Effect를 이용한 데이터 필터링
// ❌ 비효율적: Effect에서 필터링 후 state 업데이트
const [filteredTodos, setFilteredTodos] = useState([]);
useEffect(() => {
setFilteredTodos(todos.filter((todo) => todo.isDone));
}, [todos]);
// ✅ 좋은 예: 렌더링 중에 직접 필터링
const filteredTodos = todos.filter(todo => todo.isDone);
// ❌ 비효율적: Effect에서 필터링 후 state 업데이트
const [filteredTodos, setFilteredTodos] = useState([]);
useEffect(() => {
setFilteredTodos(todos.filter((todo) => todo.isDone));
}, [todos]);
// ✅ 좋은 예: 렌더링 중에 직접 필터링
const filteredTodos = todos.filter(todo => todo.isDone);
- 초기 렌더링 이후 필터가 적용되면서 리렌더링이 발생한다. 변수에 할당할 경우 초기렌더링에 계산하여 한번에 적용되므로 효율적이다.
- useMemo를 사용하면 리렌더링이 발생할때 재계산하지 않는다.
❌ props 또는 state에 따라 state 업데이트하기
function Form() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
// 🔴 중복된 state 및 불필요한 Effect
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(firstName + ' ' + lastName);
}, [firstName, lastName]);
// ✅ 렌더링 중에 계산됨
const fullName = firstName + ' ' + lastName;
}
function Form() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
// 🔴 중복된 state 및 불필요한 Effect
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(firstName + ' ' + lastName);
}, [firstName, lastName]);
// ✅ 렌더링 중에 계산됨
const fullName = firstName + ' ' + lastName;
}
- fullName이 state로 선언된 경우
- firstName이 변경되고 리렌더링
- fullName이 바뀌고 리렌더링
- lastName이 변경되고 리렌더링
- fullName이 바뀌고 리렌더링
- fullName이 변수로 선언된 경우
- firstName이 변경되고 리렌더링
- lastName이 변경되고 리렌더링
✅ useMemo로 개선하기
// 🔴 중복된 state 및 불필요한 효과
const [visibleTodos, setVisibleTodos] = useState([]);
useEffect(() => {
setVisibleTodos(getFilteredTodos(todos, filter));
}, [todos, filter]);
// 🔴 중복된 state 및 불필요한 효과
const [visibleTodos, setVisibleTodos] = useState([]);
useEffect(() => {
setVisibleTodos(getFilteredTodos(todos, filter));
}, [todos, filter]);
// ✅ todos 또는 filter가 변경되지 않는 한 다시 실행되지 않는다.
const visibleTodos = useMemo(() => {
return getFilteredTodos(todos, filter);
}, [todos, filter]);
// ✅ todos 또는 filter가 변경되지 않는 한 다시 실행되지 않는다.
const visibleTodos = useMemo(() => {
return getFilteredTodos(todos, filter);
}, [todos, filter]);
useMemo
로 감싸진 함수는 렌더링 중에 실행되므로, 순수한 계산에만 작동한다.useMemo
→ DOM 업데이트 →useEffect
순서로 동작한다.
✅ prop 변경 시 모든 state 초기화 → key값 사용
// 🔴 Effect에서 prop 변경 시 state 초기화
useEffect(() => {
setComment('');
}, [userId]);
// 🔴 Effect에서 prop 변경 시 state 초기화
useEffect(() => {
setComment('');
}, [userId]);
// ✅ key 변경 시 컴포넌트의 state 초기화
<Profile userId={userId} key={userId} />
// ✅ key 변경 시 컴포넌트의 state 초기화
<Profile userId={userId} key={userId} />
❌ prop이 변경될 때 일부 state 조정 → 변수로 선언
const [selection, setSelection] = useState(null);
// 🔴 Effect에서 prop 변경 시 state 조정하기
useEffect(() => {
setSelection(null);
}, [items]);
const [selection, setSelection] = useState(null);
// 🔴 Effect에서 prop 변경 시 state 조정하기
useEffect(() => {
setSelection(null);
}, [items]);
const [selection, setSelection] = useState(null);
// ⚠️ Effect보다는 효율적이지만 데이터 흐름을 이해하고 디버깅하기에 어렵다.
const [prevItems, setPrevItems] = useState(items);
if (items !== prevItems) {
setPrevItems(items);
setSelection(null);
}
const [selection, setSelection] = useState(null);
// ⚠️ Effect보다는 효율적이지만 데이터 흐름을 이해하고 디버깅하기에 어렵다.
const [prevItems, setPrevItems] = useState(items);
if (items !== prevItems) {
setPrevItems(items);
setSelection(null);
}
const [selectedId, setSelectedId] = useState(null);
// ✅ 렌더링 중에 모든 것을 계산
const selection = items.find((item) => item.id === selectedId) ?? null;
const [selectedId, setSelectedId] = useState(null);
// ✅ 렌더링 중에 모든 것을 계산
const selection = items.find((item) => item.id === selectedId) ?? null;
❌ 이벤트 핸들러 간 로직 공유
// 🔴 Effect 내부의 이벤트별 로직
useEffect(() => {
if (product.isInCart) {
showNotification(`...`);
}
}, [product]);
// 🔴 Effect 내부의 이벤트별 로직
useEffect(() => {
if (product.isInCart) {
showNotification(`...`);
}
}, [product]);
// ✅ 이벤트 핸들러에서 이벤트별 로직이 호출됩니다.
function buyProduct() {
addToCart(product);
showNotification(`...`);
}
function handleBuyClick() {
buyProduct();
}
function handleCheckoutClick() {
buyProduct();
navigateTo('/checkout');
}
// ✅ 이벤트 핸들러에서 이벤트별 로직이 호출됩니다.
function buyProduct() {
addToCart(product);
showNotification(`...`);
}
function handleBuyClick() {
buyProduct();
}
function handleCheckoutClick() {
buyProduct();
navigateTo('/checkout');
}
- 사용자의 행동에 따라 어떤 코드가 실행되는지 명확하게 알 수 있다.
❌ 연쇄 계산
- 다른 state에 따라 각각 state를 조정하는 Effect를 체이닝하는 경우가 있다.
// 🔴 서로를 트리거하기 위해서만 state를 조정하는 Effect 체인
useEffect(() => {
if (card !== null && card.gold) {
setGoldCardCount((c) => c + 1);
}
}, [card]);
useEffect(() => {
if (goldCardCount > 3) {
setRound((r) => r + 1);
setGoldCardCount(0);
}
}, [goldCardCount]);
useEffect(() => {
if (round > 5) {
setIsGameOver(true);
}
}, [round]);
useEffect(() => {
alert('Good game!');
}, [isGameOver]);
// 🔴 서로를 트리거하기 위해서만 state를 조정하는 Effect 체인
useEffect(() => {
if (card !== null && card.gold) {
setGoldCardCount((c) => c + 1);
}
}, [card]);
useEffect(() => {
if (goldCardCount > 3) {
setRound((r) => r + 1);
setGoldCardCount(0);
}
}, [goldCardCount]);
useEffect(() => {
if (round > 5) {
setIsGameOver(true);
}
}, [round]);
useEffect(() => {
alert('Good game!');
}, [isGameOver]);
- 이 경우 렌더링 중에 가능한 것을 계산하고 이벤트 핸들러에서 state를 조정하는 것이 좋습니다.
// ✅ 렌더링 중에 가능한 것을 계산합니다.
const isGameOver = round > 5;
function handlePlaceCard(nextCard) {
if (isGameOver) {
throw Error('Game already ended.');
}
// ✅ 이벤트 핸들러에서 다음 state를 모두 계산합니다.
setCard(nextCard);
if (nextCard.gold) {
if (goldCardCount <= 3) {
setGoldCardCount(goldCardCount + 1);
} else {
setGoldCardCount(0);
setRound(round + 1);
if (round === 5) {
alert('Good game!');
}
}
}
// ✅ 렌더링 중에 가능한 것을 계산합니다.
const isGameOver = round > 5;
function handlePlaceCard(nextCard) {
if (isGameOver) {
throw Error('Game already ended.');
}
// ✅ 이벤트 핸들러에서 다음 state를 모두 계산합니다.
setCard(nextCard);
if (nextCard.gold) {
if (goldCardCount <= 3) {
setGoldCardCount(goldCardCount + 1);
} else {
setGoldCardCount(0);
setRound(round + 1);
if (round === 5) {
alert('Good game!');
}
}
}
- 이벤트 핸들러 내부에서 state는 스냅샷처럼 동작한다는 것을 주의한다.
- 계산에 다음 값을 사용해야 하는 경우
const nextRound = round + 1
처럼 변수로 선언하고 그 값을 이용한다.
❌ 애플리케이션 초기화
앱 로드당 한번 실행될 경우 useEffect
를 이용하기 보다 전역에 선언한다.
let didInit = false;
function App() {
useEffect(() => {
if (!didInit) {
didInit = true;
// ✅ 앱 로드당 한 번만 실행
loadDataFromLocalStorage();
checkAuthToken();
}
}, []);
// ...
}
let didInit = false;
function App() {
useEffect(() => {
if (!didInit) {
didInit = true;
// ✅ 앱 로드당 한 번만 실행
loadDataFromLocalStorage();
checkAuthToken();
}
}, []);
// ...
}
if (typeof window !== 'undefined') {
// 브라우저에서 실행 중인지 확인합니다.
// ✅ 앱 로드당 한 번만 실행
checkAuthToken();
loadDataFromLocalStorage();
}
function App() {
// ...
}
if (typeof window !== 'undefined') {
// 브라우저에서 실행 중인지 확인합니다.
// ✅ 앱 로드당 한 번만 실행
checkAuthToken();
loadDataFromLocalStorage();
}
function App() {
// ...
}
- 모듈 초기화 중이나 앱이 렌더링 되기 전에 실행할 수도 있으므로 적절한 조건문을 추가한다.
❌ state 변경을 부모 컴포넌트에게 알리기
const [isOn, setIsOn] = useState(false);
// 🔴 onChange 핸들러가 너무 늦게 실행됨
useEffect(() => {
onChange(isOn);
}, [isOn, onChange]);
const [isOn, setIsOn] = useState(false);
// 🔴 onChange 핸들러가 너무 늦게 실행됨
useEffect(() => {
onChange(isOn);
}, [isOn, onChange]);
function updateToggle(nextIsOn) {
// ✅ 업데이트를 유발한 이벤트가 발생한 동안 모든 업데이트를 수행합니다.
setIsOn(nextIsOn);
onChange(nextIsOn);
}
function updateToggle(nextIsOn) {
// ✅ 업데이트를 유발한 이벤트가 발생한 동안 모든 업데이트를 수행합니다.
setIsOn(nextIsOn);
onChange(nextIsOn);
}
만약에 부모로부터 전달받은 onChange에는 디바운스를 적용하려면 어떻게 해야할까?
부모에서 onChange가 선언되어있는 곳에서 디바운스를 적용하는게 적절하다. 디바운스는 가능한 이벤트 소스에서 처리하고 자녀 컴포넌트는 가능한 순수하게 유지하는게 좋다.
// ✅ 혹은 컴포넌트는 부모에 의해 완전히 제어됩니다.
function Toggle({ isOn, onChange }) {
function handleClick() {
onChange(!isOn);
}
// ✅ 혹은 컴포넌트는 부모에 의해 완전히 제어됩니다.
function Toggle({ isOn, onChange }) {
function handleClick() {
onChange(!isOn);
}
❌ 외부 저장소 구독하기
// ❌ React 18의 Concurrent Features에서 상태 불일치 가능성
const [state, setState] = useState(store.getState());
useEffect(() => {
const unsubscribe = store.subscribe(() => {
setState(store.getState());
});
return unsubscribe;
}, []);
// ❌ React 18의 Concurrent Features에서 상태 불일치 가능성
const [state, setState] = useState(store.getState());
useEffect(() => {
const unsubscribe = store.subscribe(() => {
setState(store.getState());
});
return unsubscribe;
}, []);
useSyncExternalStore
를 이용하여 외부 저장소를 구독한다.
import { useSyncExternalStore } from 'react';
const state = useSyncExternalStore(
subscribe, // 스토어의 변경을 구독하는 함수
getSnapshot, // 스토어의 현재 상태를 읽는 함수
getServerSnapshot?, // SSR 시 사용할 초기 상태 (옵션)
);
import { useSyncExternalStore } from 'react';
const state = useSyncExternalStore(
subscribe, // 스토어의 변경을 구독하는 함수
getSnapshot, // 스토어의 현재 상태를 읽는 함수
getServerSnapshot?, // SSR 시 사용할 초기 상태 (옵션)
);
function subscribe(callback) {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
}
function useOnlineStatus() {
// ✅ 내장 Hook으로 외부 스토어 구독하기
return useSyncExternalStore(
subscribe, // 동일한 함수를 전달하는 한 React는 다시 구독하지 않습니다.
() => navigator.onLine, // 클라이언트에서 값을 얻는 방법
() => true, // 서버에서 값을 얻는 방법
);
}
function ChatIndicator() {
const isOnline = useOnlineStatus();
// ...
}
function subscribe(callback) {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
}
function useOnlineStatus() {
// ✅ 내장 Hook으로 외부 스토어 구독하기
return useSyncExternalStore(
subscribe, // 동일한 함수를 전달하는 한 React는 다시 구독하지 않습니다.
() => navigator.onLine, // 클라이언트에서 값을 얻는 방법
() => true, // 서버에서 값을 얻는 방법
);
}
function ChatIndicator() {
const isOnline = useOnlineStatus();
// ...
}
✅ 데이터 가져오기
useEffect(() => {
// 🔴 정리 로직 없이 가져오기
fetchResults(query, page).then((json) => {
setResults(json);
});
}, [query, page]);
useEffect(() => {
// 🔴 정리 로직 없이 가져오기
fetchResults(query, page).then((json) => {
setResults(json);
});
}, [query, page]);
- 데이터를 가져오는 Effect를 작성하는 것은 매우 일반적이다.
- 하지만 위의 코드에는 경쟁 조건에 대한 처리가 되어있지 않다.
- 예를들어,
"hello"
를 빠르게 입력할때query
가"h"
에서"he"
,"hel"
,"hell"
,"hello"
로 바뀌고 각각에 대해서 데이터가 요청된다. 만약"hello"
응답 후에"hell"
응답이 도착하게 된다면 결과적으로"hell"
의 데이터를 표시하게 된다. 이를 "경쟁 조건"이라고 하는데, 서로 다른 두 요청이 서로 "경쟁"하여 예상과 다른 순서로 도착하는 것을 말한다.
useEffect(() => {
let ignore = false;
fetchResults(query, page).then((json) => {
if (!ignore) {
setResults(json);
}
});
return () => {
ignore = true;
};
}, [query, page]);
useEffect(() => {
let ignore = false;
fetchResults(query, page).then((json) => {
if (!ignore) {
setResults(json);
}
});
return () => {
ignore = true;
};
}, [query, page]);
- 경쟁 조건 외에도 에러처리, 로딩여부 등을 위한 코드를 추가하게 되면 로직을 파악하기 어려워지므로
useEffect
보다는 사용자 정의 Hook을 만들거나 프레임워크를 사용하는 방법도 있다.
5. 반응형 effects의 생명주기
Effect의 관점에서 생각하기, React의 방식
같은 동작이지만 React가 설명하고자하는 방식에 대한 이해가 필요하다. 첫 마운트, 업데이트, 언마운트 등의 시점을 구분하면서 코드를 짜는것이 아니라 '동기화'에 초점을 두고 이해하면 선언적으로 코드를 구성할 수 있고 React가 추구하는 방식을 잘 이해할 수 있다.
useEffect(() => {
// roomId로 지정된 방에 연결된 effect...
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
// ...연결이 끊어질 때까지
connection.disconnect();
};
}, [roomId]);
useEffect(() => {
// roomId로 지정된 방에 연결된 effect...
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
// ...연결이 끊어질 때까지
connection.disconnect();
};
}, [roomId]);
-
"이 코드는 컴포넌트가 처음 마운트될 때만 실행되어야 하는가?", "업데이트 시에는 어떻게 다른 동작을 해야 하는가?", "마운트 해제 시 정리 로직은 어떻게 처리하지?" 와 같은 개발자가 모든 단계를 수동적으로 제어해야 하는 명령형 사고에서 벗어나야한다.
-
React는 컴포넌트는 외부 시스템과 현재 props/state를 동기화하는 것이라는 접근법을 제시한다.
- 즉, 다음 두 가지만 정의하면 된다.
- 동기화 시작 방법: 외부 시스템에 연결하는 코드 (예: 구독, 타이머, API 호출).
- 동기화 중지 방법: 연결을 해제하는 코드 (예: 구독 취소, 타이머 클리어).
- 동기화의 관점에서 생각한다.
- 마운트/업데이트/언마운트를 구분할 필요 없이,
roomId
가 바뀔 때마다 자동으로 재동기화 된다. - React가 최적의 타이밍에 시작/중지를 관리한다.
- 마운트/업데이트/언마운트를 구분할 필요 없이,
- 즉, 다음 두 가지만 정의하면 된다.
기존 클래스 컴포넌트에서는
componentDidMount
,componentDidUpdate
,componentWillUnmount
를 구분해 로직을 작성했지만, 함수형 컴포넌트의 Effect는 "동기화 시작"과 "동기화 중지"만 정의하면 된다. React가 마운트/업데이트/언마운트 시점을 자동으로 관리해 줍니다.
// ❌ 명령형 (과거의 방식)
if (isFirstMount) {
setupTimer(); // 마운트 시만 실행
} else {
updateTimer(); // 업데이트 시만 실행
}
// ✅ 선언적 (React의 방식)
useEffect(() => {
const timer = setInterval(() => {}, 1000); // 동기화 시작
return () => clearInterval(timer); // 동기화 중지
}, [deps]);
// ❌ 명령형 (과거의 방식)
if (isFirstMount) {
setupTimer(); // 마운트 시만 실행
} else {
updateTimer(); // 업데이트 시만 실행
}
// ✅ 선언적 (React의 방식)
useEffect(() => {
const timer = setInterval(() => {}, 1000); // 동기화 시작
return () => clearInterval(timer); // 동기화 중지
}, [deps]);
// ❌ "이 버튼이 처음 나타날 때만 스타일을 적용해야 한다" (명령형)
if (isFirstRender) {
buttonRef.current.style.color = 'red';
}
// ✅ "버튼은 항상 빨간색이어야 한다" (선언형)
<button style={{ color: 'red' }}>Click</button>;
// ❌ "이 버튼이 처음 나타날 때만 스타일을 적용해야 한다" (명령형)
if (isFirstRender) {
buttonRef.current.style.color = 'red';
}
// ✅ "버튼은 항상 빨간색이어야 한다" (선언형)
<button style={{ color: 'red' }}>Click</button>;
React가 effect를 다시 동기화해야 한다는 것을 인식하는 방법
function ChatRoom({ roomId }) { // roomId prop은 시간이 지남에 따라 변경될 수 있습니다.
useEffect(() => {
const connection = createConnection(serverUrl, roomId); // 이 effect는 roomId를 읽습니다.
connection.connect();
return () => {
connection.disconnect();
};
}, [roomId]);
function ChatRoom({ roomId }) { // roomId prop은 시간이 지남에 따라 변경될 수 있습니다.
useEffect(() => {
const connection = createConnection(serverUrl, roomId); // 이 effect는 roomId를 읽습니다.
connection.connect();
return () => {
connection.disconnect();
};
}, [roomId]);
종속성 목록에 roomId
를 포함함으로써 해당 코드가 roomId
에 종속되어 있다고 React에 알렸다.
roomId
가prop
이므로 시간이 지남에 따라 변경될 수 있다.- effect가
roomId
를 읽는다는 것을 알았다.(따라서 로직이, 나중에 변경될 수 있는 값에 따라 달라진다.) - 그렇기 때문에
roomId
를 effect의 종속성으로 지정한 것이다 (roomId
가 변경되면 다시 동기화되도록).
각 effect는 별도의 동기화 프로세스를 나타낸다.
function ChatRoom({ roomId }) {
useEffect(() => {
logVisit(roomId);
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect();
};
}, [roomId]);
// ...
}
function ChatRoom({ roomId }) {
useEffect(() => {
logVisit(roomId);
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect();
};
}, [roomId]);
// ...
}
- 각 effect는 별도의 독립적인 동기화 프로세스를 나타내야 한다. 따라서 동일한 종속성을 가진다고해서 하나의 Effect로 합치지 말아야한다.
- 나중에 채팅방 연결 로직의 수정이 있을때 logVisit 부분이 의도치 않게 추가 실행될 수 있다.
- 따라서 동일한 프로세스인지 여부로 묶어야 한다.
반응형 값에 "반응"하는 effect
const serverUrl = 'https://localhost:1234';
function ChatRoom({ roomId }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect();
};
}, [roomId]);
// ...
}
const serverUrl = 'https://localhost:1234';
function ChatRoom({ roomId }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect();
};
}, [roomId]);
// ...
}
- 의존성 배열에 추가되는 기준은 “리렌더링으로 인해 값이 바뀔 가능성이 있는가?”
- state냐 아니냐로 넣는게 아니다.
- 위에 serverUrl의 경우는 아무리 렌더링이 발생하더라도 값이 바뀔수 없으니 의존성배열에 넣지 않아도 된다.
컴포넌트 본문에서 선언된 모든 변수는 반응형 값
function ChatRoom({ roomId, selectedServerUrl }) {
// roomId는 반응형입니다.
const settings = useContext(SettingsContext); // settings는 반응형입니다.
const serverUrl = selectedServerUrl ?? settings.defaultServerUrl; // serverUrl는 반응형입니다.
useEffect(() => {
const connection = createConnection(serverUrl, roomId); // effect는 roomId 와 serverUrl를 읽습니다.
connection.connect();
return () => {
connection.disconnect();
};
}, [roomId, serverUrl]); // 따라서 둘 중 하나가 변경되면 다시 동기화
// ...
}
function ChatRoom({ roomId, selectedServerUrl }) {
// roomId는 반응형입니다.
const settings = useContext(SettingsContext); // settings는 반응형입니다.
const serverUrl = selectedServerUrl ?? settings.defaultServerUrl; // serverUrl는 반응형입니다.
useEffect(() => {
const connection = createConnection(serverUrl, roomId); // effect는 roomId 와 serverUrl를 읽습니다.
connection.connect();
return () => {
connection.disconnect();
};
}, [roomId, serverUrl]); // 따라서 둘 중 하나가 변경되면 다시 동기화
// ...
}
- props와 state만 반응형 값인 것은 아니다. 이들로부터 계산하는 값도 반응형 값이다. props나 state가 변경되면 컴포넌트가 다시 렌더링 되고 그로부터 계산된 값도 변경된다. 이 때문에 effect에서 사용하는 컴포넌트 본문의 모든 변수는 effect 종속성 목록에 있어야 한다.
serverUrl
은 prop이나 state 가 아니라 렌더링 중에 계산하는 일반 변수이다. 하지만 렌더링 중에 계산되므로 재 렌더링으로 인해 변경될 수 있기 때문에 반응형이다.
반응형 값 vs 변경 가능한 값
- React는
state
/props
/context
의 변화만 감지할 수 있다. - 의존성 배열은 "React가 제어하는 값"만 포함해야 효과가 있다.
window.location
와 같은 외부 값과 연동하려면 별도의 훅(useLocation
)을 사용하거나useSyncExternalStore
을 이용한다.
// ❌ 무의미!
useEffect(() => {
console.log('Path changed:', window.location.pathname);
}, [window.location.pathname]);
// ✅ React Router 사용 시 (의존성 배열에 pathname 포함)
const { pathname } = useLocation();
useEffect(() => {
console.log('Path changed:', pathname);
}, [pathname]);
// ❌ 무의미!
useEffect(() => {
console.log('Path changed:', window.location.pathname);
}, [window.location.pathname]);
// ✅ React Router 사용 시 (의존성 배열에 pathname 포함)
const { pathname } = useLocation();
useEffect(() => {
console.log('Path changed:', pathname);
}, [pathname]);
import { useSyncExternalStore } from 'react';
function useLocationPath() {
return useSyncExternalStore(
(callback) => {
window.addEventListener('popstate', callback); // 구독
return () => window.removeEventListener('popstate', callback); // 구독 해제
},
() => window.location.pathname, // 현재 값 가져오기
);
}
// 사용 예시
function Component() {
const pathname = useLocationPath(); // ✅ 반응형 값으로 변환
useEffect(() => {
console.log('Path changed:', pathname);
}, [pathname]); // 이제 의존성 배열에 안전하게 포함 가능
}
import { useSyncExternalStore } from 'react';
function useLocationPath() {
return useSyncExternalStore(
(callback) => {
window.addEventListener('popstate', callback); // 구독
return () => window.removeEventListener('popstate', callback); // 구독 해제
},
() => window.location.pathname, // 현재 값 가져오기
);
}
// 사용 예시
function Component() {
const pathname = useLocationPath(); // ✅ 반응형 값으로 변환
useEffect(() => {
console.log('Path changed:', pathname);
}, [pathname]); // 이제 의존성 배열에 안전하게 포함 가능
}
반응형 값이 아니라는 것은 리렌더링의 결과로 변경될 수 없다는 것을 의미한다
const serverUrl = 'https://localhost:1234'; // serverUrl는 반응형이 아니다.
function ChatRoom() {
const roomId = 'general'; // roomId는 반응형이 아니다.
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect();
};
}, []); // ✅ 선언된 모든 종속성
// ...
}
const serverUrl = 'https://localhost:1234'; // serverUrl는 반응형이 아니다.
function ChatRoom() {
const roomId = 'general'; // roomId는 반응형이 아니다.
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect();
};
}, []); // ✅ 선언된 모든 종속성
// ...
}
- 컴포넌트 외부에 선언하여 리렌더링해도 값이 절대 변하지 않는걸 증명하거나 내부에 선언해도 상수를 저장하게 되면 리렌더링해도 값이 변하지 않으므로 의존성에 포함시키지 않아도 된다.
6. Effect에서 이벤트 분리하기
이벤트 핸들러와 Effect 중에 선택하기
- 실행
- 이벤트 핸들러 : 특정 상호작용에 대한 응답으로 실행
- Effect : 동기화가 필요할 때마다 실행
- 트리거
- 이벤트 핸들러 : 버튼 클릭과 같이 항상 “수동으로” 트리거
- Effect : 동기화 유지에 필요한 만큼 자주 실행 및 재실행되기 때문에 “자동으로” 트리거
- 재실행
- 이벤트 핸들러 : 사용자가 같은 상호작용(예: 클릭)을 반복하지 않는 한 재실행되지 않습니다.
- Effect : Effect에서 반응형 값을 읽는 경우 그 값을 의존성으로 지정해야 한다. 그렇게 하면 리렌더링이 그 값을 바꾸는 경우 React가 새로운 값으로 Effect의 로직을 다시 실행합니다.
이벤트 핸들러가 적절한지 Effect가 적절한지는 '반응형 값이 변했을때 재실행이 되어야하는가?'로 판단할 수 있다. 반응형 값이 변했을때 재실행이 되어야한다면 Effect이다.
예시) 채팅방 연결
- 새로운 채팅방을 클릭할때마다 바뀌므로 이벤트 핸들러로 생각할수도 있으나 채팅방 컴포넌트가 앱의 첫 화면이고 사용자가 아무런 상호작용을 하지 않은 경우라 해도 여전히 연결되어 있어야 한다. 그러므로 이 코드는 Effect이다.
sendMessage(message);
// message이 바뀐다고 sendMessage를 재실행 해야하는가? → no → 이벤트 핸들러
sendMessage(message);
// message이 바뀐다고 sendMessage를 재실행 해야하는가? → no → 이벤트 핸들러
const connection = createConnection(serverUrl, roomId);
connection.connect();
// roomId이 바뀐다고 createConnection를 재실행해야하는가? → yes → Effect
const connection = createConnection(serverUrl, roomId);
connection.connect();
// roomId이 바뀐다고 createConnection를 재실행해야하는가? → yes → Effect
connection.on('connected', () => {
showNotification('연결됨!', theme);
});
// theme이 바뀐다고 showNotification를 재실행해야하는가? → no → 이벤트 핸들러
connection.on('connected', () => {
showNotification('연결됨!', theme);
});
// theme이 바뀐다고 showNotification를 재실행해야하는가? → no → 이벤트 핸들러
7. Effect 의존성 제거하기
의존성을 제거하려면 의존성이 아님을 증명하세요
const serverUrl = 'https://localhost:1234';
const roomId = 'music'; // Not a reactive value anymore
function ChatRoom() {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => connection.disconnect();
}, []); // ✅ All dependencies declared
// ...
}
const serverUrl = 'https://localhost:1234';
const roomId = 'music'; // Not a reactive value anymore
function ChatRoom() {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => connection.disconnect();
}, []); // ✅ All dependencies declared
// ...
}
- Effect의 코드에서 사용되는 모든 반응형 값은 의존성 목록에 선언되어야 한다.
- 반응형 값에는 props와 컴포넌트 내부에서 직접 선언된 모든 변수 및 함수가 포함된다.
- 컴포넌트 밖으로 이동시켜서 반응형값이 아니고 리렌더링 시에도 변경되지 않음을 증명할 수 있다.
Effect 가 관련 없는 여러 가지 작업을 수행하나요?
useEffect(() => {
let ignore = false;
fetch(`/api/cities?country=${country}`)
.then((response) => response.json())
.then((json) => {
if (!ignore) {
setCities(json);
}
});
// 🔴 두개의 독립적인 프로세스를 하나의 Effect에서 다룬다.
if (city) {
fetch(`/api/areas?city=${city}`)
.then((response) => response.json())
.then((json) => {
if (!ignore) {
setAreas(json);
}
});
}
return () => {
ignore = true;
};
}, [country, city]);
useEffect(() => {
let ignore = false;
fetch(`/api/cities?country=${country}`)
.then((response) => response.json())
.then((json) => {
if (!ignore) {
setCities(json);
}
});
// 🔴 두개의 독립적인 프로세스를 하나의 Effect에서 다룬다.
if (city) {
fetch(`/api/areas?city=${city}`)
.then((response) => response.json())
.then((json) => {
if (!ignore) {
setAreas(json);
}
});
}
return () => {
ignore = true;
};
}, [country, city]);
- 나라와 도시에 대한 로직이 하나의 Effect안에 있는데, 이는 도시를 변경할때 다시 나라에 대한 데이터를 요청하게 된다.
useEffect(() => {
let ignore = false;
fetch(`/api/cities?country=${country}`)
.then((response) => response.json())
.then((json) => {
if (!ignore) {
setCities(json);
}
});
return () => {
ignore = true;
};
}, [country]);
useEffect(() => {
if (city) {
let ignore = false;
fetch(`/api/areas?city=${city}`)
.then((response) => response.json())
.then((json) => {
if (!ignore) {
setAreas(json);
}
});
return () => {
ignore = true;
};
}
}, [city]);
useEffect(() => {
let ignore = false;
fetch(`/api/cities?country=${country}`)
.then((response) => response.json())
.then((json) => {
if (!ignore) {
setCities(json);
}
});
return () => {
ignore = true;
};
}, [country]);
useEffect(() => {
if (city) {
let ignore = false;
fetch(`/api/areas?city=${city}`)
.then((response) => response.json())
.then((json) => {
if (!ignore) {
setAreas(json);
}
});
return () => {
ignore = true;
};
}
}, [city]);
- 두개로 분리하여 개별로 동작하도록 한다.
실험) useEffect에서 의도적으로 원치않은 의존성을 제거하고 싶을때 useEffectEvent
useEffectEvent는 아직 안정화 버전에 출시되지 않은 훅이다.
function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(() => {
showNotification('연결됨!', theme);
});
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('connected', () => {
onConnected();
});
connection.connect();
return () => connection.disconnect();
}, [roomId]); // ✅ 모든 의존성이 선언됨
// ...
function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(() => {
showNotification('연결됨!', theme);
});
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('connected', () => {
onConnected();
});
connection.connect();
return () => connection.disconnect();
}, [roomId]); // ✅ 모든 의존성이 선언됨
// ...
- 여기서
onConnected
를 'Effect 이벤트'라고 한다. - 이벤트 핸들러는 사용자의 상호작용에 대한 응답으로 실행되는 반면에 Effect 이벤트는 Effect에서 직접 트리거 된다는 것에 차이가 있다.
- Effect 이벤트를 사용하면 Effect의 반응성과 반응형이어서는 안 되는 코드 사이의 연결을 끊어준다.
useEffectEvent
는 반응형이 아니길 원하는 코드 라인에만 적용한다.// eslint-disable-next-line react-hooks/exhaustive-deps
이 주석을 통해서도 린트 룰을 피해서 원치 않는 의존성을 제거할수있지만, Effect 안에 모든 코드에 전부 적용되므로 위험이 있다.useEffectEvent
를 사용하게 되면 안에 코드만 반응형을 무시하고 나머지 코드에 대해서는 린트룰을 적용하므로 더 안정적이다.
일부 반응형 값이 의도치 않게 변경되나요?
const options = {
serverUrl: serverUrl,
roomId: roomId,
};
useEffect(() => {
const connection = createConnection(options);
connection.connect();
return () => connection.disconnect();
}, [options]);
const options = {
serverUrl: serverUrl,
roomId: roomId,
};
useEffect(() => {
const connection = createConnection(options);
connection.connect();
return () => connection.disconnect();
}, [options]);
- Effect안에서 사용되는 객체 options를 컴포넌트 내에 선언할 경우, 컴포넌트가 리렌더링 될때마다 options는 재선언되고 이에 useEffect도 다시 실행된다.
- 해결방법
- useMemo로 객체를 감싼다.
const options = useMemo( () => ({ serverUrl: serverUrl, roomId: roomId, }), [serverUrl, roomId], );
const options = useMemo( () => ({ serverUrl: serverUrl, roomId: roomId, }), [serverUrl, roomId], );
- useEffect안에 options를 선언한다.
- 구조분해 할당을 통해 객체내에 데이터를 모두 의존성배열에 추가한다.
const { roomId, serverUrl } = options; useEffect(() => { const connection = createConnection({ roomId: roomId, serverUrl: serverUrl, }); connection.connect(); return () => connection.disconnect(); }, [roomId, serverUrl]);
const { roomId, serverUrl } = options; useEffect(() => { const connection = createConnection({ roomId: roomId, serverUrl: serverUrl, }); connection.connect(); return () => connection.disconnect(); }, [roomId, serverUrl]);
- useMemo로 객체를 감싼다.
8. 커스텀 Hook으로 로직 재사용하기
- 커스텀 hook은 내부 코드가 어떻게 그것을 하는지 보다 그들이 무엇을 하려는지에 대해 설명하고 있다.
작명규칙
- Hook의 이름은
use
뒤에 대문자로 시작해야 한다.- 이런 규칙들은 컴포넌트를 볼 때, 어디에 state, Effect 및 다른 React 기능들이 포함되어 있는지 알 수 있게 해준다.
- 커스텀 Hook이 무슨 일을 하고, 무엇을 props로 받고, 무엇을 반환하는지 알 수 있도록 아주 명확해야 한다.
- 더 기술적이고 해당 시스템을 특정하는 용어를 사용하는 것이 좋다.
커스텀 Hook은 state 그 자체를 공유하는게 아닌 state '저장 로직'을 공유한다.
function StatusBar() {
const isOnline = useOnlineStatus();
// ...
}
function SaveButton() {
const isOnline = useOnlineStatus();
// ...
}
function StatusBar() {
const isOnline = useOnlineStatus();
// ...
}
function SaveButton() {
const isOnline = useOnlineStatus();
// ...
}
- 커스텀 Hook은 우리가 state 그 '값 자체'가 아닌 state '저장 로직'을 공유하도록 해준다.
- 같은 Hook을 호출하더라도 각각의 Hook 호출은 완전히 독립되어 있다.
- 여러 컴포넌트 간 state 자체를 공유할 필요가 있다면, state를 위로 올려 전달하거나 Context API를 사용해야한다.
Hook 사이에 상호작용하는 값 전달하기
export default function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');
useChatRoom({
roomId: roomId,
serverUrl: serverUrl
});
// ...
export default function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');
useChatRoom({
roomId: roomId,
serverUrl: serverUrl
});
// ...
- 커스텀 Hook 안의 코드는 컴포넌트가 리렌더링될 때마다 다시 실행된다.
- 이게 바로 커스컴 Hook이 (컴포넌트처럼) 순수해야하는 이유이다.
- 매번
ChatRoom
가 리렌더링될 때마다, Hook에 최신roomId
와serverUrl
값을 넘겨준다.