함수형 컴포넌트 내부에서 상태를 정의하고, 이 상태를 관리할 수 있게 해주는 훅이다.
useState의 인수로는 사용할 state의 초깃값을 넘겨준다. 아무런 값을 넘겨주지 않으면 초깃값은 undefinted다. useState 훅의 반환 값은 배열이며, 배열의 첫 번째 원소로 state 값 자체를 사용할 수 있고, 두 번째 원소인 setState 함수를 사용해 해당 state 값을 변경할 수 있다.
usetState는 자바스크립의 특징 중 하나인 클로저에 의존해 구현돼 있을 것이라는 사실을 짐작해 볼 수 있다. 클로저를 사용함으로써 외부에 해당 값을 노출시키지 않고 오직 리액트에서만 쓸 수 있었고, 함수형 컴포넌트가 매번 실행되더라도 useState에서 이전의 값을 정확하게 꺼내 쓸 수 있게 됐다.
useState에 변수 대신 함수를 넘기는 것을 게으른 초기화(lazy initialization)라고 한다.
리액트 공식 문서에서 이러한 게으른 초기화는 useState의 초깃값이 복잡하거나 무거운 연산을 포함하고 있을 때 사용하라고 돼 있다. 이 게으른 초기화 함수는 오로지 state가 처음 만들어질 때만 사용된다.
만약 useState 인수로 자바스크립트에 많은 비용을 요구하는 작업이 들어가 있다면 이는 계속해서 실행될 위험이 존재할 것이다. 그러나 우려와는 다르게 useState 내부에 함수를 넣으면 이는 최초 렌더링 이후에는 실행되지 않고 최초의 state 값을 넣을 때만 실행된다.
만약 Number.parseInt(window.localStorage.getItem(cacheKey))와 같이 한 번 실행되는 데 어느 정도 비용이 드는 값이 있다고 가정해 보자. useState의 인수로 이 값 자체를 사용한다면 초깃값이 필요한 최초 렌더링과, 초깃값이 있어 더 이상 필요 없는 리렌더링 시에도 동일하게 계속 해당 값에 접근해서 낭비가 발생한다. 따라서 이런 경우에는 함수 형태로 인수에 넘겨주는 편이 훨씬 경제적일 것이다. 초깃값이 없다면 함수를 실행해 무거운 연산을 시도할 것이고, 이미 초깃값이 존재한다면 함수 실행을 하지 않고 기존 값을 사용할 것이다.
그렇다면 게으른 최적화는 언제 쓰는 것이 좋을까? 리액트에서는 무거운 연산이 요구될 때 사용하라고 한다. 즉, localStorage나 sessionStorage에 대한 접근 map, filter, find같은 배열에 대한 접근, 혹은 초깃값 계산을 위해 함수 호출이 필요할 때와 같이 무거운 연산을 포함해 실행 비용이 많이 드는 경우에 게으른 초기화를 사용하는 것이 좋다.
알려진 것처럼 생명주기 메서드를 대체하기 위해 만들어진 훅도 아니다. useEffect의 정의를 정화갛게 내리자면 useEffect는 애플리케이션 내 컴포넌트의 여러 값들을 활용해 동기적으로 부수 효과를 만드는 메커니즘이다. 그리고 이 부수 효과가 '언제' 일어나는지보다 어떤 상태값과 함께 실행되는지 살펴보는 것이 중요하다.
클린업 함수는 이전 counter 값, 즉 이전 state를 참조해 실행된다는 것을 알 수 있다. 클린업 함수는 새로운 값과 함께 렌더링된 뒤에 실행되기 때문에 위와 같은 메시지가 나타난다. 여기서 중요한 것은, 클린업 함수는 비록 새로운 값을 기반으로 렌더링 뒤에 실행되지만 이 변경된 값을 읽는 것은 아니라 함수가 정의됐을 당시에 선언됐던 이전 값을 보고 실행된다는 것이다.
함수형 컴포넌트의 useEffect는 그 콜백이 실행될 때마다 이전의 클린업 함수가 존재한다면 그 클린업 함수를 실행한 뒤에 콜백을 실행한다. 따라서 이벤트를 추가하기 전에 이전에 등록했던 이벤트 핸들러를 삭제하는 코드를 클린업 함수에 추가하는 것이다. 이렇게 함으로써 특정 이벤트의 핸들러가 무한히 추가되는 것을 방지할 수 있다.
이처럼 클린업 함수는 생명주기 메서드의 언마운트 개념과는 조금 차이가 있는 것을 볼 수 있다. 언마운트는 특정 컴포넌트가 DOM에서 사라진다는 것을 의미하는 클래스형 컴포넌트의 용어다. 클린업 함수는 언마운트라기보다는 함수형 컴포넌트가 리렌더링됐을 때 의존성 변화가 있었을 당시 이전의 값을 기준으로 실행되는, 말 그대로 이전 상태를 청소해 주는 개념으로 보는 것이 옳다.
만약 빈 배열을 둔다면 리액트가 이 useEffect는 비교할 의존성이 없다고 판단해 최초 렌더링 직후 실행된 다음부터는 더 이상 실행되지 않는다. 아무런 값도 넘겨주지 않는다면 이때는 의존성을 비교할 필요 없이 렌더링할 때마다 실행이 필요하다고 판단해 렌더링이 발생할 때마다 실행된다.
// 1
function Component() {
console.log('렌더링됨');
}
// 2
function Component() {
useEffect(() => {
console.log('렌더링됨;);
});
}
useEffect의 effect는 컴포넌트의 사이드 이펙트, 즉 부수 효과를 의미한다는 것을 명심하자. useEffect는 컴포넌트가 렌더링된 후에 어떠한 부수 효과를 일으키고 싶을 때 사용하는 훅이다.
핵심은 의존성 배열의 이전 값과 현재 값의 얕은 비교다. Object.is를 기반으로 하는 얕은 비교를 수행한다. 이전 의존성 배열과 현재 의존성 배열의 값에 하나라도 변경 사항이 있다면 callback으로 선언한 부수 효과를 실행한다. 이것이 useEffect의 본질이다.
비용이 큰 연산에 대한 결과를 저장(메모제이션)해 두고, 이 저장된 값을 반환하는 훅이다.
첫 번째 인수로는 어떠한 값을 반환하는 생성 함수를, 두 번째 인수로는 해당 함수가 의존하는 값의 배열을 전달한다. useMemo는 렌더링 발생 시 의존성 배열의 값이 변경되지 않았으면 함수를 재실행하지 않고 이전에 기억해 둔 해당 값을 반환하고, 의존성 배열의 값이 변경됐다면 첫 번째 인수의 함수를 실행한 후에 그 값을 반환하고 그 값을 다시 기억해 둘 것이다. 이러한 메모이제이션은 단순히 값뿐만 아니라 컴포넌트도 가능하다.
useMemo 등 메모이제이션을 활용하면 무거운 연산을 다시 수행하는 것을 막을 수 있다는 장점이 있다.
useMemo가 값을 기억했다면, useCallback은 인수로 넘겨받은 콜백 자체를 기억한다. 쉽게 말해 useCallback은 특정 함수를 새로 만들지 않고 다시 재사용한다는 의미다.
useMemo와 useCallback의 유일한 차이는 메모이제이션을 하는 대상이 변수냐 함수냐일 뿐이다. 자바스크립트에서는 함수 또한 값으로 표현될 수 있으므로 이러한 코드는 매우 자연스럽다고 볼 수 있다. 다만 useMemo로 useCallback을 구현하는 경우 다음과 같이 불필요하게 코드가 길어지고 혼동을 야기할 수 있으므로 리액트에서 별도로 제공하는 것으로 추측해 볼 수 있다.
const handleClick1 = useCallback(() => {
setCounter((prev) => prev + 1);
}, []);
const handleClick2 = useMemo(() => {
return () => setCounter((prev) => prev + 1);
}, []);
이러한 props 내려주기를 극복하기 위해 등장한 개념이다. 콘텍스트를 사용하면 이러한 명시적인 props 전달 없이도 선언한 하위 컴포넌트 모두에서 자유롭게 원하는 값을 사용할 수 있다.
useContext를 사용하면 상위 컴포넌트 어딘가에서 선언된 <Context.Provider />에서 제공한 값을 사용할 수 있게 된다. 만약 여러 개의 Provider가 있다면 가장 가까운 Provider의 값을 가져오게 된다.
useContext를 함수형 컴포넌트 내부에서 사용할 때는 항상 컴포넌트 재활용이 어려워진다는 점을 염두에 둬야 한다. useContext가 선언돼 있으면 Provider에 의존성을 가지고 있는 셈이 되므로 아무데서나 재활용하기에는 어려운 컴포넌트가 된다.
컨텍스트가 미치는 범위는 필요한 환경에서 최대한 좁게 만들어야 한다.
일부 리액트 개발자들이 콘텍스트와 useContext를 상태 관리를 위한 리액트의 API로 오해하고 있다는 것이다. 엄밀히 따지면 콘텍스트는 상태를 주입해 주는 API다. 상태 관리 라이브러리가 되기 위해서는 최소한 다음 두 가지 조건을 만족해야 한다.
그러나 콘텍스트는 둘 중 어느 것도 하지 못한다. 단순히 props 값을 하위로 전달해 줄 뿐, useContext를 사용한다고 해서 렌더링이 최적화되지는 않는다.
useReducer는 useState의 심화 버전으로 볼 수 있다. useState와 비슷한 형태를 띠지만 좀 더 복잡한 상태값을 미리 정의해 놓은 시나리오에 따라 관리할 수 있다.
복잡한 형태의 state를 사전에 정의된 dispatcher로만 수정할 수 있게 만들어 줌으로써 state 값에 대한 접근은 컴포넌트에서만 가능하게 하고, 이를 업데이트하는 방법에 대한 상세 정의는 컴포넌트 밖에다 둔 다음, state의 업데이트를 미리 정의해 둔 dispatcher로만 제한하는 것이다. state 값을 변경하는 시나리오를 제한적으로 두고 이에 대한 변경을 빠르게 확인할 수 있게끔 하는 것이 useReducer의 목적이다.
일반적으로 단순히 number나 boolean과 같이 간단한 값을 관리하는 것은 useState로 충분하지만 state 하나가 가져야 할 값이 복잡하고 이를 수정하는 경우의 수가 많아진다면 state를 관리하는 것이 어려워진다. 또 여러 개의 state를 관리하는 것보다 때로는 성격이 비슷한 여러 개의 state를 묶어 useReducer로 관리하는 편이 더 효율적일 수도 있다. 이렇게 useReducer를 사용해 state를 관리하면 state를 사용하는 로직과 이를 관리하는 비즈니스 로직을 분리할 수 있어 state를 관리하기가 한결 쉬워진다.
useReducer나 useState 둘 다 세부 작동과 쓰임에만 차이가 있을 뿐, 결국 클로저를 활용해 값을 가둬서 state를 관리한다는 사실에는 변함이 없다. 따라서 리액트 개발자는 필요에 맞게 useReducer나 useState를 취사선택해 사용하면 될 것이다.
ref는 useRef에서 반환한 객체로, 리댁트 컴포넌트의 props인 ref에 넣어 HTMLElement에 접근하는 용도로 흔히 사용한다. forwardRef는 ref를 전달하는 데 있어서 일관성을 제공하기 위해 탄생했다. 어떤 props 명으로 전달할지 모르고, 이에 대한 완전한 네이밍의 자유가 주어진 props 보다는 forwardRef를 사용하면 좀 더 확실하게 ref를 전달할 것임을 예측할 수 있고, 또 사용하는 쪽에서도 확실히 안정적으로 받아서 사용할 수 있다.
useImperativeHandle은 부모에게서 넘겨받은 ref를 원하는 대로 수정할 수 있는 훅이다.
'모든 DOM의 변경 후에 useLayoutEffect의 콜박함수 실행이 동기적으로 발생'한다. 여기서 말하는 DOM 변경이란 렌더링이지, 브라우저에 실제로 해당 변경 사항이 반영되는 시점을 의미하는 것은 아니다.
실행순서
순서상으로는 useEffect가 먼저 선언돼 있지만 항상 useLayoutEffect가 useEffect보다는 먼저 실행된다. 이는 useLayoutEffec가 브라우저에 변경 사항이 반영되기 전에 실행되는 반면 useEffect는 브라우저에 변경 사항이 반영된 이후에 실행되기 때문이다.
그리고 동기적으로 발생한다는 것은 리액트의 useLayoutEffect의 실행이 종료될 때까지 기다린 다음에 화면을 그린다는 것을 의미한다. 즉, 리액트 컴포넌트는 useLayoutEffect가 완료될 때까지 기다리기 때문에 컴포넌트가 잠시 동안 일시 중지되는 것과 같은 일이 발생하게 된다. 따라서 이러한 작동 방식으로 인해 웹 애플리케이션 성능에 문제가 발생할 수 있다.
useLayoutEffect의 특징상 DOM은 계산됐지만 이것이 화면에 반영되기 전에 하고 싶은 작업이 있을 때와 같이 반드시 필요할 때만 사용하는 것이 좋다. 특정 요소에 따라 DOM 요소를 기반으로 한 애니메이션, 스크롤 위치를 제어하는 등 화면에 반영되기 전에 하고 싶은 작업에 useLayoutEffect를 사용한다면 useEffect를 사용했을 때보다 훨씬 더 자연스러운 사용자 경험을 제공할 수 있다.
최상위에서만 훅을 호출해야 한다. 반복문이나 조건문, 중첩된 함수 내에서 훅을 실행할 수 없다. 이 규칙을 다라야만 컴포넌트가 렌더링 될 때마다 항상 동일산 순서로 훅이 호출되는 것을 보장할 수 있다.
훅을 호출할 수 있는 것은 리액트 함수형 컴포넌트, 혹은 사용자 정의 훅의 두 가지 경우뿐이다. 일반 자바스크립트 함수에서는 훅을 사용할 수 없다.
useState나 useEffect는 모두 순서에 아주 큰 영향을 받는다.
리액트 훅은 파이버 객체의 링크드 리스트의 호출 순서에 따라 저장된다. 그 이유는 각 훅이 파이버 객체 내에서 순서에 의존해 state나 effect의 결과에 대한 값을 저장하고 있기 때문이다. 이렇게 고정된 순서에 의존해 훅과 관련된 정보를 저장함으로써 이전 값에 대한 비교와 실행이 가능해진다.
이렇게 조건이나 다른 이슈로 인해 훅의 순서가 깨지거나 보장되지 않을 경우 리액트 코드는 에러를 발생시킨다.
그러므로 훅은 절대 조건문, 반복문 등에 의해 리액트에서 예측 불가능한 순서로 실행되게 해서는 안 된다. 항상 훅은 실행 순서를 보장받을 수 있는 컴포넌트 최상단에 선언돼 있어야 한다. 조건문이 필요하다면 반드시 훅 내부에서 수행해야 한다.
서로 다른 컴포넌트 내부에서 같은 로직을 공유하고자 할 때 주로 사용되는 것이 바로 사용자 정의 훅이다.
이 사용자 정의 훅의 규칙 중 하나는 이름이 반드시 use로 시작하는 함수를 만들어야 한다는 것이다. 리액트 훅의 이름은 use로 시작한다는 규칙이 있으며,사용자 정의 훅도 이러한 규칙을 준수함으로써 개발 시 해당 함수가 리액트 훅이라는 것을 바로 인식할 수 있다는 장점도 있다.
사용자 정의 훅은 내부에 useState와 useEffect 등을 가지고 자신만의 원하는 훅을 만드는 기법으로, 내부에서 useState와 같은 리액트 훅을 사용하고 있기 떄문에 당연히 앞서 언급한 리액트 훅의 규칙을 따라야 한다.
이러한 사용자 정의 훅은 리액트 커뮤니티에서 다양하게 찾아볼 수 있었는데, 유명한 저장소는 아래와 같다.
고차 컴포넌트(HOC, Higher Order Component)는 컴포넌트 자체의 로직을 재사용하기 위한 방법이다.
고차 함수(Higher Order Function)의 일종으로, 자바스크립트의 일급 객체, 함수의 특징을 이용하므로 굳이 리액트가 아니더라도 자바스크립트 환경에서 널리 쓰일 수 있다.
고차 함수의 사전적인 정의를 살펴보면 '함수를 인수로 받거나 결과로 반환하는 함수'라고 정의돼 있다.
사용자 정의 훅과 고차 컴포넌트 모두 리액트 코드에서 어떠한 로직을 공통화해 별도로 관리할 수 있다는 특징이 있다. 애플리케이션 전반에 필요한 중복된 로직을 별도로 분리해 컴포넌트의 크기를 줄이고 가독성을 향상시키는 데 도움을 줄 수 있다.
단순히 useEffect, useState와 같이 리액트에서 제공하는 훅으로만 공통 로직을 격리할 수 있다면 사용자 정의 훅을 사용하는 것이 좋다. 사용자 정의 훅은 그 자체로는 렌더링에 영향을 미치지 못하기 때문에 사용이 제한적이므로 반환하는 값을 바탕으로 무엇을 할지는 개발자에게 달려 있다. 따라서 컴포넌트 내부에 미치는 영향을 최소화해 개발자가 훅을 원하는 방향으로만 사용할 수 있다는 장점이 있다.
반면 withLoginComponent는 고차 컴포넌트가 어떤 일을 하는지, 어던 결과물을 반환할지는 고차 컴포넌트를 직접 보거나 실행하기 전까지는 알 수 없다. 대부분의 고차 컴포넌트는 렌더링에 영향을 미치는 로직이 존재하므로 사용자 정의 훅에 비해 예측하기가 어렵다. 따라서 단순히 컴포넌트 전반에 걸쳐 동일한 로직으로 값을 제공하거나 특정한 훅의 작동을 취하게 하고 싶다면 사용자 정의 훅을 사용하는 것이 좋다.
어차피 loggedIn이 false인 경우 렌더링해야 하는 컴포넌트는 동일하지만 사용자 정의 훅만으로는 이를 표현하기 어렵다. 사용자 정의 훅은 해당 컴포넌트가 반환하는 렌더링 결과물에까지 영향을 미치기는 어렵기 때문이다. 그리고 이러한 중복 처리가 해당 사용자 정의 훅을 사용하는 애플리케이션 전반에 걸쳐 나타나게 될 것이므로 사용자 정의 훅보다는 고차 컴포넌트를 사용해 처리하는 것이 좋다.