숨쉬듯 쓰는 useState, useEffect, useRef 좀 더 알고 쓰자
난 모든 훅을 파헤치진 않을거고 내가 자주 쓰는 친구들만 파헤칠 예정
const Component () => {
let state = 'hello'
const handleButtonClick = () => {
state = 'hi'
}
return (
<>
<h1>{state}</h1>
<button onClick={handleButtonClick}>hi</button>
</>
)
}
위 코드에서 버튼을 누른다고 화면의 h1태그의 값이 "hi"로 바뀌진 않는다.
이건 뭐 당연하지.
리액트에서 렌더링은 함수형 컴포넌트의 return 값을 받아서 이 값을 이전의 리액트 트리와 비교해 리렌더링이 필요한 부분만 업데이트해서 이뤄진다.
그런데 위 코드는 리렌더링을 일으키기 위한 조건을 전혀 충족시키지 못한다.
const Component () => {
const [, triggerRender] = useState()
let state = 'hello'
const handleButtonClick = () => {
state = 'hi'
triggerRender();
}
return (
<>
<h1>{state}</h1>
<button onClick={handleButtonClick}>hi</button>
</>
)
}
그럼 위처럼 setState 함수를 사용해 리렌더링을 일으키면 h1태그의 값이 "hi"로 바뀔까?
그것도 당연히 아니지.
리액트의 렌더링은 함수형 컴포넌트의 return 값을 비교하여 실행하는데
setState를 실행 후 Component 함수가 다시 실행될 때 state는 'hello'로 초기화되기 때문이다.
함수형 컴포넌트는 매번 함수를 실행해 렌더링이 일어나고, 함수 내부의 값은 함수가 실행될 때마다 다시 초기화된다.
그렇다면 useState는 어떻게 함수가 실행되도 그 값을 유지하고 있을까?
const useState = (initialValue) => {
let internalState = initialValue;
const setState = (newValue) => {
internalState = newValue
}
return [internalState, setState]
}
이렇게 짜면 useState를 구현한걸까?
const [value, setValue] = useState(0)
setValue(1)
console.log(value) // 0
허나 이는 내가 원하는 대로 작동하지 않는다.
이러한 결과가 발생하는 이유는 setValue로 값을 변경했음에도 이미 구조 분해 할당으로 state의 값, 즉 value를 이미 할당해 놓은 상태이기 때문에 훅 내부의 setState를 호출하더라도 변경된 새로운 값을 반환하지 못하는 것이다.
value가 useState 함수가 반환한 배열의 첫 번째 요소를 가리키고 있기 때문이다. setValue 함수를 호출해도 value가 가리키는 값은 변경되지 않는다.
문제는 useState 함수가 반환한 배열이 상태 변경 시 새로운 배열로 대체되지 않는다는 점이다.
구조분해할당을 통해 배열을 반환한다는게 결국 메모리 어느공간데 저장된 배열을 반환하는건데
setState를 통해 값을 변경해도 기존에 반환된 배열은 그대로 유지되니까
그리고 그 배열의 0번째 값인 원래의 value를 참조하는건 그대로니까
반영이 제대로 안된다는 말이다.
아니 그럼 실제 useState는 뭐 어떻게 구현되어있을까?
아래는 실제 리액트의 useState코드가 아니라 작동방식을 대략적으로 흉내 낸 코드이다.
const MyReact = () => {
const global = {};
let index = 0;
const useState = (initialState) => {
if (!global.state) {
// 애플리케이션 전체의 states 배열을 초기화한다.
// 최초 접근이라면 빈 배열로 초기화한다.
global.states = [];
}
// states 정보를 조회해서 현재 상태값이 있는지 확인하고,
// 없다면 초깃값으로 설정한다.
const currentState = global.states[index] || initialState;
// states의 값을 위에서조회한 현재 값으로 업데이트한다.
global.states[index] = currentState;
// 즉시 실행 함수로 setter를 만든다.
const setState = (() => {
// 현재 index를 클로저로 가둬놔서 이후에도 계속해서 동일한 index에 접근할 수 있도록 한다.
let currentIndex = index;
return (value) => {
global.states[currentIndex] = value;
// 컴포넌트를 렌더링한다. 실제로 컴포넌트를 렌더링하는 코드는 생략
};
})();
// useState를 쓸 때마다 index를 하나씩 추가한다. 이 index는 setState에서 사용된다.
// 즉, 하나의 state마다 index가 할당돼 있어 그 index가 배열의 값(global.states)을 가리키고
// 필요할 때마다 그 값을 가져오게 한다.
index = index + 1;
return [currentState, setState];
};
// 실제 useState를 사용하는 컴포넌트
const Component = () => {
const [value, setValue] = useState(0);
//...
};
};
작동 자체만 구현했을 뿐, 실제 구현체와는 차이가 있다.
실제 리액트 코드에서는 useReducer를 사용하여 구현돼 있다고 한다.
currentState의 값은 어떻게 리렌더링 될때 바뀐 값을 나타낼 수 있는걸까?
global.states[index] 이 부분을 참조하기 때문이다.
index는 useState 함수가 실행될 때 클로저로 저장되기 때문이고.
즉, currentState 변수는 global.states[index]를 통해 현재 상태 값을 참조하기 때문에 리렌더링 시에도 업데이트된 값을 나타낼 수 있다.
useState 함수가 호출될 때마다 index 변수가 증가하고, 해당 인덱스를 사용하여 global.states 배열에 상태 값을 저장하고 조회한다.
이때 index 변수는 useState 함수의 클로저로 캡처되어 각 상태마다 고유한 인덱스를 유지할 수 있다.
따라서 currentState 변수는 항상 global.states[index]를 참조하므로, 해당 인덱스에 저장된 최신 상태 값을 가져올 수 있게된다.
이렇게 index를 클로저로 캡처하고 global.states 배열을 사용하여 상태 값을 관리함으로써, 각 상태마다 고유한 인덱스를 유지하고 리렌더링 시에도 업데이트된 상태 값을 참조할 수 있게 된다.
일반적으로 useState에서 기본값을 선언하기 위해 useState() 인수로 원시값을 넣는 경우가 대부분이다.
그러나 이 useState의 인수로 특정한 값을 넘기는 함수를 인수로 넣어줄 수도 있다.
useState에 변수 대신 함수를 넘기는 것을 게으른 초기화(lazy initialization)라고 한다.
이게 뭘까?
// 일반적인 useState 사용
// 바로 값을 집어넣는다.
const [count, setCount] = useState(
Number.parseInt(window.localStorage.getItem(cacheKey)),
);
// 게으른 초기화
// 위 코드와의 차이점은 함수를 실행해 값을 반환한다는 것이다.
const [count, setCount] = useState(() =>
Number.parseInt(window.localStorage.getItem(cacheKey)),
);
리액트 공식 문서에서 이러한 게으른 초기화는 useState의 초기값이 복잡하거나 무거운 연산을 포함하고 있을 때 사용하라고 돼 있다.
이 게으른 초기화 함수는 오로지 state가 처음 만들어질 때만 사용된다.
만약 이후에 리렌더링이 발생한다면 이 함수의 실행은 무시된다.
만약 한 번 실행되는 데 어느 정도 비용이 드는 값이 있다고 가정해 보자.
useState의 인수로 이 값 자체를 사용한다면 초깃값이 필요한 최초 렌더링과, 초깃값이 필요 없는 리렌더링 시에도 동일하게 계속 해당 값에 접근해서 낭비가 발생한다.
따라서 이러한 경우에는 함수 형태로 인수에 넘겨주는것이 훨씬 경제적일 것이다.
초깃값이 없다면 함수를 실행해 무거운 연산을 시도할 것이고, 이미 초깃값이 존재한다면 함수 실행을 하지 않고 기존 값을 사용할 것이다.
그럼 게으른 초기화는 언제 쓰는게 좋을까?
리액트에서 무거운 연산이 요구될 때 라고 하는게 언제일까?
useEffect의 일반적인 형태는 아래와 같다.
const Component = () => {
//...
useEffect(()=>{
//do something
},[props, state])
}
첫번째 인수로 실행할 부수 효과가 포함된 함수를, 두번째 인수로는 의존성 배열을 전달한다.
의존성 배열이 변경될 때마다 첫 번째 인수인 콜백을 실행된다.
이건 다 아는 사실이다.
그러면 useEffect는 어떻게 의존성 배열이 변경된것을 알고 실행할까?
여기서 한 가지 기억해야할 사실은 바로 함수형 컴포넌트는 매번 함수를 실행해 렌더링을 수행한다는 것이다.
함수형 컴포넌트는 렌더링 시마다 고유의 state와 props값을 갖고 있다.
useEffect는 자바스크립트의 proxy나 데이터 바인딩, 옵저버같은 특별한 기능을 통해 의존성 배열내의 값의 변화를 관찰라는 것이 아니고 렌더링할 때마다 의존성에 있는 값을 보면서 이 의존성의 값이 이전과 다른게 하나라도 있으면 부수효과를 실행하는 평범한 함수라고 볼 수 있다.
이전 값들은 별도로 메모리에 저장된 후 컴포넌트 함수를 다시 실행하고 다시 실행될 때의 의존성배열내의 값들은 별도의 메모리에 저장한 이전값들과 비교된다.
비교가 끝난 후, 리액트는 의존성 배열의 이전 값들을 새로운 값으로 업데이트된다. 이 과정에서 이전 값들은 더 이상 참조되지 않게 됩니다.
이때, 새로운 의존성 배열이 리액트의 내부 구조에 저장되고, 다음 렌더링 시 이 값들이 이전 값으로 사용됩니다.
이전 값들이 더 이상 참조되지 않으면 자바스크립트의 가비지 콜렉터(Garbage Collector)에 의해 메모리에서 삭제된다.
클린업 함수라 불리는 useEffect 내에서 반환되는 함수는 정확히 무엇이고 어떤 일을 할까?
const App = () => {
const [counter, setCounter] = useSTate(0)
const handleClick = () => {
setCounter(prev => prev + 1)
}
useEffect(()=>{
const addMouseEvent = () => {
console.log(counter)
}
window.addEventListener('click', addMouseEvent)
// 클린업 함수
return () => {
console.log('클린업 함수 실행!', counter)
window.removeEventListener('click', addMouseEvent)
}
},[counter])
return (
<>
<h1>{counter}</h1>
<button onClick={handleClick}>+</button>
</>
)
}
위 useEffect가 포함된 컴포넌트를 실행해 보면 다음과 같은 결과를 얻을 수 있다.
클린업 함수 실행! 0
1
클린업 함수 실행! 1
2
클린업 함수 실행! 2
3
클린업 함수 실행! 3
4
...
위 로그를 살펴보면 클린업 함수는 이전 counter 값, 즉 이전 state를 참조해 실행된다는 것을 알 수 있다.
클린업 함수는 새로운 값과 함께 렌더링된 뒤에 실행되기 때문이다.
중요한 것은, 클린업 함수는 비록 새로운 값을 기반으로 렌더링 뒤에 실행되지만 이 변경된 값을 읽는 것이 아니라 함수가 정의됐을 당시 선언됐던 이전 값을 보고 실행된다는 것이다.
이제 왜 useEffect에 이벤트를 추가했을 때 클린업 함수에서 지워줘야하는 지 알 수 있다.
하수형 컴포넌트의 useEffect는 그 콜백이 실행될 때마다 이전의 클린업 함수가 존재한다면 그 클린업 함수를 실행한 뒤에 콜백을 실행한다.
따라서 이벤트를 추가하기 전에 이전에 등록했던 이벤트 핸들러를 삭제하는 코드를 클린업 함수에 추가하는것이다.
이렇게 함으로써 특정 이벤트의 핸들러가 무한히 추가되는것을 방지할 수 있다.
클린업 함수는 언마운트라기보다는 함수형 컴포넌트가 리렌더링됐을 때 의존성 변화가 있었을 당시 이전의 값을 기준으로 실행되는, 말 그대로 이전 상태를 청소해주는 개념으로 보는것이 옳다.
(아 물론 컴포넌트가 언마운트 될 때도 클린업 함수는 실행된다.)
의존성 배열에 빈 배열을 둔다면 리액트가 이 useEffect는 비교할 의존성이 없다고 판단해 최초 렌더링 직후에 실행된 다음부터는 더 이상 실행되지 않는다.
아무런 값도 넘겨주지 않는다면 이때는 의존성을 비교할 필요 없이 렌더링할 때마다 실행이 필요하다고 판단해 렌더링이 발생할 떄마다 실행된다.
이는 보통 컴포넌트가 렌더링되었는지 확인하기 위한 방법으로 사용된다.
그런데 여기서 한가지 의문점이 든다.
의존성 배열이 없는 useEffect가 매 렌더링마다 실행된다면 그냥 useEffect 없이 써도 되는거 아닌가?
// 1
const Component = () => {
console.log('렌더링됨');
}
// 2
const Component = () => {
useEffect(()=>{
console.log('렌더링됨');
})
}
두 코드는 명백한 차이점을 지니고 있다.
1. SSR관점에서 useEffect는 클라이언트 사이드에서 실행되는 것을 보장해 준다. useEffect 내부에서는 window 객체의 접근에 의존하는 코드를 사용해도 된다.
2. useEffect는 컴포넌트 렌더링의 부수 효과. 즉, 컴포넌트의 렌더링이 완료된 이후에 실행된다. 반면 직접 실행은 컴폰너트가 렌더링 되는 도중에 실행된다. 따라서 1번과는 달리 SSR의 경우에 서버에서도 실행된다. 그리고 이 작업은 함수형 컴포넌트의 반환을 지연시키는 행위다. 즉, 무거운 작업일 경우 렌더링을 방해하므로 성능에 악영향을 미칠 수 있다.