책정리/모던 리액트 Deep Dive

3장 리액트 훅 깊게 살펴보기

뽀글보리 2024. 5. 15. 11:49
반응형

3.1 리액트의 모든 훅 파헤치기

3.1.1 useState

함수형 컴포넌트는 매번 함수를 실행해 렌더링이 일어나기 때문에 함수 내부의 let으로 정의된 변수도 함수가 실행될 때마다 다시 초기화됩니다.

useState는 클로저로 구현되어 외부에 해당 값을 노출시키지 않으면서 함수가 실행되더라도 이전의 값을 정확하게 꺼내 쓸 수 있습니다.

 

게으른 초기화

useState 인수로 원시값을 넣지 않고 함수를 넣어줍니다. 초기값이 복잡하거나 무거운 연산을 포함하고 있을 때 사용하며, 리렌더링시에는 함수를 실행하지 않습니다. 이는 localStorage, sessionStorage 접근, 배열 순회 등이 필요할 때 사용하는 것이 좋습니다.

 

import React, { useState } from 'react';

const Counter = () => {
  // useState를 사용하여 count 상태를 정의하고 초기값 0으로 설정
  const [count, setCount] = useState(0);

  // 버튼 클릭시 count 상태를 증가시키는 함수
  const increment = () => {
    setCount(prevCount => prevCount + 1); // 이전 값에 1을 더한 값을 새로운 상태로 설정
  };

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>Increase Count</button>
    </div>
  );
};

export default Counter;
 
위 예시는 useState를 사용하여 간단한 카운터를 만드는 예시입니다. 함수형 컴포넌트 내부에서 count 상태를 정의하고, 클릭 이벤트에 따라 count를 증가시키는 기능을 구현하고 있습니다.
 

3.1.2 useEffect

useEffect란?

useEffect는 컴포넌트의 렌더링과 관련된 부수 효과를 수행하는 React 훅입니다. 클래스형 컴포넌트의 생명주기 메서드와 유사하지만 완전히 같지는 않습니다. useEffect는 렌더링 결과에 영향을 주는 작업을 수행합니다.

useEffect에 대한 오해

  1. 클래스형 컴포넌트의 생명주기 메서드와 비슷한 작동을 구현할 수 있다.
    • useEffect는 클래스형 컴포넌트의 생명주기 메서드와 유사한 작동을 할 수 있지만 완전히 같지는 않습니다.
  2. 의존성 배열에 빈 배열을 넣으면 컴포넌트가 마운트될 때만 실행된다.
    • 의존성 배열에 빈 배열을 넣으면 컴포넌트가 마운트될 때 한 번만 실행됩니다.
  3. 클린업 함수는 컴포넌트가 언마운트될 때 실행된다.
    • useEffect 내에서 반환된 함수는 해당 효과가 다음으로 실행되기 전에 실행되며, 언마운트 시에도 실행됩니다. 이것은 메모리 누수를 방지하고 리소스를 해제하는 데 사용됩니다.

클린업 함수의 목적

useEffect의 클린업 함수는 해당 효과가 사라질 때 실행되는 함수입니다. 주로 이전 효과를 정리하고 리소스를 해제하기 위해 사용됩니다.

의존성 배열

의존성 배열에 값을 전달하면 해당 값이 변경될 때만 useEffect 효과가 실행됩니다. 빈 배열을 전달하면 컴포넌트가 마운트될 때 한 번만 실행됩니다.

 

함수를 직접 실행하지 않고  useEffect를 사용하는 이유

  1. useEffect는 클라이언트 사이드에서 실행되는 것을 보장해준다.
  2. useEffect는 렌더링이 완료된 이후에 실행되므로. 직접 실행은 렌더링되는 도중에 실행되며, 서버에서도 실행되고, 렌더링을 방해하므로 성능에 영향을 줄 수 있다.

