zero-wiki Help

리액트 훅 깊게 살펴보기

리액트의 모든 훅 파헤치기

useState

구현 살펴보기

import { useState } from 'react' const [state, setState] = useState(initialState);

useState의 인수로는 사용할 state의 초기값을 넘겨줍니다. 만약 넘겨주지 않을 경우 초기값은 undefined입니다.

useState는 다음과 같이 구현이 되어 있습니다. setState를 통해 이전 상태에 바로 접근할 수 있는 이유는 closure를 이용하여 구현되어 있습니다. 또한 closure는 이처럼 "독립적인 주소를 계속 기억하고 있어야 하기 때문에 불필요하게 메모리를 잡아먹습니다. 때문에 적게 사용하는 것이 좋습니다.

const Project = (function(){ const global = {} let index = 0 function useState(initialState) { // 어플리케이션 전체의 state를 초기화합니다. if(!global.states){ global.states = [] } } // state 정보를 조회해 현재 상태값이 있는지 확인하고 없다면 초기값으로 설정합니다. const currentState = global.states[index] || initialState global.states[index] = currentState // 즉시 실행 함수로 setter를 만듭니다. const setState = (function () { // 현재 index를 클로저를 사용해서 가둬두었기에 이후에도 동일한 index로 접근이 가능합니다. let currentIndex = index return function (value) { global.states[currentIndex] = value } })() // useState를 사용할 때마다 index가 1씩 증가합니다. 다음 값을 가리키게 됩니다. index += 1 return [currentState, setState] function Component() { const [value, setValue] = useState(0) } // ... }

게으른 초기화

useState의 인자 값으로 원시 값이 아닌 특정한 값을 넘기는 함수를 넣어줄 수 있습니다. 이를 게으른 초기화 함수라고 얘기를 하는데 이는 오로지 state가 처음 만들어 질 때만 사용이 됩니다. 이후에 렌더링이 발생된다면 이 함수의 실행은 무시됩니다.

useEffect

대부분의 개발자들이 useEffect에 대해 하는 말입니다.

  • useEffect는 두 개의 인수를 받는데, 첫 번째는 콜백, 두 번째는 의존성 배열이다. 이 두 번째 의존성 배열의 값이 변경되면 첫 번째 인수인 콜백을 실행합니다.

  • 클래스 컴포넌트의 생명주기 메서드와 비슷하게 동작을 구현할 수 있다. 의존성 배열에 빈 배열을 넣으면 컴포넌트가 마운트 될 때만 사용할 수 있습니다.

  • 클린업 함수를 반환할 수 있는데, 이 클린업 함수는 언마운트 될 때 실행됩니다.

이러한 설명은 정확하게 맞지 않지만 다음과 같이 정의할 수 있습니다.

사용법

function Componet(){ useEffect(() => { callback() },[props, state]) }

첫 번째 인수로는 실행할 부수 효과가 포함된 함수를, 두 번째 인수로는 의존성 배열을 전달합니다.

useEffect는 의존성 배열이 변경될 때마다 첫 번째 인수인 콜백을 실행한다. 과연 어떻게 알아채고 실행을 하는걸까?

여기서 기억해야하는 사실은 함수 컴포넌트는 매번 함수를 호출해 렌더링을 수행한다는 것이다. 때문에 렌더링이 일어날 때마다 의존성에 있는 값을 보면서 값이 이전과 다른게 하나라도 있다면 부수 효과를 실행하는 평범한 함수입니다.

클린업 함수의 목적

아래와 같이 count 값을 출력하는 동작을 가진 컴포넌트가 있습니다.

export function Counter() { const [count, setCount] = useState(0) const handleClick = () => { setCount(prev => prev + 1) } useEffect(()=> { function showCount(){ console.log(count) } window.addEventListener('click', showCount) return () => { console.log('clean up function', count) window.removeEventListener('click', showCount) } }, [count]) return ( <div> <span>{count}</span> <button onClick={handleClick}>click</button> </div> ) }

진행되는 과정은 다음과 같습니다.

  1. 클린업 함수는 컴포넌트가 언마운트되거나, 의존성 배열의 값이 바뀔 때 실행됩니다.

  2. 버튼을 클릭하였을 때 window에 내장된 EventListener로 인해 count 값이 증가합니다.

  3. count 값이 증가함에 따라 리렌더링이 되고 useEffect가 이를 감지합니다.

  4. useEffect 내에 선언된 함수가 다시 실행이 됩니다.

위의 출력 결과는 다음과 같습니다.

# 버튼 클릭 클린 업 함수 실행 0 0 # 버튼 클릭 클린 업 함수 실행 0 1 # 버튼 클릭 클린 업 함수 실행 1 2 # 버튼 클릭 클린 업 함수 실행 2 3

만약 클린업 함수를 통해 removeEventListener를 제거하지 않으면 다음과 같이 작동합니다.

# 버튼 클릭 0 1 # 버튼 클릭 0 1 2 # 버튼 클릭 0 1 2 3 # 브라우저 클릭 0 1 2 3

이같은 상황이 펼쳐지는 이유는 클린업 함수는 작동하지 않았지만 계속해서 이벤트가 등록이 되기 때문입니다.

useEffect(()=> { function showCount(){ console.log(count) } window.addEventListener('click', showCount) }, [count])

때문에 매번 클린업 함수를 통해 이전 함수를 지워주어야지 아니면 계속해서 쌓이게 됩니다.

window.addEventListener('click', showCount) window.addEventListener('click', showCount) window.addEventListener('click', showCount) window.addEventListener('click', showCount) window.addEventListener('click', showCount)

의존성 배열

아무것도 주지 않은 경우

의존성을 비교할 필요없이 렌더링 할 때마다 실행이 필요하다고 판단하여 렌더링이 발생할 때마다 실행됩니다. 이는 보통 컴포넌트가 렌더링이 되었는지 확인하기 위한 용도로 사용됩니다.

빈 배열을 넘겨준 경우

의존성 배열에 아무런 값도 넘겨주지 않고 빈 배열을 넣는다면 useEffect는 비교할 의존성이 없다고 판단하여 최초 렌더링 직후 더 이상 실행되지 않습니다.

여기서 만약 컴포넌트 렌더링이 되는 것을 확인하고 싶다고 하여 useEffect 내부에 담아둔다고 하였을 때 없이 실행해도 되지 않는가에 대한 생각이 있다. 이에대한 답변은 다음과 같다.

  • useEffect는 클라이언트 사이드에서 실행되는 것을 보장해준다. 또한 window 객체의 접근에 의존하는 코드를 사용해도 좋다.

  • useEffect는 렌더링의 부수효과, 즉 컴포넌트의 렌더링이 완료된 이후에 실행된다. 하지만 직접 실행하는 경우 렌더링 도중에 실행된다. 만약 무거운 작업의 경우 렌더링을 방해할 수 있어 악영향을 끼친다.

주의할 점

eslint-disable-line react-hooks/exhaustive-deps

해당 주석은 의존성 배열에 포함돼 있지 않은 값이 내부에서 사용될 때 경고를 제거하는 것이다.

의존성 배열을 넘기지 않은 채 콜백 함수 내부에서 특정 값을 사용하는 것은 실제로 관찰해서 실행돼야 하는 값과 별개로 작동한다는 것을 의미한다.

따라서 의존성으로 값을 넘기지 않을때에는 콜백 함수의 실행이 필요한지 되물어야한다.

거대한 useEffect를 만들지 마라

만약 부득이하게 큰 useEffect를 만들어야 한다면 적은 의존성 배열을 사용하는 여러 개의 useEffect로 분리하는 것이 좋다. 의존성 배열이 커진다면 언제 발생하는지 추적하기가 어려워진다.

왜 비동기 함수를 콜백 인수로 넣을 수 없을까?

만약 여러개의 state 값을 업데이트 하는 로직이 있다고 하였을 때 비동기 함수의 응답 속도에 따라 결과가 이상하게 나올 수 있다. 이러한 문제를 useEffect의 경쟁상태라고 한다.

그러나 인수로 비동기 함수를 넣을수 없는 것이지 함수 실행 자체가 문제가 되는 것은 아니다. 내부에서 비동기 함수를 선언해 실행하거나 즉시 실행 비동기 함수를 만들어서 사용할 수 있다.

useMemo

useMemo는 의존성 배열의 값이 변경되지 않았으면 함수를 실행하지 않고 이전에 기억해 둔 해당 값을 반환합니다. 이는 단순히 값뿐만 아니라 컴포넌트에도 적용할 수 있습니다.

하지만 컴포넌트에 useMemo를 사용하는 것보단 React.memo를 쓰는 것이 더 현명하다.

useCallback

useMemo가 값을 기억했다면, useCallback은 인수로 넘겨받은 콜백 함수를 기억합니다. 이는 특정 함수를 새로 만들지 않고 다시 사용하는 것을 말합니다.

export function Counter() { const [count, setCount] = useState(0) const handleClick = () => { setCount(prev => prev + 1) } return ( <div> <StatusBar count={count} /> <ClickButton onClick={handleClick} /> </div> ) }

위와 같은 컴포넌트가 있습니다. ClickButton 컴포넌트는 메모제이션을 했지만 자식 컴포넌트 전체가 렌더링을 하고있습니다. 이는 매번 렌더링이 될 때마다 handleClick이 다시 선언이 되면서 새롭게 전달되기 때문입니다.

const handleClick = useCallback( const handleClick = () => { setCount(prev => prev + 1) }, [count] )

위와 같이 의존성 배열을 통해 변하지 않는 함수를 받을 수 있습니다. 또한 위처럼 익명함수가 아닌 기명함수를 넘겨줄 경우 크롬 메모리 탭에서 디버깅을 하기 용이하기 때문에 함수명을 붙여줍니다.

이처럼 useMemo는 값을 메모이제이션하고, useCallback은 함수 자체를 메모이제이션합니다.

useRef

useRefuseState와 동일하게 컴포넌트 내부에서 렌더링이 일어나도 변경 가능한 상태값을 저장한다는 공통점이 있습니다. 그러나 큰 차이점이 있습니다.

  • useRef는 반환값인 객체 내부에 있는 current로 값에 접근 또는 변경할 수 있습니다.

  • useRef는 그 값이 변경하더라도 렌더링을 발생시키지 않습니다.

export function Counter() { const count = useRef(0) const handleClick = () => { count.current += 1 console.log(count.current) } return ( <div> <StatusBar count={count} /> <ClickButton onClick={handleClick} /> </div> ) }

이전에 사용했던 버튼을 클릭했을 때 값이 변하는 컴포넌트입니다. state에서 ref로 변경하였습니다. 값이 증가하는 것은 확인을 할 수 있으나 렌더링이 되지 않으니 변경되는 값도 보여지지 않습니다.

만약 위처럼 고정된 값을 관리하기 위함이라면 일반 변수를 사용하는 것은 안되는 것인지 생각을 해보면 이는 단점을 가지고 있습니다.

let value = 0; function Component(){ function handleClick(){ value += 1 } // ... }

먼저 컴포넌트가 실행되어 렌더링이 되지 않았음에도 value 라는 값이 기본적으로 존재하게 됩니다. 또한 컴포넌트가 여러개가 생성이 된다면 모든 컴포넌트들이 하나의 value로 동일하게 됩니다.

useRef는 이러한 문제점들을 모두 극복할 수 있는 리액트식 접근법이빈다. 렌더링될 때만 값이 생성되며 인스턴스 즉 컴포넌트가 여러개라도 각각 별개의 값을 바라봅니다.

가장 일반적인 것은 바로 DOM에 접근하고 싶을 때 입니다.

아래는 ref를 이용하여 받아온 값이 변경이 될 때만 ref.current의 값이 바뀌도록 설정한 함수입니다.

function useRrevious(value){ const ref = useRef(); useRef(() => { ref.current = value }, [value]) return ref.current } function Component(){ const [count, setCount] = useState(0) const previous = usePrevious(count); const handleClick = () => { setCount(prev => prev += 1) console.log(count) // 0, 1, 2, 3 console.log(previous) // undefined, 0, 1, 2 } // ... }

useContext

Context란?

리액트 애플리케이션은 부모, 자식 구조로 이루어진 트리 구조를 가지고 있기 때문에 부모가 가지고 있는 데이터를 자식에서도 사용하고 싶다면 props로 데이터를 넘겨주는 것이 일반적입니다. 하지만 만약 다음과 같이 거리가 멀 경우 코드는 복잡해집니다.

<Parent props={something}> <Parent props={something}> <Parent props={something}> <Parent props={something}> <Children props={something}/> </Parent> </Parent> </Parent> </Parent>

이러한 문제점을 props drilling이라고 합니다. 이는 제공하는 쪽이나 사용하는 쪽 모두에게 불편한 결과를 줍니다.

Content 내부에서 사용할 수 있게 만들어주는 useContext 훅

콘텍스트와 해당 콘텍스트와 사용할 수 있게 해주는 useContext는 다음과 같이 작성할 수 있습니다.

const Auth = createContext<{ isLogin: boolean } | undefined>(undefined) function Container(){ return ( <Auth.Provider value={{ isLogin: true }}> <ChildComponent /> </Auth.Provider> ) } function ChildComponent(){ const value = useContext(Auth); return <>{value.isLogin ? "로그인 된 유저" : "로그인이 안 된 유저"}</> }

만약 컴포넌트 트리가 복잡해지면 Context를 사용하는데에 있어서도 실수를 할 수 있습니다. 때문에 이러한 에러를 방지할 수도 있습니다.

const Auth = createContext<{ isLogin: boolean } | undefined>(undefined) function AuthProvider(){ return ( <Auth.Provider value={{ isLogin: true }}> <ChildComponent /> </Auth.Provider> ) } function useAuth(){ const auth = useContext(Auth); if(!auth) throw new Error('useAuth는 AuthProvider 내부에서만 사용할 수 있습니다.') return auth } function ChildComponent(){ const { isLogin } = useAuth; return <>{isLogin ? "로그인 된 유저" : "로그인이 안 된 유저"}</> }

사용시 주의할 점

함수 컴포넌트 내부에서 사용할 때는 함수 컴포넌트 재활용이 어려워진다는 점을 염두에 둬야한다. 이는 useContext를 선언할 경우 Provider에 의존성을 가지고 있는 셈이니 아무데서나 재활용하기에는 어려운 컴포넌트가 된다. 만약 중첩되어 있는 걸 인식하지 못하고 useContext를 사용할 경우 예기치 못한 작동 방식이 만들어지기도 한다. 이러한 상황을 방지하기 위해서는 컴포넌트를 최대한 작게 만들거나 재사용되지 않을 컴포넌트에서만 사용하여야 합니다.

또한 useContext는 단순히 props 값을 하위로 전달해 줄 뿐, 이를 사용한다고 해서 렌더링이 최적화되지는 않는다. 이를 방지하고 싶다면 따로 React.memo를 사용하여 최적화를 구현해야합니다.

useReducer

useReducer는 상태 관리 로직이 복잡할 때 사용하는 useState의 대안으로 볼 수 있습니다. 비슷한 형태를 가지지만 미리 정의해 놓은 시나리오를 통해 관리할 수 있습니다.

사용 방법은 아래와 같습니다.

// 지연 초기화를 위한 함수 const init = (x, y) => { return { x, y } } const initialState = { x: 0, y: 0 } function reducer(state, action) { switch(action.type) { case: 'right-move': return { x: state.x + 1 } // ... } } const [state, dispathcer] = useReducer(reducer, initialState, init) const handleClick = () => { dispathcer({ type: 'right-move' }) } return ( <> <button onClick={handleClick}>클릭</button> </> )
  • state: 현재 Reducer가 가지고 있는 값을 의미합니다.

  • dispatcher: state를 업데이트 하는 함수로 action을 통해 값을 어떻게 변경할 지 넘겨줄 수 있습니다.

  • reducer: useReducer에서 기본 action을 정의하는 함수입니다.

  • initialState: useReducer의 초기값을 의미합니다.

  • init: 초기값을 지연하여 설정하고 싶을 때 사용할 수 있는 함수입니다.

useReduceruseState 둘 다 세부 작동과 쓰임에만 차이가 있을 뿐, 클로저를 활용하여 값을 다루는데에는 변함이 없다.

useImperativeHandle

forwardRef 살펴보기

refuseRef에서 반환한 객체로 컴포넌트의 propsref에 넣어 HTMLElement에 접근하는 용도로 흔히 사용됩니다.

아래와 같이 props를 통해서 전달할 수 있습니다.

function Component({parentRef}) { useEffect(()=>{ console.log(parentRef) },[parentRef]) return <div>:)</div> } function Container() { const inputRef = useRef() return <> <input ref={inputRef} /> <Component parentRef={inputRef} /> </> }

이러한 역할을 도와주는 것이 forwardRef의 역할입니다. props로 전달할 수 있는데 탄생한 배경은 ref를 전달하는 데 있어서 일관성을 제공해주기 위해서입니다.

const Component = forwardRef((props, ref) => { useEffect(()=>{ console.log(ref) },[parentRef]) return <div>:)</div> }) function Container() { const inputRef = useRef() return <> <input ref={inputRef} /> <Component parentRef={inputRef} /> </> }

먼저 ref를 받고자 하는 컴포넌트를 forwardRef로 감싸고 두 번째 인수로 ref를 전달합니다. 그리고 부모 컴포넌트에서는 동일하게 props.ref로 전닳하면 됩니다. 그렇다면 전달받은 컴포넌트에서도 ref라는 이름을 그대로 사용할 수 있습니다.

use-ImperativeHandle이란?

부모 컴포넌트에 노출되는 값을 원하는 대로 제어할 수 있습니다.

const Input = forwardRef((props, ref) => { useImperativeHandle( ref, () => ({ alert: () => alert(props.value), }), [props.value] ) return <input ref={ref} {...props} /> }) function Container() { const inputRef = useRef() function handleClick(){ inputRef.current.alert() } return <> <input ref={inputRef} /> <button onClick={inputRef} /> </> }

자식 컴포넌트에서 설정한 값을 useImperativeHandle 훅을 이용하여 추가적인 동작을 정의하였습니다. 이로써 부모 컴포넌트에서 자식 컴포넌트가 새롭게 정의한 객체의 키와 값에 접근할 수 있게 되었습니다.

useLayoutEffect

useLayoutEffect는 화면상에 모든 렌더링이 끝나기 이전에 실행되게 하고싶은 함수를 정의할 수 있습니다. 즉 DOM은 계산됐지만 화면에 반영되기 전에 하고 싶은 작업이 있을때 사용할 수 있습니다.

다음과 같은 순서로 실행이 됩니다.

  1. 리액트가 DOM을 업데이트

  2. useLayoutEffect가 실행

  3. 브라우저에 변경 사항을 반영

  4. useEffect를 실행

useDebugValue

리액트 애플리케이션을 개발하는 과정에서 사용하는데 디버깅 하고 싶은 정보를 기록하는데 사용합니다.

function useDate(){ const date = new Date() useDebugValue(date, (date) => `현재 시간: ${date.toISOString()}`) return date }

훅의 규칙

리액트에서 제공하는 훅을 사용하기 위해서는 몇 가지 규칙이 존재합니다. 이러한 규칙을 rules-of-hooks 라고 합니다.

  1. 최상위에서만 훅을 호출해야합니다. 반복문이나 조건문, 중첩된 함수 내에서 훅을 사용할 수 없스니다.

  2. 훅을 호출할 수 있는 것은 리액트 함수 컴포넌트 훅, 사용자 정의 훅 두 가지 뿐입니다. 일반 자바스크립트 함수에서는 훅을 사용할 수 없습니다.

이와 같은 규칙을 통해 알 수 있듯이 훅은 예측 불가능한 순서로 실행되게 해서는 안 된다. 항상 실행 순서를 보장받을 수 있는 컴포넌트 최상단에 선언돼 있어야 합니다.

사용자 정의 훅과 고차 컴포넌트 중 무엇을 써야할까?

중복 코드를 피해야한다는 DRY 원칙과 같이 중복된 코드는 비효율이며 유지보수도 어렵게 만듭니다. 이러한 재사용 로직을 관리하는 방식에는 바로 사용자 정의 훅과 고차 컴포넌트(HOC)가 있습니다.

사용자 정의 훅

서로 다른 컴포넌트 내부에 같은 로직을 공유하고자 할 때 주로 사용되는 것이 바로 사용자 정의 훅입니다. 이러한 훅들은 use로 시작되어야 한다는 규칙이 있습니다.

아래는 debounce 기능을 활용하기 위해서 만드는 useDebounce 훅입니다.

const useDebounce = (callback: () => void, term: number) => { const timer = useRef<ReturnType<typeof setTimeout>>(); const dispatch = () => { if (timer.current) { clearTimeout(timer.current); } const newTimer = setTimeout(() => { callback(); }, term); timer.current = newTimer; }; return dispatch; }; export default useDebounce;

고차 컴포넌트

고차 컴포넌트는 리액트가 아니더라도 자바스크립트 환경에서 널리 쓰일 수 있습니다. 유명한 고차 컴포넌트는 바로 React.meme가 있습니다.

React.memo란?

리액트 컴포넌트가 렌더링하는 조건에는 여러 가지가 있지만 그중 하나가 바로 부모 컴포넌트가 새롭게 렌더링이 되는 것입니다. 이는 자식 컴포넌트의 props가 변경되는 것과 상관없이 실행이 됩니다.

고차 함수를 활용한 리액트 고차 컴포넌트 만들어보기

아래는 로그인 여부에 따라 다른 컴포넌트를 보여줄 수 있는 리액트 고차 컴포넌트입니다.

function withLoginComponent<T>(Children){ return function(props){ const { loginRequried, ...restProps } = props if(loginRequried) { return <>로그인이 필요합니다.</> } return <Component {...restProps} /> } } const Component = withLoginComponent((props) => { return <h3>로그인에 성공하였습니다. </h3> } function App(){ const isLogin = true return <Component loginRequired={isLogin} /> }

이처럼 고차 컴포넌트는 컴포넌트 전체를 감쌀 수 있다는 점에서 사용자 정의 훅보다 더욱 큰 영향력을 컴포넌트에 줍니다. 따라서 사용자가 더 주의할 수 있도록 with와 같이 접두사를 붙이는 것이 일종의 관심이 되었습니다.

사용자 정의 훅과 고차 컴포넌트 중 무엇을 써야 할까?

사용자 정의 훅이 필요한 경우

만약 리액트에서 제공하는 훅으로만 공통 로직을 격리할 수 있다면 사용자 정의훅을 사용하는 것이 좋습니다. 훅 자체로는 렌더링에 영향을 미치지 못하므로 컴포넌트 내부에 미치는 영향을 최소화해 개발자가 훅을 원하는 방향으로만 사용할 수 있다는 장점이 있습니다. 따라서 단순히 동일한 로직의 값을 제공하거나 특정한 훅의 작동을 취하게 하고 싶다면 사용자 정의 훅을 취하게 하는 것이 좋습니다.

고차 컴포넌트를 사용해야 하는 경우

만약 로그인을 하지 않은 사람의 경우 컴포넌트에 접근하려 할 때 애플리케이션의 관점에서 로그인을 요구하는 컴포넌트를 노출하거나 특정 에러가 있을 때 에러를 노출하는 에러바운더리와 같이 렌더링 결과물에 영향을 미쳐야 하는 경우가 있어야합니다. 이러한 경우 사용자 정의 훅보다 고차 컴포넌트를 사용해 처리하는 것이 좋습니다.

즉 렌더링의 결과물에도 영향을 미치는 공통 로직이라면 고차 컴포넌트를 사용하는 것이, 만약 로직의 재사용이나 특정 훅을 작동하게 하고싶다면 커스텀 훅을 사용하는 것이 좋습니다.

Last modified: 17 August 2024