모든 랜더링은 고유의 Prop과 State가 있다.
여느 특정 랜더링 시 그 안에 있는 state 상수는 시간이 지난다고 바뀌는 것이 아니다. 컴포넌트가 다시 호출되고, 각각의 랜더링마다 격리된 고유의 state 값을 “보는” 것이다.
모든 랜더링은 고유의 이벤트 핸들러를 가진다.
아래의 예제는 3초 뒤에 count 값과 함께 alert을 띄워준다.
function Counter() {
const [count, setCount] = useState(0);
function handleAlertClick() {
setTimeout(() => {
alert('You clicked on: ' + count);
}, 3000);
}
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
<button onClick={handleAlertClick}>
Show alert
</button>
</div>
);
}
만약 아래의 3단계를 실행하면 alert에 어떤 count값이 나올까?
정답은 3이다. 왜그럴까?
앞서 count 값이 매번 별개의 함수 호출마다 존재하는 상수값이라고 했다. 함수는 여러번 호출되지만(랜더링마다 한 번씩), 각각의 랜더링에서 함수 안의 count 값은 상수이자 독립적인 값(특정 랜더링 시의 상태)으로 존재한다.
따라서, 특정 랜더링 시 그 내부에서 props와 state는 영원히 같은 상태로 유지된다. props와 state가 랜더링으로부터 분리되어 있다면, 이를 사용하는 어떠한 값(이벤트 핸들러 등)도 분리되어 있는 것이다. 따라서 이벤트 핸들러 내부의 비동기 함수라 할지라도 같은 count 값을 “보게” 된다.
위의 예제에서 객체와 같이 다른 값에 대해서도 같은 생각을 할 수 지만, 오로지 불변 값을 사용한다는 전제가 있어야 한다.
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
document.title = `You clicked ${count} times`;
});
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}
그렇다면 위 예제에서 어떻게 useEffect가 최신의 count 상태를 읽어 들이는 걸까?
우리는 이미 count는 특정 컴포넌트 랜더링에 포함되는 상수라고 배웠으며 이벤트 핸들러는 그 랜더링에 속한 count 상태를 본다.
그리고 이펙트에도 똑같은 개념이 적용된다.
변화하지 않는 이펙트 안에서 count 변수가 임의로 바뀐다는 뜻이 아닌 이펙트 함수 자체가 매 랜더링마다 별도로 존재한다.
각각의 이펙트 버전은 매번 랜더링에 속한 count 값을 본다.
리액트는 이펙트 함수를 기억해 놨다가 DOM의 변화를 처리하고 브라우저가 스크린에 그리고 난 뒤 실행한다.
따라서 비록 우리가 하나의 개념으로 이펙트를 이야기하고 있지만, 사실 매 랜더링 마다 다른 함수라는 뜻이다. 그리고 각각의 이펙트 함수는 그 랜더링에 속한 props와 state를 본다.
개념적으로, 이펙트는 랜더링 결과의 일부라 볼 수 있다.
엄격하게 이야기하자면 그렇진 않지만, 우리가 형성하고 있는 멘탈 모델 속에서 이펙트 함수는 이벤트 핸들러처럼 특정 랜더링에 속하는 함수라고 생각면 된다.
다시 한번 첫 번째 랜더링을 되짚어 보자.
리액트가 state가 0 일 때의 UI를 요구한다.
컴포넌트는 랜더링 결과물로
You clicked 0 times
를 제공한다.리액트는 UI를 업데이트 하기위해 브라우저에게 DOM에 추가 사항을 알려고 브라우저가 이를 화면에 그려준다.
리액트는 컴포넌트가 준 이펙트를 실행한다.
그럼 버튼을 클릭하면 어떤 일이 벌어질까?
리액트가 state가 1 일 때의 UI를 요구한다.
컴포넌트는 랜더링 결과물로
You clicked 1 times
를 제공한다.리액트는 UI를 업데이트 하기위해 브라우저에게 DOM에 추가 사항을 알려고 브라우저가 이를 화면에 그려준다.
리액트는 컴포넌트가 준 이펙트를 실행한다.
이제 useEffect는 매 랜더링 후 실행되며, 그리고 개념적으로 컴포넌트 결과물의 일부로서 특정 랜더링 시점의 prop과 state를 “본다”는 것을 확인했다.
다시 한번 정리해보자면,
“컴포넌트의 랜더링 안에 있는 모든 함수는 (이벤트 핸들러, useEffect, setTimeout이나 그 안에서 호출되는 API 등) 랜더(render)가 호출될 때 정의된 props와 state 값을 잡아둔다.”
따라서 아래의 두 예제는 같다.
function Example(props) {
useEffect(() => {
setTimeout(() => {
console.log(props.counter);
}, 1000);
});
// ...
}
function Example(props) {
const counter = props.counter;
useEffect(() => {
setTimeout(() => {
console.log(counter);
}, 1000);
});
// ...
}
props나 state를 컴포넌트 안에서 일찍 읽어 들였는지 아닌지는 상관 없다. 하나의 랜더링 스코프 안에서 props와 state는 변하지 않은 값으로 남아있기 때문이다. (값들을 분해 할당하면 더 확실해진다.)
클린업(cleanup)
클린업 함수는 useEffect 내의 함수가 여러번 실행될 때, 다음 useEffect 가 실행되기 전에 실행되는 함수이다.
컴포넌트가 언마운트 되거나 업데이트 되기 직전에 어떤 작업을 수행하고 싶다면, 클린업 함수를 반환해주어야 한다.
useEffect(() => {
ChatAPI.subscribe(props.id, handleStatusChange);
return () => {
ChatAPI.unsubscribe(props.id, handleStatusChange);
};
});
첫 번째 랜더링에서 prop 이 {id: 10} 이고, 두 번째 랜더링에서 {id: 20} 이라고 할 때 아마 아래와 같은 흐름을 예상할 것이다.
리액트가 {id: 10} 을 다루는 이펙트를 클린업한다.
리액트가 {id: 20} 을 가지고 UI를 랜더링한다.
리액트가 {id: 20} 으로 이펙트를 실행한다.
위의 순서대로라면, 클린업이 리랜더링 되기 전에 실행되고 이전의 prop을 보고, 그 다음 새 이펙트가 리랜더링 이후 실행되기 때문에 새 prop을 본다고 생각할 수 있지만 이는 잘못된 내용이다.
리액트는 브라우저가 페인트 하고 난 뒤에야 useEffect를 실행하여 대부분의 이펙트가 스크린 업데이트를 가로막지 않기 때문에 앱을 빠르게 만들어준다. 마찬가지로 이펙트의 클린업도 미뤄진다. 때문에 이전 useEffect는 새 prop과 함께 리랜더링 되고 난 뒤에 클린업된다.
리액트가 {id: 20} 을 가지고 UI를 랜더링한다.
브라우저가 화면 상에 {id: 20} 이 반영된 UI를 그린다.
리액트는 {id: 10} 에 대한 이펙트를 클린업한다.
리액트가 {id: 20} 에 대한 이펙트를 실행한다.
어떻게 prop이 {id: 20} 으로 바뀌고 나서도 이전 이펙트의 클린업이 여전히 예전 값인 {id: 10} 을 보는 걸까?
컴포넌트가 랜더링 안에 있는 모든 함수는 랜더가 호출될 때 정의된 props와 state 값을 잡아둔다.
useEffect의 클린업은 최신 prop이 아닌 클린업이 정의된 시점의 랜더링에 있던 값을 읽기 때문이다.
리액트는 우리가 지정한 props와 state에 따라 DOM과 동기화한다. 랜더링 시 “마운트” 와 “업데이트” 의 구분이 없다.
useEffect도 같은 방식인데 useEffect는 리액트 트리 바깥에 있는 것들을 props와 state에 따라 동기화 할 수 있게 해준다.
필요한 최소한의 정보를 useEffect 안에서 컴포넌트로 전달하는게 최적화에 도움이 될 것이다.
매번 리랜더링마다 DOM 전체를 새로 그리는 것이 아니라, 리액트가 실제로 바뀐 부분만 DOM을 업데이트 한다.
이처럼 useEffect를 꼭 적용할 필요가 없는 경우라면 다시 실행하지 않는 것이 좋다.
function Greeting({ name }) {
const [counter, setCounter] = useState(0);
useEffect(() => {
document.title = 'Hello, ' + name;
});
return (
<h1 className="Greeting">
Hello, {name}
<button onClick={() => setCounter(count + 1)}>
Increment
</button>
</h1>
);
}
위 예제에서 컴포넌트는 counter가 변경될 때마다 리랜더링 된지만 useEffect는 counter 상태값을 사용하지 않는다. name prop이 변경 되지 않는데 document.title 을 매번 counter 값이 바뀔때마다 재할당하는 것은 비효율적이다.
그래서 특정한 이펙트가 불필요하게 다시 실행되는 것을 방지하고 싶다면 의존성 배열을(deps) useEffect의 인자로 전달하면 된다.
(effect 내부에 사용되는 값을 의존성 배열에 포함 시켜야 한다.)
useEffect(() => {
document.title = 'Hello, ' + name;
}, [name]);
위 코드처럼 useEffect 내에 의존성 배열로 [name]을 적어주면 이제 useEffect는 컴포넌트가 첫 랜더링이 될 때를 제외하면 name의 내용이 변경될 때만 실행된다.
의존성에 대해 리액트에게 거짓말을 할 경우 좋지 않은 결과를 가져오게 된다.
예를 들어 매 초마다 숫자가 올라가는 카운터를 작성한다고 할 때 아래와 같이 작성 하게 되면 숫자가 오로지 한 번만 증가한다.
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, []);
return <h1>{count}</h1>;
}
첫 번째 랜더링에서 count 는 0 이다.
따라서 첫 번째 랜더링의 이펙트에서 setCount(count + 1) 는 setCount(0 + 1) 이라는 뜻이다. deps를 [] 라고 정의했기 때문에 이펙트는 다시 실행되지 않고, useEffect는 처음 실행 당시의 count값만 기억하기 때문에 계속 setCount(0 + 1)만 호출하게 되는 것이다.
우리는 리액트에게 useEffect가 컴포넌트 안에 있는 값을 쓰지 않는다고 거짓말을 했다.
const count = // ...
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, []);
위 예제에서 우리는 setCount를 위해 useEffect를 사용하고 있다. 이 경우 굳이 스코프 안에서 count를 쓸 필요가 없다!
때문에 이전 상태를 기준으로 상태 값을 업데이트 하고 싶을 때는, setState 에 함수 형태의 업데이터를 사용하면 된다.
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + 1);
}, 1000);
return () => clearInterval(id);
}, []);
우리가 리액트에게 알려줘야 하는 것은 지금 값이 뭐든 간에 상태 값을 하나 더하라는 것이다. 이제 이펙트는 더 이상 랜더링 스코프에서 count 값을 읽어 들이지 않고 setCount를 정상적으로 실행한다.
흔한 실수 중 하나가 함수는 의존성에 포함되면 안된다는 것입니다. 예를 들어 이 코드는 동작하는 것 처럼 보입니다.
function SearchResults() {
const [data, setData] = useState({ hits: [] });
async function fetchData() {
const result = await axios(
'https://hn.algolia.com/api/v1/search?query=react',
);
setData(result.data);
}
useEffect(() => {
fetchData();
}, []);
이 코드는 동작하지만 간단히 로컬 함수를 의존성에서 제외하는 해결책은 컴포넌트가 커지면서 모든 경우를 다루고 있는지 보장하기 힘들다는 문제가 있다.
예를들면,
function SearchResults() {
// 길다고 상상하자
function getFetchUrl() {
return 'https://hn.algolia.com/api/v1/search?query=react';
}
// 이 함수도 길다고 상상하자
async function fetchData() {
const result = await axios(getFetchUrl());
setData(result.data);
}
useEffect(() => {
fetchData();
}, []);
// ...
}
에서 이 함수들 중에 하나가 state나 prop을 사용한다고 생각해 보자.
function SearchResults() {
const [query, setQuery] = useState('react');
function getFetchUrl() {
return 'https://hn.algolia.com/api/v1/search?query=' + query;
}
async function fetchData() {
const result = await axios(getFetchUrl());
setData(result.data);
}
useEffect(() => {
fetchData();
}, []);
// ...
}
만약 이런 함수를 사용하는 useEffect들 중 하나라도 deps를 업데이트하는 것을 깜빡했다면, 이펙트는 prop과 state의 변화에 동기화하는데 실패할 것이다.
다행히도, 이 문제를 해결할 쉬운 방법이 있다. 어떠한 함수를 useEffect 안에서만 쓴다면, 그 함수를 직접 useEffect 안으로 옮기면 된다.
function SearchResults() {
const [query, setQuery] = useState('react');
useEffect(() => {
function getFetchUrl() {
return 'https://hn.algolia.com/api/v1/search?query=' + query;
}
async function fetchData() {
const result = await axios(getFetchUrl());
setData(result.data);
}
fetchData();
}, [query]);
// ...
}
하지만 한 컴포넌트에서 여러개의 useEffect가 있는데 같은 함수를 호출할 때 로직을 복붙해야 해서, 또는 prop 때문에 함수를 이펙트 안에 옮기고 싶지 않을 수도 있다.
이런 함수를 이펙트의 의존성으로 정의하지 말아야 할까? 아까도 말했듯이 이펙트는 자신의 의존성에 대해 거짓말을 하면 안된다. 사실은, 컴포넌트 안에 정의된 함수는 매 랜더링마다 바뀐다.
function SearchResults() {
function getFetchUrl(query) {
return 'https://hn.algolia.com/api/v1/search?query=' + query;
}
useEffect(() => {
const url = getFetchUrl('react');
// ...
}, []); // 빠진 deps: getFetchUrl
useEffect(() => {
const url = getFetchUrl('redux');
// ...
}, []); // 빠진 deps: getFetchUrl
// ...
}
이 경우 getFetchUrl 을 각각의 이펙트 안으로 옮기게 되면 로직을 공유할 수 없다. 두 이펙트 모두(어짜피 매 랜더링마다 바뀌는) getFetchUrl 에 기대고 있으니, 의존성 배열도 쓸모가 없다.
이에 대한 2가지 해결책을 알아보자.
// 데이터 흐름에 영향을 받지 않는다
function getFetchUrl(query) {
return 'https://hn.algolia.com/api/v1/search?query=' + query;
}
function SearchResults() {
useEffect(() => {
const url = getFetchUrl('react');
// ...
}, []);
useEffect(() => {
const url = getFetchUrl('redux');
// ...
}, []);
// ...
}
저 함수는 랜더링 스코프에 포함되어있지 않으며 데이터 흐름에 영향을 받을 수 없기 때문에 deps에 명시할 필요가 없다.
function SearchResults() {
// ✅ 여기 정의된 deps가 같다면 항등성을 유지한다
const getFetchUrl = useCallback((query) => {
return 'https://hn.algolia.com/api/v1/search?query=' + query;
}, []); // ✅ 콜백의 deps는 OK
useEffect(() => {
const url = getFetchUrl('react');
// ... 데이터를 불러와서 무언가를 한다 ...
}, [getFetchUrl]); // ✅ 이펙트의 deps는 OK
useEffect(() => {
const url = getFetchUrl('redux');
// ... 데이터를 불러와서 무언가를 한다 ...
}, [getFetchUrl]); // ✅ 이펙트의 deps는 OK
// ...
}
위 방법은 함수의 의존성을 피하기보다 함수 자체가 필요할 때만 바뀔 수 있도록 만드는 것이다.
만약 입력을 받는 부분을 추가하여 임의의 query 를 검색할 수 있다고 해보자. 그래서 query 를 인자로 받는 대신, getFetchUrl 이 지역상태로부터 query를 읽어들인다.
전역상태와 지역상태
전역상태는 전역변수와 비슷하게 상태의 위치가 어디던 간에 어디서든 사용할 수 있는 상태로 어떠한 상태를 리액트 코드 전역에서 사용하기 위한 상태 관리의 개념이다. 반대로 지역상태는 어느 한 구역에서만 사용되는 상태다.(기본적인 state의 개념)
function SearchResults() {
const [query, setQuery] = useState('react');
const getFetchUrl = useCallback(() => {
return 'https://hn.algolia.com/api/v1/search?query=' + query;
}, [query]);
// ...
}
이때, useCallback 의 deps에 query 를 포함시키면 getFetchUrl을 사용하는 어떠한 이펙트라도 query 가 바뀔 때마다 다시 실행되고 query 가 같다면, getFetchUrl 또한 같을 것이며, 이펙트는 다시 실행되지 않을 것이다.
위에서 설명했던,
setCount(c => c + 1) 같은 업데이터 형태는 setCount(count + 1) 보다 명백히 적은 정보를 전달한다. 예를 들어 서로에게 의존하는 두 상태 값이 있거나 prop 기반으로 다음 상태를 계산할 필요가 있을 때는 도움이 되지 않는다.
이럴 때 사용할 수 있는 강력한 자매 패턴이 있는데 바로 useReducer 다.
useReducer에 대해선 다음 글에서 알아보자