주의사항

  • useEffect 내에 부수 효과가 올바른 위치에서 실행되는지 확인하세요.
  • 클린업 함수는 리소스를 해제하고 메모리 누수를 방지하기 위해 중요합니다.
  • 함수명을 첫 번째 인수로 사용하여 코드를 읽기 쉽게 만드세요.
  • useEffect를 여러 개로 나누어 사용하면 코드를 더 명확하게 만들 수 있습니다.

eslint-disable-lint react-hooks/exhaustive deps 주석은 최대한 자제하라

정말 필요하다면 useEffect내 부수 효과가 실행될 위치가 잘못됐을 가능성이 크다.

메모이제이션을 적절히 활용해서 값의 변화를 막거나 적당한 실행 위치를 다시 한번 고민해 보는 것이 좋다.

비동기 함수와 useEffect

useEffect 내에서 직접적으로 비동기 함수를 실행할 때 주의해야 합니다. 비동기 함수는 렌더링 사이클과 동기화되지 않을 수 있으며, race condition이 발생할 수 있습니다. 따라서 비동기 함수를 사용할 때는 적절한 클린업 함수를 제공하여 이전 요청을 취소하거나 관리해야 합니다.

3.1.3 useMemo

useMemo는 비용이 큰 연산 결과를 저장하고 저장된 값을 반환하는 훅입니다. 렌더링 시 의존성 배열의 값이 변경되지 않으면 이전 값을 반환하고, 변경되었다면 함수를 실행하고 그 값을 다시 기억합니다.

3.1.4 useCallback

useCallback은 함수를 새로 만들지 않고 다시 재사용합니다. 만약 의존성 배열에 함수가 있다면 해당 함수가 변경될 때마다 새로 생성됩니다. 함수의 재생성을 막아 불필요한 리소스를 줄이고 리렌더링을 방지하고 싶을 때 useCallback을 사용합니다.

 

useCallback은 내부적으로 useMemo를 사용해서 구현되므로, 둘 다 동일한 역할을 합니다.

 

useMemo와 useCallback의 차이점

  • 기능: useMemo는 값을 캐싱하고 반환하는 반면, useCallback은 함수를 캐싱하고 반환합니다.
  • 사용 목적: useMemo는 연산 결과를 재사용하고 성능을 향상시키기 위해 사용되며, useCallback은 함수의 재생성을 막아 불필요한 리소스를 줄이고 리렌더링을 방지하기 위해 사용됩니다.
  • 구현: useCallback은 useMemo를 사용하여 구현됩니다. 따라서 두 훅은 동일한 역할을 합니다. 다만, useMemo는 값을 캐싱하고 반환하는 데에 중점을 두고, useCallback은 함수를 캐싱하고 반환하는 데에 중점을 둡니다.
const memoizedValue = useMemo(() => expensiveOperation(a, b), [a, b]);

const memoizedCallback = useCallback(() => {
  doSomething(a, b);
}, [a, b]);

 

useMemo는 값을, useCallback은 함수를 메모이제이션하여 성능을 향상시키고 불필요한 재렌더링을 방지합니다. 두 훅은 비슷한 역할을 하지만 사용하는 대상이 값인지 함수인지에 따라 선택하여 사용해야 합니다.

3.1.5 useRef

useRef는 변경 가능한 상태값을 저장하는 데 사용되며, current 속성을 통해 해당 값을 읽거나 변경할 수 있습니다. 값이 변하더라도 렌더링을 발생시키지 않는 점에서 useState와 차이가 있습니다.

주요 특징

  • 값 저장: useRef를 사용하면 변경 가능한 상태값을 저장할 수 있습니다.
  • 렌더링과 관계 없음: 값이 변경되어도 컴포넌트를 다시 렌더링하지 않습니다. 따라서 상태값을 변경하더라도 컴포넌트가 다시 그려지지 않습니다.
  • 컴포넌트 인스턴스 간 독립성: 여러 개의 컴포넌트 인스턴스가 있더라도 useRef를 사용하면 각각의 컴포넌트 인스턴스가 별개의 값을 바라보게 됩니다.

