지금부터 리액트 훅에 대해 알아보자.
Hook은 클래스 컴포넌트에서만 가능했던 state, ref 등 리액트의 핵심 기능을 함수에서도 가능하게 만들어 준 것이다. 리액트로 웹 서비스르를 만드는 개발자라면 훅이 어떻게 동작하는지 이해할 필요가 있다.
useState
useState는 함수 컴폰너트 내부에서 상태를 정의하고, 이 상태를 관리할 수 있게 해주는 훅이다.
import {useState} from 'react'
const [state,setState] = useState(initialState)
useState의 인수로는 사용할 state의 초깃값을 넘겨주면 된다.
만약 useState를 사용하지 않는다면 어떻게 될까?
import React from 'react'
const Component = () => {
let state = 'hello'
function handleButtonClick() {
state = 'hi'
}
return (
<>
<h1>{state}</h1>
<button onClick={handleButtonClick}>hi</button>
</>
)
}
/* STYLE */
export default Component
리액트 렌더링 과정을 생각해보면, 함수형 컴포넌트의 return을 실행한 결과를 이전의 리액트 트리와 비교해 리렌더링이 필요한 부분만 업데이트한다. 하지만 위의 코드를 작성하면 리렌더링 조건이 충족되지 않는다.
import React from 'react'
const Component = () => {
const [,triggerRender] = useState()
let state = 'hello'
function handleButtonClick() {
state = 'hi'
triggerRender()
}
return (
<>
<h1>{state}</h1>
<button onClick={handleButtonClick}>hi</button>
</>
)
}
/* STYLE */
export default Component
위의 코드도 리렌더링 되지 않는데, 리액트에서 리렌더링이 일어나면 함수를 다시 새롭게 실행하게 된다. 따라서 새롭게 실행되는 함수에서 state는 항상 hello로 다시 초기화되기 때문에 값의 변화가 반영되지 않는다.
실제 useState는 아니지만 useState를 구현한 원리가 담겨있는 예시를 살펴보자.
const MyReact = function() {
const global = {}
let index = 0
function useState(initialState){
if(!global.states) {
global.states = []
}
const currentState = global.states[index] || initialState
global.states[index] = currentState
const setState = (function() {
//클로저로 index를 가둬두어서 동일한 index에 접근이 가능
let currentIndex = index
return function(value){
global.states[currentIndex] = value
//컴포넌트 렌더링이 들어가는 부분
}
})()
index = index + 1
return [currentState,setState]
}
실제 useState의 구현은 useReducer를 이용해 구현되지만, 이는 나중에 자세하게 다뤄보자.
실제 리액트 훅의 구성
훅에 대한 구현체를 타고 올라가다 보면 __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED 라는 당황스러운 문구를 볼 수 있다....
이러한 이유는 일반 사용자의 접근을 차단하고, 나아가 실제 프로덕션 코드에서 사용하지 못하게 하기 위함이라 생각된다.
해당 책에서 등장하는 훅의 예제는 Preact라는 리액트의 경량화 버전으로 모든 코드를 볼 수 있는 라이브러리를 기준으로 삼고있다고 한다.
useState는 JS의 클로저에 의존해 구현돼 있을 것 이라는 사실을 짐작해 볼 수 있다. 클로저를 사용하며 외부에 해당 값을 노출시키지 않고 오직 리액트에서만 사용할 수 있으며, 함수 컴포넌트가 매번 실행되도 useState에서 이전의 값을 꺼내 쓸 수 있다.
게으른 초기화
useState의 인수로 특정한 값을 넘기는 함수를 인수로 넣어줄 수도 있다. 이러한 기술을 게으른 초기화라고 한다.
//useState
const [count,setCount] = useState(Number.parseInt(window.localStorage.getItem(cacheKey)))
//게으른 초기화
const [count,setCount] = useState(()=> Number.parseInt(window.localStorage.getItem(cacheKey)),)
리액트 공식 문서에서 이러한 게으른 초기화는 useState의 초기값이 복잡하거나 무거운 연산을 포함할 때 사용하라고 되어 있다.
게으른 초기화 함수는 오로지 state가 처음 만들어질 때만 사용되며 리렌더링이 발생된다면 이 함수의 실행은 무시된다.
함수형 컴포넌트의 함수는 렌더링이 실행될때마다 다시 실행된다. 즉 useState 값도 재실행되게 된다. 인수를 넣는 과정에서 함수로 복잡한 과정을 실행시키면 비용이 많이 발생하지만, 위와 같이 useState 내부에 함수가 있다면 최초 렌더링 이후 실행되지 않게된다.
무거운 연산의 예시는 localStorage, sessionStorage, map, filter, find 등의 실행 비용이 많이 드는 경우 사용하는 것이 좋다.
useEffect
나를 비롯한 대부분의 리액트 개발들은 useEffect의 정의에 대해 아래와 같이 정의할 것이다.
- useEffect는 두 개의 인수를 받으며, 첫 번째는 콜백, 두 번째는 의존성 배열이며 의존성 배열의 값이 변경될 때 콜백 함수를 실행한다.
- 의존성 배열에 빈 배열을 넣으면 컴포넌트가 마운트될 때만 실행된다.
- 클린업 함수를 반환할 수 있으며 컴포넌트가 언마운트될 때 실행된다.
위의 정의는 어느 정도 옳지만 완전히 정확하지는 않다. useEffect는 애플리케이션 내 컴포넌트의 여러 값들을 활용해 동기적으로 부수 효과를 만드는 매커니즘이다. 이 부수 효과가 "언제" 일어나는지 보다 "어떤 상태 값과 함께 실행되는지" 살펴보는 것이 중요하다.
function Component(){
useEffect(() => {
//do
},[props,state])
}
useEffect가 의존성 배열이 변경되는 것을 파악하고 실행할 수 있는 원리는 무엇인가? 함수형 컴포넌트가 매번 함수를 실행하여 렌더링을 수행한다는 것은 알고 있다.
import React, { useState } from 'react'
const Component = () => {
const [counter, setCounter] = useState(0)
function handleClick() {
setCounter((prev) => prev + 1)
}
useEffect(() => {
console.log(counter)
})
return (
<>
<h1>{counter}</h1>
<button onClick={handleClick}>+</button>
</>
)
}
export default Component
간단한 카운터 예제에서 + 버튼을 누르는 순간 함수는 아래처럼 작동한다.
import React, { useState } from 'react'
const Component = () => {
counter = 1
//...
useEffect(() => {
console.log(counter) //2,3,4,5,,,,
})
return (
<>
<h1>{counter}</h1>
<button onClick={handleClick}>+</button>
</>
)
}
export default Component
useEffect는 JS의 proxy나 데이터 바인딩, 옵저버 같은 특별한 기능을 통해 값의 변화를 관찰하는 것이 아니라 렌더링할 때마다 의존성 안의 값을 확인하며 이전과 다른것이 있으면 부수 효과를 발생시키는 평범한 함수이다.
클린업 함수
useEffect 내에서 반환되는 함수인 클린업 함수는 이벤트를 등록, 삭제할 때 많이 사용된다.
import React, { useEffect, useState } from 'react'
const Component = () => {
const [counter, setCounter] = useState(0)
function handleClick() {
setCounter((prev) => prev + 1)
}
useEffect(() => {
function addMouseEvent() {
console.log('counter =', counter)
}
window.addEventListener('click', addMouseEvent)
return () => {
console.log('cleanup함수 실행, counter =', counter)
window.removeEventListener('click', addMouseEvent)
}
})
return (
<>
<h1>{counter}</h1>
<button onClick={handleClick}>+</button>
</>
)
}
export default Component
위 코드를 실행하면 아래와 같이 나온다.
클린업 함수는 이전 counter 값(이전 state)를 참조하여 실행한다. 클린업 함수는 새로운 값과 함께 렌더링된 뒤에 실행되기에 새로운 값을 기반으로 렌더링 뒤에 실행되지만, 이 변경된 값을 읽는 것이 아니라 함수가 정의됐을 당시의 선언되었던 이전 값을 보고 실행된다는 것이다.
따라서 useEffect는 콜백함수가 실행될 때마다 이전의 클린업 함수가 존재한다면 그 클린업 함수를 실행한 뒤에 콜백을 실행하기에 특정 이벤트의 핸들러가 무한히 추가되는 것을 방지할 수 있다.
클린업 함수는 언마운트 개념과는 차이가 있다.
언마운트는 특정 컴포넌트가 DOM에서 사라진것을 의미하지만, 클린업 함수는 함수 컴포넌트가 리렌더링됐을 때 의존성 변화가 있었을 당시 이전의 값을 기준으로 실행되는 개념이다.
의존성 배열
의존성 배열은 빈 배열, 아무것도 없는 값, 사용자가 원하는 값 등등 여러 가지 값을 넣을 수 있다.
빈 배열
useEffect가 비교할 의존성이 없다고 판단하여 최초의 렌더링 이후에는 실행되지 않는다.
아무런 값이 없는 경우
의존성을 비교하지 않고 렌더링될 때마다 실행된다. 보통은 렌더링 유무를 확인하기 위해 사용한다.
useEffect(() => {
console.log('render!!')
})
이때 과연 useEffect를 사용하지 않았을때와 다른 점이 있을까?
function Component(){
console.log('render!!')
}
function Component(){
useEffect(() => {
console.log('render!!')
})
}
차이점은 다음과 같다.
- SSR 관점에서 useEffect()는 클라이언트 사이드에서 실행되는 것을 보장한다.
- useEffect 내부에서는 window 객체의 접근에 의존하는 코드를 사용할 수 있다.
- 컴포넌트 렌더링의 부수 효과 이후에 실행된다. 직접 실행은 컴포넌트가 렌더링 되는 중에 실행되며 렌더링을 방해하기에 성능에 악영향을 줄 수 있다.
useEffect의 구현
const MyReact = (function () {
const global = {};
let index = 0;
function useEffect(callback, dependencies) {
const hooks = global.hooks;
let previouseDependencies = hooks[index];
let isDependenciesChanged = previouseDependencies
? dependencies.some(
(val, idx) => !Object.is(val, previouseDependencies[idx])
)
: true;
if (isDependenciesChanged) callback();
hooks[index] = dependencies;
index++;
}
return { useEffect };
})();
useEffect 사용의 주의점
useEffect는 리액트 코드를 작성할 때가장 많이 사용하는 훅이며 가장 주의해야할 훅이다. 예기치 못한 버그의 발생, 무한 루프에 빠질 수 있다.
eslint-disable-line react-hooks/exhaustive-deps 주석 사용 자제
useEffect 인수 내부에서 사용하는 값 중 의존성 배열에 있지 않는 경우 경고를 발생시켜주는데 의도치 못한 버그 발생의 원인이 될 수 있다.
반드시 useEffect의 의존성 배열 내부의 값과 부수효과로 일어날 콜백함수의 행동 간의 연결고리를 잘 이어주자.
useEffect의 첫 번째 인수에 함수명을 부여하라
useEffect를 사용하는 많은 코드에서 첫 번째 인수로 익명 함수를 부여한다. 이는 공식 문서도 마찬가지다. 하지만 useEffect의 코드가 복잡하고 많아질수록 useEffect의 목적을 파악하기 힘들기에 목적을 파악하기 쉽게 기명함수를 전달하는 것이 좋다.
useEffect(function logActivieUser() {
logging(user.id)
},[user.id],)
거대한 useEffect를 만들지 말것
useEffect는 의존성이 변경될 때마다 부수 효과를 실행하는데, 이 부수 효과의 크기가 커질수록 애플리케이션 성능에 악영향을 미친다. 따라서 useEffect를 최대한 간결하게 만드는 것이 좋으며, 만약 큰 useEffect를 만들더라도 작은 useEffect로 분리하는 것이 좋다.
의존성 배열에 여러 변수가 들어가는 상황이면 최대한 useCallback과 useMemo 등으로 사전에 정제한 내용들만 useEffect에 들어가게 하도록 하자.
불필요한 외부 함수를 만들지 말것
useEffect가 실행하는 콜백 또한 불필요하게 존재하면 안된다. 콜백 함수가 거대해질수록 코드의 가독성이 저하되기에 최대한 useEffect 외부 함수를 내부에 가져와 사용하는 편이 좋다.
useEffect 콜백으로 비동기 함수를 못넣는 이유
이 부분은 나도 상당히 많이 봤던 문제이다. 책에서는 useEffect의 인자로 비동기 함수 사용이 가능하다면 함수의 응답 속도에 따라 결과가 이상하게 나타날 수 있다고 한다. useEffect에서 비동기로 함수를 호출하는 경우 경쟁 상태가 발생할 수 있기 때문이다. 물론 비동기 함수 실행 자체는 문제가 되지 않는다.
이 비동기 함수를 실행하는 경우 클린업 함수를 통해 이전 비동 함수에 대한 처리를 해주는 것이 좋다.
만약 fetch를 사용해서 API 요청을 한다면 abortController 등으로 이전 요청을 취소하도록 하자.
간단하게 정리하자면 비동기 useEffect는 state의 경쟁 상태를 발생시킬 수 있고 cleanup 함수 실행 순서를 보장할 수 없기 때문에 만들지 않는다.
'React > 모던 리액트 Deep Dive' 카테고리의 다른 글
useMemo, useCallback와 useRef (0) | 2025.04.09 |
---|---|
리액트의 렌더링과 메모이제이션 (1) | 2024.11.08 |
클래스 컴포넌트와 함수 컴포넌트 (0) | 2024.11.05 |
가상 DOM과 리액트 파이버 (2) | 2024.10.30 |
[React Deep Dive] JSX(Javascript XML) (0) | 2024.10.26 |