React Hooks
이 문서는 "React, Hook 들어오네!?" 라는 전자책을 읽고 생각해볼만한 부분을 정리한 것이다. 코드잇 스프린트를 통해 리액트 쓰는 데 당장은 불편함이 없지만, 코어한 부분들에 대해서는 아직 미숙한 점이 많다고 생각해 이 책을 읽게 되었다. (이 다음으로는 아마도 모던 리액트 deep dive를 읽지 않을까 싶다) 해당 전자책은 리디에서 무료로 배포중이고, 또한 Notion 문서를 통해서도 자유롭게 읽어볼 수 있다.
so, what are Hooks?
생각해보면 훅이 뭔지도 모르고 무작정 쓰고 있었다. 클래스 컴포넌트 시절 ─ 이라고 이야기하니까 어쩐지 서부 개척 시대처럼 멀게만 느껴지는데 아무튼 ─ 에는 컴포넌트 내에서 다양한 메소드를 이용해 생애주기를 관리했었다. 함수형 컴포넌트가 발표된 초기에는 생애주기를 관리할 수 있는 수단이 전무했고, 사람들은 여전히 클래스 컴포넌트를 사용하였다. 따라서 함수형 컴포넌트에서 '효율적인 코드로 상태 관리 및 생애주기 기능을 사용'할 수 있도록 할 필요가 있었는데, 이러한 요구에 따라 만들어진 것이 바로 훅이다.
오직 React 함수 내에서만이 Hook을 호출할 수 있다. 이때 React는 Hook이 호출된 순서에 의존하여 상태 값을 구분하고 기억하기 때문에 조건문이나 반복문 안에서는 사용할 수 없다. 원활한 Hook의 사용을 위해 React는 위의 규칙을 강제해주는 eslint-plugin-react-hooks라는 ESLint 플러그인을 제공하고 있다. 이는 Create React App을 통해 React 프로젝트를 생성하면 기본적으로 포함되어 있으며 아래와 같은 명령어로 사용할 수 있다.
npm install eslint-plugin-react-hooks --save-dev
// ESLint 설정 파일
{
"plugins": [
// ...
"react-hooks"
],
"rules": {
// ...
"react-hooks/rules-of-hooks": "error", // Checks rules of Hooks
"react-hooks/exhaustive-deps": "warn" // Checks effect dependencies
}
}
useState
아직 공개하지 않은 Jotai 정리 문서에서 길게 쓰긴 했지만, 어쨌든 나는 서버 상태와 클라이언트 상태를 따로 분리한다는 개념을 극도로 최근에 알게 되었다. 그걸 깨닫고나니 useState와 '상태'라는 표현이 조금은 다르게 느껴진다.
아무튼 useState는 상태를 담고 있는 객체 state와 이 객체를 관리하는 함수 setState로 이루어진 배열을 뱉는다. 이전까지만 해도 왜 setState를 이용하는 것인지, state 값을 직접 수정하면 안 되는 것인지 의아했 ─ 다기보다는 사실 이 부분에 대해서 별 생각이 없 ─ 었는데, 이걸 보고 알게 되었다. state가 객체로 되어있기 때문에 직접 새 객체를 넣어주는 것은 곧 변수의 불변성을 손상시키는 행위였던 것이다!
또한, 함수 컴포넌트 안에서 일반 변수는 렌더링 될 때마다 초기화(reset 말고 init) 되지만, state 변수는 객체이기 때문에 변경된 값을 반영하여 업데이트하는 것이 가능해진다.
useEffect
컴포넌트가 호출되면 마운트 상태에 진입하고, React는 이를 계산해 가상 DOM을 생성한다. 이 가상 DOM의 변경 사항이 실제 DOM에 반영되면, 브라우저는 페인팅 과정까지를 밟게 된다.
그런데 만약 컴포넌트에 API 통신 등의 비동기 작업이 존재한다면, 새로 랜더링 될 때마다 비동기 작업을 수행하게 될 것이다. 이는 브라우저의 페인팅에까지 영향을 미치고, 모든 리소스가 비효율적으로 운영되게 된다. 이러한 부수 효과를 처리하기 위해 사용하는 훅이 바로 useEffect인 셈이다.
💡 부수효과(Side Effect) 란?
리액트 공식문서에는 ‘render() 함수는 순수해야 합니다. 즉, 컴포넌트의 state를 변경하지 않고, 호출될 때마다 동일한 결과를 반환해야 하며, 브라우저와 직접적으로 상호작용을 하지 않습니다.’ 라고 언급하고 있다. 즉, 컴포넌트의 state를 변경하거나 브라우저와 상호작용하여 호출될 때마다 동일한 결과가 반환되지 않는 것들을 부수효과라고 하는 것이다.
아주 오래 전 클래스 컴포넌트 시절에는 componentDidMount , componentDidUpdate, componentWillUnmount 라고 하는 메소드로 생애주기를 관리했는데, useEffect는 이 세 메소드를 합친 것처럼 동작한다.
정리 함수(clean-up function)
useEffect의 콜백 함수에서 동작하는 것들은 필요 없어진 순간 끊어낼 수 있어야 한다. 가령 URL.createObjectURL() 같은 정적 메소드에 이미지를 넣으면 img 태그에서 쓸 수 있는 문자열을 반환하는데, 이 과정에서 이미지 파일이 메모리 힙에 들어가게 된다. 때문에 이미지가 필요 없어진 순간에 이를 정리해주지 않으면 메모리 누수가 생길 수 밖에 없다. 이러한 문제를 해결해주는 것이 바로 useEffect의 정리함수이다.
useEffect(() => {
const imgSrc = URL.createObjectURL(imgFile)
setImgSrc(imgSrc)
return () => {
URL.removeObjectURL(imgFile)
setImgSrc('')
}
}, [])
useLayoutEffect
흔하게 쓰이는 훅은 아니다. useEffect와 비슷하게 동작하나, 동작 시점이 다르다. 프로젝트를 진행하면서 useEffect로 인한 어쩔 수 없는 깜빡임 때문에 좀 기절할 뻔 했는데, useLayoutEffect를 사용하면 이러한 ─ 주로 비동기 데이터를 받아온다던가 할 때의 ─ 깜빡임을 막아줄 수 있다.
원리는 간단하다. useEffect는 paint 이후에 실행되지만, useLayoutEffect는 paint 이전에 실행되기 때문에 사용자에게 보여지는 게 없어서 깜빡이지도 않는 것이다.
useContext
state를 전달하기 위해 prop drilling을 하는 일은 ─ 특히 컴포넌트의 재사용성을 높이기 위해 잘게 조각내어 놓은 상황이라면 ─ 아찔하다. 나는 이를 해결하기 위해 간단하게 Jotai를 사용했지만, 결국 Jotai도 context api를 끌어다가 훨씬 더 고도로 추상화해놓은 것에 불과하다는 이야기를 들은 적이 있다.
개인적으로는 context api가 결국 1) 컨텍스트 생성 2) provider 설정 3) useContext로 값을 가져오는 3개의 구조로 되어있다고 생각한다. 아래는 이러한 구조를 바탕으로 재사용성을 높인 커스텀 훅 비스무리한 것이다.
import { createContext, useContext, useState } from 'react';
const LocaleContext = createContext(); // 컨텍스트 생성
export function LocaleProvider({ defaultValue = 'ko', children }) {
const [locale, setLocale] = useState(defaultValue);
return (
<LocaleContext.Provider value={{ locale, setLocale }}>
{children}
</LocaleContext.Provider>
);
} // provider 설정
export function useLocale() {
const context = useContext(LocaleContext);
if (!context) {
throw new Error('반드시 LocaleProvider 안에서 사용해야 합니다');
}
const { locale } = context;
return locale;
} // useContext 훅을 사용해 값 사용
export function useSetLocale() {
const context = useContext(LocaleContext);
if (!context) {
throw new Error('반드시 LocaleProvider 안에서 사용해야 합니다');
}
const { setLocale } = context;
return setLocale;
} // useContext 훅을 사용해 값 사용
+ context API에 state를 넣어서 써서 이게 상태관리 툴처럼 느껴지지만, 실제로는 외부에서 의존성 주입하는 데 더 요긴하게 써먹을 것 같다. 객체지향의 다형성이나 타입스크립트의 덕타이핑 비슷한 결이라고 생각된다.
// 컨텍스트 생성
const SignContext = createContext();
// 프로바이더를 사용하여 외부에서 로직 주입
<SignContext.Provider value={{signLogic : signupLogic}}>
<SignForm />
</SignContext.Provider>
// 컨텍스트를 사용하는 쪽에서는 signinLogic인지 signupLogic인지 알 필요가 없음
const {{ signLogic }} = useContext(SignContext);
useReducer
기본적으로 useReducer는 복잡한 상태 처리를 간편하게 해치우기 위해 도입되었다. Redux에서 봤던 개념들이 여기서도 보이는데, 'dispatch, action, reducer'가 그것이다. 간단하게 설명하자면 dispatch는 상태 변화 요청이고, action은 상태 변경 내용이며, reducer는 state를 업데이트하는 데 사용된다.
타입스크립트에서 setState의 타입이나 useReducer 훅이 리턴하는 dispatch 함수의 타입이 dispatch<SetStateAction<string>> 이런 식인 것도 다 그런 이유이다.
const [state, dispatch] = useReducer(reducer, initialValue);
이것이 기본적인 useReducer 훅의 형태이다. dispatch가 호출되면, dispatch 함수를 통해 전달되는 action 객체가 reducer로 들어가게 되고, reducer에서 조건문을 통해 리턴된 값으로 state를 업데이트하게 된다. state의 업데이트 로직이 컴포넌트 바깥에 위치하므로, 결국 이 useReducer를 사용하면 컴포넌트에서 상태 변화 로직을 분리할 수 있게 된다.
function reducer(state, action) {
if (action.type === "up") {
return state + 1;
} else if (action.type === "down") {
return state - 1;
} else if (action.type === "reset") {
return 0;
}
}
useReducer를 쓰는 가장 큰 이유는 setState의 기능을 세분화하여 reducer 함수에서 이를 처리한다는 점이다. 이를 통해 reducer 함수 내부에서 state를 변경하는 과정을 은닉할 수 있게 된다.
useState에서와 마찬가지로 useReducer에서도 dispatch와 reducer를 하위 컴포넌트로 내려보내는 건 어마어마한 prop drilling을 유발하기 때문에, 위에서 useState를 커스텀 훅으로 잘 감싼 것처럼 비슷하게 만들어서 쓰면 좋다.
useMemo vs useCallback
이 두 훅은 굉장히 비슷한 형태로 쓰인다. 그러나 useMemo의 대상은 값 그 자체이며, 주로 계산 비용이 높은 연산이나 함수의 리턴 값을 캐싱하여 렌더링 시 다시 계산하지 않도록 한다. 반면에 useCallback의 대상은 함수이며, 재 랜더링 될 때마다 함수를 새로 생성하는 것을 방지하기 위해 사용한다. 둘 모두 의존성 배열을 받으며, 의존성 배열에 변화가 있으면 useMemo는 값에 대한 새로운 계산을 진행하고, useCallback은 새로운 콜백 함수를 생성한다.
const memoizedCallback = useCallback(function, deps);
const memoizedValue = useMemo(() => function, deps);
💡 Memoization
메모이제이션 기법은 연산의 결괏값을 메모리에 저장해 두고 이전 렌더링에서 계산한 값과 현재 렌더링에서의 결과가 같은 경우, 중복 연산을 할 필요 없이 저장해 둔 값을 재사용 할 수 있으므로 성능을 최적화할 수 있다. 가령 A라는 함수의 전체 실행 시간이 10초라고 한다면, 리 랜더링 될 때마다 10초씩 걸리게 될 것이다.
그러나 메모이제이션을 통해 값을 저장하고 함수가 실행될 때 그 결괏값만 재사용할 수 있다면 그만큼 연산을 줄일 수 있으므로 전체적인 프로젝트의 실행 속도를 올릴 수 있게 된다.
useRef
바닐라 JS에서는 온갖 똥꼬쇼를 통해 DOM에 접근하지만, react에서는 이를 권장하지 않고 있다. 따라서 가상 DOM의 가상 node에 접근하기 위해 사용하는 것이 바로 useRef이다. 이 훅을 사용하여 반환된 객체는 컴포넌트의 전 생애주기를 통해 유지된다. 때문에 컴포넌트 값의 변경은 관리해야 하지만 리렌더링을 발생시킬 필요는 없을 때 활용할 수 있겠다.
const ref = useRef(initialValue);
한 가지 주의해야 할 점은 DOM이 랜더링 되기도 전에 node에 접근하려는 시도는 에러를 일으킨다는 것이다. 따라서 ref.current에 어떠한 조작을 가하려면 1) useEffect 등을 사용하여 랜더링이 완료된 후에 접근하거나 2) handleClick 과 같은 이벤트 핸들러 함수를 통해 랜더링이 완료된 것을 확신할 수 있는 시점에 조작할 수 있도록 해야겠다.
useHooks 라이브러리
이번 프로젝트를 진행하면서 느낀 점 중에 하나는 라이브러리를 잘 가져다 쓰는 것만으로도 생산성이 증가한다는 점이다. 리액트는 커스텀 훅이라고 하는 개념이 존재하고, 이를 라이브러리처럼 묶어서 배포하는 경우도 종종 있다. 그 중 useHooks 라이브러리는 서버 사이드 렌더링에서도 쓸 수 있는 안전한 훅이 모여있어서, 여기서 제공하는 몇 가지 개쩌는 훅을 짚고 넘어가고자 한다.
useDebounce
debounce란 함수를 여러 번 호출하고 마지막 호출에서 일정 시간이 지난 후에야 비로소 해당 함수의 기능이 동작하도록 만드는 기법을 말한다. 검색 기능에서 주로 사용되는데, 키 입력마다 API 요청을 보낸다면 불필요한 리소스를 사용하는 것은 물론 서버에 부하를 줄 수도 있다.
const [inputValue, setInputValue] = useState("");
// useDebounce 훅을 사용하여 입력값을 0.5초마다 업데이트합니다.
const debouncedInputValue = useDebounce(inputValue, 500);
useEffect(() => {
// 입력값이 변경될 때마다 debouncedInputValue를 업데이트합니다.
setInputValue(debouncedInputValue);
}, [debouncedInputValue]);
useLocalStorage
Local Storage는 Session Storage와 달리 브라우저를 닫아도 데이터가 휘발되지 않고 계속 유지되는 저장소이다. 이러한 특성으로 인해 고정적으로 박아둬야 하는 값들을 이곳에 저장하기도 하는데, useLocalStorage 훅을 사용하면 훨씬 간편하게 이러한 저장소에 접근할 수 있다.
const [token, setToken] = useLocalStorage("accessToken", null);
useLocalStorage의 첫 매개 변수로는 Local Storage에서 일치하는 key를 찾는 데 사용되며, 만약 일치하는 key에 value가 없다면 두 번째로 주어지는 매개 변수로 state를 초기화한다.
useWindowSize
가끔은 화면 크기에 따라서 동적으로 state를 변경해주어야 할 때가 있는데, useWindowSize 훅을 쓰면 언제든 컴포넌트의 크기를 확인할 수 있다. 여기서 중요한 것은 브라우저의 크기가 아니라 컴포넌트의 크기를 확인한다는 점이다. 이 훅은 width와 heigth 값으로 이루어진 객체를 반환한다.
const size = useWindowSize();
const width = size.width
const heigth = size.height
useIntersectionObserver
이번 프로젝트를 진행하면서 가장 만들기 햇갈렸던 커스텀 훅이다. 이런 식으로 누가 라이브러리화 해뒀을 줄은 꿈에도 생각 못했다... 글자 그대로 intersectionObserver API를 편하게 사용할 수 있도록 만들어주는 훅이다.
const [ref, entry] = useIntersectionObserver({
threshold: 0,
root: null,
rootMargin: "0px",
});
이 훅은 옵션 객체를 받는데, 이 객체를 구성하는 각 프로퍼티들은 intersectionObserver 에서도 그대로 쓰이는 값들이다. 더 궁금하다면 intersectionObserver API에 대해서도 정리해둔 문서가 있으니 확인 ㄱㄱ
그리고 ref와 entry로 이루어진 배열을 뱉는데, 추적하기를 원하는 요소에다가 ref를 달아준다면 아주 간편하게 요소가 보이는 지 안 보이는지를 추적할 수 있다. entry는 IntersectionObserverEntry와 같은 종류의 객체인데, isIntersecting 메소드나 intersectionRatio와 같은 메소드 역시 사용할 수 있다.
entry?.isIntersecting && (
<>
<img src={imgUrl} alt={caption} />
<figcaption>
<a href={href} alt={caption} target="_blank" rel="noreferrer">
{caption}
</a>
</figcaption>
</>
)
useClickAway
모달창이 활성화 되어있는 상태에서 닫기 버튼이 아닌 모달 바깥의 영역을 클릭하여 모달창을 닫고 싶은 경우가 있을 수 있다. 이럴 때 useClickAway를 사용하면 쉽게 구현할 수 있다.
const ref = useClickAway(() => {})
이 훅은 함수를 받고 ref를 뱉는데, useIntersectionObserver의 ref와 마찬가지로 추적하고 싶은 요소에 달아주면 된다.
블로그의 정보
Ayden's journal
Beard Weard Ayden