구현

useRef를 사용하면서 렌더링을 발생시키지 않으려면 빈 배열을 의존성 배열로 선언하여 useMemo와 함께 사용할 수 있습니다.

const myRef = useMemo(() => useRef(initialValue), []);

3.1.6 useContext

Context란?

보통 데이터를 자식으로 넘겨주고 싶다면 props를 사용합니다. 하지만 컴포넌트 A에서 하위 컴포넌트 D까지 전달하기 위해서는 번거로운 props drilling이 발생합니다. 이런 경우에 Context를 사용하면 하위 컴포넌트에서 props 전달 없이도 값 사용이 가능합니다.

 

이때, useContext 훅을 사용하면 상위에 선언된 Context.Provider의 값을 사용할 수 있습니다.

 

주의할 점

  • useContext가 선언되어 있으면 Provider에 의존성을 가지므로 아무데서나 재활용하기에는 어려운 컴포넌트가 됩니다.
  • 모든 콘텍스트를 최상위 루트 컴포넌트에 넣는 것은 콘텍스트가 많아질수록 불필요하게 리소스를 낭비할 수 있습니다. 따라서 컨텍스트 범위는 최대한 좁게 유지해야 합니다.

컨텍스트는 상태를 주입해 주는 API가 아닙니다.

  • 상태 관리 라이브러리가 아니기 때문에 useContext를 사용하는 parent부터 하위 컴포넌트까지 모두 리렌더링됩니다.
// ThemeContext.js
import React from 'react';

const ThemeContext = React.createContext('light');

export default ThemeContext;

// App.js
import React from 'react';
import ThemeContext from './ThemeContext';
import ChildComponent from './ChildComponent';

const App = () => {
  return (
    <ThemeContext.Provider value="dark">
      <ChildComponent />
    </ThemeContext.Provider>
  );
};

export default App;

// ChildComponent.js
import React, { useContext } from 'react';
import ThemeContext from './ThemeContext';

const ChildComponent = () => {
  const theme = useContext(ThemeContext);
  return <div>Current theme: {theme}</div>;
};

export default ChildComponent;

 

useContext를 사용하면 전역적으로 상태 값을 사용할 수 있습니다. 그러나 모든 컨텍스트를 최상위 루트 컴포넌트에 넣는 것은 성능상의 이유로 권장되지 않습니다. 따라서 컨텍스트의 범위는 최대한 좁게 유지하는 것이 좋습니다.

3.1.7 useReducer

useState의 심화 버전

  • state: 상태값을 가집니다.
  • dispatcher: state를 변경하는 action을 넘겨줍니다.
    • 사전에 정의된 dispatcher로만 수정할 수 있게 만들어줍니다.
    • state를 사용하는 로직과 이를 관리하는 비즈니스 로직을 분리할 수 있습니다.
import React, { useReducer } from 'react';

// reducer function
const reducer = (state, action) => {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 };
    case 'decrement':
      return { count: state.count - 1 };
    default:
      throw new Error();
  }
};

// Component using useReducer
const Counter = () => {
  const [state, dispatch] = useReducer(reducer, { count: 0 });

  return (
    <div>
      Count: {state.count}
      <button onClick={() => dispatch({ type: 'increment' })}>+</button>
      <button onClick={() => dispatch({ type: 'decrement' })}>-</button>
    </div>
  );
};

export default Counter;

 

useReducer와 useState 둘 다 클로저를 활용해 값을 가둬서 state를 관리합니다. useState와의 차이점은 reducer 함수를 통해 상태를 변경하며, 이를 통해 상태와 액션을 관리하는 비즈니스 로직을 분리할 수 있습니다. 이를 통해 컴포넌트의 유지보수성과 가독성을 향상시킬 수 있습니다.

3.1.8 useImperativeHandle

forwardRef 살펴보기

