본 포스팅 시리즈는 📚'모던 리액트 Deep Dive'를 주간 별로 1장씩 공부하며
* 새롭게 알게 된 것들
* 평소 알고 있다고 생각했지만 이번에 제대로 알게 된 것들
* 궁금한 부분에 대해 딥다이브한 것들
등을 기재하기 위해 시작되었다.
useState를 사용하지 않고 자체 변수를 사용해 상태값을 관리하기 어려운 이유는? (p. 190-194)function Component() {
let state = 'hello'
function handleButtonClick() {
state = 'hi'
}
return (
<>
<h1>{state}</h1>
<button onClick={handleButtonClick}>hi</button>
</>
)
}
useState 를 사용하지 않고 함수 내부에서 자체적으로 변수를 사용해 상태값을 관리하는 위 예제 코드를 살펴보자.
위 코드가 개발자가 의도한 대로 동작하지 않는 이유는 무엇일까?
리액트에서 렌더링은 함수 컴포넌트의 return과 클래스 컴포넌트의 render 함수를 실행한 결과를 이전의 리액트 트리와 비교해 리렌더링이 필요한 부분만 업데이트해 이루어진다.
이때 일반 변수는 리액트의 상태 관리 메커니즘에 포함되지 않기 때문에 값이 바뀌어도 리렌더링되지 않으며, 리렌더링되더라도 함수형 컴포넌트는 렌더링될 때마다 함수가 처음부터 다시 실행되므로 state 변수의 값은 계속 'hello'로 초기화된다.
useState 훅은 함수가 실행되어도 어떻게 그 값을 유지하고 있을까?이를 위해 리액트는 클로저(closer)를 이용했다.
♾️ 이 맥락에서 클로저는,
어떤 함수(useState) 내부에 선언된 함수(setState)가 함수의 실행이 종료된 이후에도(useState가 호출된 이후에도) 지역변수인 state를 계속 참조할 수 있다는 것을 의미한다.
매번 실행되는 함수 컴포넌트 환경에서 state의 값을 유지하고 사용하기 위해서 리액트는 클로저를 활용하고 있다. 실제 리액트에서 useState 는 useReducer 를 이용해 구현돼 있다.
와 나는 '게으른 초기화'라는 문장을 처음 들어봤으며, useState 에 초깃값을 함수로 넣을 생각은 전혀 못 해봤다 ... (띵 ~ )
⚡️ 게으른 초기화(lazy Initialization)
:useState에 변수 대신 함수를 넘기는 것.
리액트에서 렌더링이 실행될 때마다 함수 컴포넌트의 함수가 재실행되며, useState의 값도 재실행된다. 하지만 리액트는 useState 내부에 존재하는 클로저를 통해 값을 가져오며, 초깃값은 최초에만 사용된다.
// 일반적인 useState 사용
// 바로 값을 집어넣는다.
const [count, setCount] = useState(
Number.parseInt(window, localStorage.getItem(cacheKey))
)
// 게으른 초기화
// 위 코드와의 차이점은 함수를 실행해 값을 반환한다는 것이다.
const [count, setCount] = useState(() =>
Number.parseInt(window.localStorage.getItem(cacheKey))
)
만약 useState 인수로 자바스크립트에 많은 비용을 요구하는 작업이 들어가 있다면, 이는 계속해서 실행될 위험이 존재할 것이다. 하지만 useState 의 초깃값으로 함수를 넣으면, 최초 렌더링 이후에는 실행되지 않고 최초의 state 값을 넣을 때만 실행된다.
리액트에서는 무거운 연산이 요구될 때 사용하라고 한다.
useEffect 클린업 함수의 동작 흐름 (p. 199-201)보통 useEffect 내에서 반환되는 함수를 클린업 함수라고 부른다.
import { useState, useEffect } from ‘react'
export default function App() {
const [counter, setCounter] = useState(0)
function handleClick() {
setCounter((prev) => prev + 1)
}
useEffect(() => {
function 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>
</>
)
}
위 컴포넌트를 실행해보면 다음과 같은 결과를 얻을 수 있다.
클린업 함수 실행! 0
1
클린업 함수 실행! 1
2
클린업 함수 실행! 2
3
...
로그를 살펴보면 클린업 함수는 이전 state 값을 참조해 실행된다❗️는 것을 알 수 있다. 새로운 값을 기반으로 렌더링 뒤에 실행되지만, 이 변경된 값을 읽는 것이 아니라 함수가 정의됐을 당시에 선언됐던 이전 값을 보고 실행된다는 것이다.
useEffect의 흐름은 무엇일까?useEffect와 useEffect를 사용하지 않는 것의 차이 (p. 202)
useEffect의 의존성 배열로 빈 배열을 사용할 경우
➡️ 리액트가 이useEffect는 비교할 의존성이 없다고 판단해, 컴포넌트가 마운트(mount)될 때만 실행되고 그 이후에는 실행되지 않는다.
useEffect의 의존성 배열에 아무 것도 넘기지 않을 경우
리액트가 이useEffect는 의존성을 비교할 필요가 없다고 판단해, 렌더링이 발생할 때마다 실행된다.
// 1
function Component() {
console.log("컴포넌트 렌더링됨")
}
// 2
function Component() {
useEffect(() => {
console.log("컴포넌트 렌더링됨")
})
}
useEffect와 함수 본문에서의 코드 실행은 실행 횟수는 같아 보이지만, 실행 시점 / 환경 / 성능 영향에서 큰 차이가 있다.
즉 DOM 조작, 이벤트 등록, API 호출 등 렌더링 외적인 작업은 useEffect 로 분리해 실행하는 것이 리액트의 핵심 철학이다.
useMemo와 React.memo의 차이 (p. 210)
useMemo로 컴포넌트도 감쌀 수 있다. 물론React.memo를 쓰는 것이 더 현명하다.
React.memo(SomeComponent) 는 “이 컴포넌트를 메모이제이션하겠다”는 뜻이 명확하다.
반면 useMemo(() => <SomeComponent />) 는 “렌더링 결과를 메모이제이션”하는 꼼수 같아, 코드를 읽는 개발자가 헷갈릴 수 있다.
React.memo 는 props를 얕게 비교해, 바뀌지 않으면 리렌더링을 막는다.
하지만 useMemo 로 <SomeComponent /> 를 감싸면 props 비교를 의존성 배열 관리로 직접 해줘야 하기 때문에 실수 위험이 증가한다.
React.memo 는 내부적으로 컴포넌트 렌더링 최적화 로직을 갖고 있어서 이 목적에 특화되어 있다.
하지만 useMemo 는 단순히 계산 결과를 캐싱하는 훅이라, 컴포넌트 렌더링 최적화엔 맞지 않는다.
useCallback에 기명 함수를 넘겨주는 이유 (p. 214)const toggle1 = useCallback(() => {
setStatus1(!status1)
}, [status1])
위 예제 코드와 같이 useCallback 에 기명 함수를 사용하는 것이 권장되는 이유는 크롬 메모리 탭에서 디버깅을 용이하게 하기 위함이다. 익명 함수로 넘겨줄 경우, 말 그대로 이름이 없어 해당 함수를 추적하기 어렵기 때문이다.
useRef의 유용한 활용 예제와 코드 흐름 (p. 218)function usePrevious(value) {
const ref = useRef()
useEffect(() => {
ref.current = value
}, [value])
return ref.current
}
function SomeComponent() {
const [counter, setCounter] = useState(0)
const previousCounter = usePrevious(counter)
function handleClick() {
setCounter((prev) => prev + 1)
}
// 0 (undefined)
// 1 0
// 2 1
// ...
return (
<button onClick={handleClick}>
{counter} {previousCounter}
</button>
)
}
위 예제는 useRef 를 활용해 useState의 이전 값을 저장하는 usePrevious 훅을 구현한 것이다.
🔎 코드의 흐름은 어떻게 되는가
usePrevious(0) 이 실행된다. useEffect 가 실행되고 ref.current = 0이 된다. useReducer 파악하기 ⭐️ (p.225-227)useReducer 훅은 useState 와 비슷한 형태를 띠지만, 좀 더 복잡한 상태값을 미리 정의해 놓은 시나리오에 따라 관리할 수 있다.
state: 현재 useReducer가 가지고 있는 값.dispatcher: state를 업데이트하는 함수. dispatcher는 setState와 다르게 state를 변경할 수 있는 액션인 action을 넘겨준다. reducer: useReducer의 기본 action을 정의하는 함수.initialState: useReducer의 초깃값init: 초깃값을 지연해서 생성시키고 싶을 때 사용하는 함수. 필수값은 아니다.// useReducer가 사용할 state
type State = {
counter: number
}
// state의 변화를 발생시킬 action의 타입과 넘겨줄 값(payload)을 정의
type Action = {type: 'up' | 'down' | 'reset'; payload?: State }
// 무거운 연산이 포함된 게으른 초기화 함수
function init(count: State): State {
// State를 받아서 초깃값을 어떻게 정의할지 연산하면 된다.
return count
}
// 초깃값
const initialState: State = { count: 0 }
// state와 action을 기반으로 state가 어떻게 변경될지 정의
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'up':
return { count: state.count + 1 }
case 'down':
return { count: state.count - 1 > 0 ? state.count : 0 }
case 'reset':
return init(action.payload || { count: 0 })
default:
throw new Error(`Unexpected action type ${action.type}`)
}
}
export default function App() {
const [state, dispatcher] = useReducer(reducer, initialState, init)
function handleUpButtonClick() {
dispatcher({ type: 'up' })
}
function handleDownButtonClick() {
dispatcher({ type: 'down' })
}
function handleResetButtonClick() {
dispatcher({ type: 'reset', payload: { count: 1 }})
}
return (
<div className="App">
<h1>{state.count}</h1>
<button onClick={handleUpButtonClick}>+</button>
<button onClick={handleDownButtonClick}>-</button>
<button onClick={handleResetButtonClick}>reset</button>
</div>
)
}
책에 나와있는 useReducer 예제 코드이다.
useReducer 의 목적은 간단하다.
복잡한 형태의 state를 사전에 정의된 dispatcher로만 수정할 수 있게 만들어 줌으로써 state 값에 대한 접근은 컴포넌트에서만 가능하게 하고, 이를 업데이트하는 방법에 대한 상세 정의는 컴포넌트 밖에다 둔 다음, state의 업데이트를 미리 정의해 둔 dispatcher로만 제한하는 것이다.
state 값을 변경하는 시나리오를 제한적으로 두고 이에 대한 변경을 빠르게 확인할 수 있게끔 하는 것이 useReducer 의 목적이다❗️
따라서 단순히 number나 boolean처럼 간단한 값을 관리하는 것은 useState로 충분하지만, state 하나가 가져야 할 값이 복잡하고 이를 수정하는 경우의 수가 많아진다면, 성격이 비슷한 여러 개의 state를 묶어 useReducer로 관리하는 편이 더 효율적일 수도 있다.
forwardRef 파악하기 ⭐️ (p. 229-231)useRef 훅에서 반환한 객체인 ref는 리액트 컴포넌트의 props인 ref에 넣어 HTMLElement에 접근하는 용도로 사용한다.
이러한 ref를 상위 컴포넌트에서 하위 컴포넌트로 전달하고 싶거나, 상위 컴포넌트에서 접근하고 싶은 ref가 있지만 이를 직접 props로 넣어 사용할 수 없을 때 forwardRef 훅을 사용할 수 있다.
일반적으로 리액트에서는 컴포넌트의 props인 ref 속성을 통해 ref 객체를 전달할 수 없게 되어 있다. 전달하게 될 경우 undefined를 반환하며 경고문이 뜰 것이다.
다른 이름의 props를 설정해 ref 객체를 전달할 수도 있지만, 코드 상의 일관성을 제공하기 위해 forwardRef 훅이 탄생하게 되었다.
const ChildComponent = forwardRef((props, ref) => {
useEffect(() => {
// {current: undefined}
// {current: HTMLInputElement}
console.log(ref)
}, [ref])
return <div>안녕!</div>
})
function ParentComponent() {
const inputRef = useRef()
return (
<>
<input ref={inputRef} />
<ChildComponent ref={inputRef} />
</>
)
}
forwardRef 를 사용하는 예제를 살펴보자.
먼저 ref 객체를 받고자 하는 컴포넌트를 forwardRef 로 감싸고, 두 번째 인수로 ref를 전달 받는다. 그리고 부모 컴포넌트에서는 동일하게 props의 ref 속성을 통해 ref 객체를 넘겨줄 수 있다.
결국 forwardRef 를 사용하면 ref를 props로 전달할 수 있고, 전달 받은 컴포넌트에서도 ref라는 이름을 그대로 사용할 수 있다.
useEffect와 useLayoutEffect의 차이점과 실행 흐름 (p. 233-234)useLayoutEffect 훅은 형태나 사용 예제 면에서 useEffect 훅과 차이가 없다.
큰 차이점은 useLayoutEffect 훅은 모든 DOM의 변경 후에 useLayoutEffect의 콜백 함수 실행이 동기적으로 발생한다는 점이다. 여기서 DOM 변경은 브라우저에 변경사항이 반영되는 것이 아닌, 렌더링을 의미한다.
useEffect 와 useLayoutEffect 를 한 컴포넌트 내에서 함께 사용하고, 해당 컴포넌트의 state가 변경되어 리렌더링이 발생했을 때 두 훅의 콜백 함수가 어떻게 실행되는지 흐름을 살펴보자.
useLayoutEffect 를 실행한다.useEffect 를 실행한다.항상 useLayoutEffect 는 useEffect 보다 먼저 실행된다. 이는 useLayoutEffect 가 브라우저에 변경사항이 반영되기 전에 실행되는 반면, useEffect 는 그 이후에 실행되기 때문이다.
주의할 점으로는, 리액트 컴포넌트가 useLayoutEffect 가 완료될 때까지 기다려야 하기 때문에 웹 애플리케이션이 일시 중지되는 것과 같은 일이 발생하게 되며, 이는 성능 문제로까지 이어질 수 있다는 것이다. 따라서 반드시 필요할 때만 사용하는 것이 좋다.
AbortController 에 대하여 (p.239-240)import { useEffect, useState } from 'react'
// HTTP 요청을 하는 사용자 정의 훅
function useFetch<T>(
url: string,
{ method, body }: { method: string; body?: XMLHttpRequestBodyInit }
) {
// 응답 결과
const [result, setResult] = useState<T | undefined>()
// 요청 중 여부
const [isLoading, setIsLoading] = useState<boolean>(false)
// 2xx 3xx로 정상 응답인지 여부
const [ok, setOk] = useState<boolean | undefined>()
// HTTP Status
const [status, setStatus] = useSTate<number | undefined>()
useEffect(() => {
const abortController = new AbortController()
;(async () => {
setIsLoading(true)
const response = await fetch(url, {
method,
body,
signal: abortController.signal
})
setOk(response.ok)
setStatus(response.status)
if (response.ok) {
const apiResult = await response.json()
setResult(apiResult)
}
setIsLoading(false)
})()
return () => {
abortController.abort()
}
}, [url, method, body])
return [ok, result, isLoading, status]
}
위 코드는 AbortController 를 활용해 HTTP 요청을 하는 사용자 정의 훅을 구현한, 책에 나와있는 예제 코드이다. 여기서 AbortController 에 대해 더 자세하게 알아보려고 한다.
AbortController 란?AbortController 는 자바스크립트에서 제공하는 비동기 작업 취소용 컨트롤러이다. 주로 fetch API와 함께 사용되며, 요청을 중간에 중단할 수 있다.
〰️ 전반적인 흐름
controller = new AbortController()controller.signalcontroller.abort()리액트에서는 컴포넌트 언마운트 시 불필요한 네트워크 요청을 중단하는 용도로 많이 쓴다. 예를 들어 검색창 자동완성 기능에서 사용자가 입력할 때마다 API 요청을 보내면, 이전 요청이 완료되기도 전에 새 요청이 발생한다. 이럴 때 이전 요청을 취소해주면 불필요한 리소스 낭비를 막을 수 있다.
위 코드를 예로 들면, url, method, body가 바뀌면 useEffect가 다시 실행되면서 이전 요청은 클린업 함수에서 취소된다.
이외에도 언마운트된 컴포넌트에서 setState가 호출되는 문제를 예방해 메모리 누수를 방지하고, 검색, 무한스크롤, 실시간 데이터 요청에서 불필요한 요청을 줄여 리소스를 최적화하는 데에도 AbortController 를 주로 사용한다.
주의할 점은 AbortController 는 대부분의 최신 브라우저에서 지원되지만, IE와 일부 오래된 환경에서는 동작하지 않는다. 필요하다면 polyfill을 고려해야 한다.
사용자 정의 훅과 고차 컴포넌트 모두 리액트 코드에서 어떠한 로직을 공통화해서 별도로 관리할 수 있다는 특징이 있다.