ref는 HTMLElement에 접근하는 용도로 사용됩니다. 그러나 ref를 props로 전달하면 warning이 발생합니다. ref를 전달하는 데 있어서 일관성을 제공하기 위해서 forwardRef를 사용하는 것이 좋습니다.

 

useImperativeHandle이란?

부모에게서 받은 ref를 원하는 대로 수정할 수 있는 훅입니다.

ref에 원하는 값이나 액션을 추가로 정의할 수 있습니다.

3.1.9 useLayoutEffect

공식 문서에 의하면, useEffect와 동일하게 작동하지만, 모든 DOM의 변경 후에 동기적으로 발생합니다. DOM 변경이란 렌더링이 아니라 브라우저에 해당 변경 사항이 반영되는 시점을 의미합니다.

 

동기적이란?: useLayoutEffect는 화면을 그리기 전에 실행되며, 리액트는 이 실행이 완료될 때까지 기다렸다가 화면을 그립니다.

 

차이점:

  • useLayoutEffect는 useEffect보다 항상 먼저 실행됩니다.
  • useLayoutEffect는 브라우저 변경 사항이 반영되기 전에 실행되고, useEffect는 변경 사항이 반영된 이후에 실행됩니다.

언제 쓰는 게 좋을까?: DOM은 계산됐지만 이것이 화면에 반영되기 전에 하고 싶은 작업이 있을 때 사용합니다. 예를 들어, DOM 요소를 기반으로 한 애니메이션, 스크롤 위치 제어 등을 수행할 때 유용합니다.

3.1.10 useDebugValue

useDebugValue는 리액트 애플리케이션을 개발하는 과정에서 사용됩니다. 주로 다른 훅의 내부에서 실행되며, 개발자 도구에서 훅의 값을 쉽게 확인할 수 있도록 도와줍니다. 공통 훅을 제공하는 라이브러리나 대규모 웹 애플리케이션에서 디버깅 관련 정보를 제공하고 싶을 때 유용합니다.

3.1.11 훅의 규칙

  1. 최상위에서만 훅을 호출해야 한다.
    • 반복문이나 조건문, 중첩된 함수 내에서 훅을 실행할 수 없습니다. 이 규칙을 따라야만 컴포넌트가 렌더링될 때마다 항상 동일한 순서로 훅이 호출되는 것을 보장할 수 있습니다.
  2. 훅을 호출할 수 있는 것은 리액트 함수형 컴포넌트 또는 사용자 정의 훅 뿐이어야 한다.
    • 일반 자바스크립트 함수 내에서는 훅을 사용할 수 없습니다.


훅에 대한 정보 저장

  • 리액트 내 객체 기반 링크드 리스트 형식으로 구현되어 있습니다.
    • 이 링크드 리스트는 리액트 내부에서 훅 호출의 순서를 기록합니다.
    • 각 훅 호출은 이전 훅 호출의 정보를 가리키는 링크를 가지고 있습니다.
  • 리액트 훅은 파이버 객체의 링크드 리스트의 호출 순서에 따라 저장됩니다.
    • 이 링크드 리스트는 리액트의 재조정 파이버(fiber)에 의해 관리됩니다.
    • 훅 호출 순서는 파이버 트리의 각 노드에 저장되어 있습니다.

만약 위 규칙을 지키지 않으면 링크드 리스트의 순서가 깨져버리고, 에러를 발생시킨다.

따라서 예층 불가능한 순서로 실행되게 해서는 안된다. 조건문은 훅 내부에서 수행되어야 한다.

3.1.12 정리

훅은 렌더링에 많은 영향을 미치므로, 성능을 신경쓰려면 훅에 대해 정확히 이해해야 한다.

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

리액트 애플리케이션을 개발할 때 사용자 정의 훅과 고차 컴포넌트를 선택해야 할 때가 있습니다. 각각의 장단점과 어떤 경우에 사용하는 것이 적합한지 살펴봅시다.

3.2.1 사용자 정의 훅

  • 사용 사례:
    • 서로 다른 컴포넌트에서 같은 로직을 공유하고자 할 때
    • 특정 로직을 자주 사용하는 경우에 사용자 정의 훅을 만들어 재사용성을 높일 수 있습니다.
    • use로 시작하는 이름을 사용해야 합니다.
function useLogin() {
  const [loggedIn, setLoggedIn] = useState(false);

  const login = () => {
    // 로그인 로직
    setLoggedIn(true);
  };

  return { loggedIn, login };
}

3.2.2 고차 컴포넌트

고차 컴포넌트(Higher-Order Component, HOC)는 컴포넌트 자체의 로직을 재사용하기 위한 방법으로, 리액트가 아니더라도 자바스크립트 환경에서 사용할 수 있습니다.

 

React.memo

 

리액트에서는 부모 컴포넌트가 새롭게 렌더링될 때, 자식 컴포넌트의 props 변경과 상관없이 리렌더링됩니다. memo를 사용하면 이전 props와 같다면 렌더링을 생략하고 이전에 렌더링된 컴포넌트를 재사용합니다.

const ChildComponent = memo((({ value }: { value: string }) => {
  return <>안녕하세요!{value}</>
}

const ChildComponent = useMemo(() => {
  return <>안녕하세요!{value}</>
}, [])

 

 

고차 함수 만들어보기

고차 함수는 함수를 인수로 받거나 결과로 반환하는 함수입니다. 예를 들어 배열의 map, forEach, setState 함수가 그 예입니다.

const doubledList = list.map((item) => item * 2))
function add(a) {
  return function (b) {
    return a + b
  }
}

// add를 호출하는 시점에 1이라는 정보가 클로저에 기억된다. 따라서 result2를 호출할 때 결과를 활용 가능하다.
const result = add(1)
const result2 = result(2)

 

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

function withLoginComponent<T>(Component: ComponentType<T>) {
  return function (props: T & LoginProps) {
    const { loginRequired, ...restProps } = props;
    
    if (loginRequired) {
      return <>로그인이 필요합니다.</>
    }
    
  return <Component {...(restProps as T)} />
}
  • with로 시작하는 이름을 사용해야 합니다. 이는 리액트 커뮤니티에서 널리 사용되는 관습입니다.
  • 부수 효과를 최소화해야 합니다. props를 임의로 수정, 추가, 삭제하는 일은 없어야 합니다.
  • 여러 개의 고차 컴포넌트로 감쌀 경우 복잡성이 커집니다. 따라서 최대한 사용을 줄이는 것이 좋습니다.

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

  • 사용자 정의 훅 사용 시:
    • 리액트 훅을 기반으로 자체적인 로직을 만들어야 할 때 사용합니다.
    • 부수 효과가 적고 간단한 로직을 재사용하고자 할 때 적합합니다.
  • 고차 컴포넌트 사용 시:
    • 컴포넌트 자체의 로직을 재사용하거나, 렌더링 결과물에 영향을 주는 경우 사용합니다.
    • 컴포넌트를 감싸는 형태로 사용되며, 로직을 더 투명하게 재사용할 수 있습니다.

⇒ 사용자 정의 훅과 고차 컴포넌트 모두 공통화해 별도로 관리할 수 있다. 따라서 컴포넌트 크기를 줄이고 가독성을 향상시킨다.

 

useLogin vs withLoginComponent

리액트에서 제공하는 훅으로만 공통 로직을 격리할 수 있다면 사용자 정의 훅을 사용하는 것이 좋습니다. 렌더링에 영향을 미치지 않으므로 개발자가 로직을 자유롭게 구성할 수 있습니다. 따라서 부수 효과가 비교적 제한적입니다.

  • 고차 컴포넌트를 사용하기 좋은 예시
    • 로그인되지 않은 사용자가 컴포넌트에 접근하려고 할 때 로그인을 요구하는 공통 컴포넌트를 노출해야 할 때
    • 에러 바운더리와 같이 에러가 발생했을 때 공통 에러 컴포넌트를 노출해야 할 때

 

반응형