본문 바로가기
📖 책 찢기/모던 리액트 Deep Dive

[모던 리액트 Deep Dive] 3장. 리액트 훅 깊게 살펴보기

by 짱돌보리 2024. 7. 2.
728x90

https://bori-note.tistory.com/32

https://bori-note.tistory.com/32

https://github.com/Study-FE-Techbook/Modern-React-Deep-Dive

 

GitHub - Study-FE-Techbook/Modern-React-Deep-Dive: 모던 리액트 딥다이브 스터디

모던 리액트 딥다이브 스터디. Contribute to Study-FE-Techbook/Modern-React-Deep-Dive development by creating an account on GitHub.

github.com

 

 

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

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

훅은 클래스 컴포넌트에서만 가능했던 리액트의 핵심 기능을 함수에서도 가능하게 만들었다.

✨useState

  • 함수컴포넌트 내부에서 상태를 정의하고, 상태를 관리할 수 있게 해주는 훅
  • 아무런 값을 넘겨주지 않으면 초깃값은 undefined
  • 매번 실행되는 함수 컴포넌트 환경에서 state 값을 유지하고 사용하기 위해서 리액트는 클로저를 활용하고 있다.
import React, { useState } from 'react';

function Counter() {
  // setCount: 클로저를 통해 이전 상태 값을 참조하고, 새로운 상태 값을 계산해 업데이트
  const [count, setCount] = useState(0);

  const handleIncrement = () => {
    setCount(prevCount => prevCount + 1);
  };

  const handleDecrement = () => {
    setCount(prevCount => prevCount - 1);
  };

  console.log('현재 카운트 값:', count); // 매번 렌더링될 때마다 현재 상태 값을 출력

  return (
    <div>
      <p>카운트: {count}</p>
      <button onClick={handleIncrement}>증가</button>
      <button onClick={handleDecrement}>감소</button>
    </div>
  );
}

export default Counter;

 

📍게으른 초기화

  • useState에 변수 대신 함수를 넘기는 것
  • 리액트 컴포넌트가 렌더링될 때마다 상태 초기화 코드를 실행하는 대신, 초기화 함수는 첫 번째 렌더링 시에만 호출된다.
  • 무거운 연산이 요구될 때 (localStorage, sessionStorage에 대한 접근 + map, filter, find 등 배열에 대한 접근, 초깃값 계산)
import React, { useState } from 'react';

// 복잡한 초기화 로직을 포함하는 함수
const computeInitialValue = () => {
  console.log('초기 값 계산 중...');
  // 여기서 시간이 많이 걸리는 작업을 수행한다고 가정하자..
  return 100;
};

function ExampleComponent() {
  // 초기 상태를 설정하는 함수를 전달합니다.
  const [value, setValue] = useState(() => computeInitialValue());

  return (
    <div>
      <p>현재 값: {value}</p>
      <button onClick={() => setValue(value + 1)}>값 증가</button>
    </div>
  );
}

export default ExampleComponent;

✨useEffect

  • 콜백, 의존성 배열을 인수로 받는다.
  • 클래스 컴포넌트의 생명주기 메서드와 비슷한 작동을 구현할 수 있다.
  • 의존성 배열에 빈 배열을 두면 컴포넌트가 마운트될 때만 실행된다.
  • 클린업 함수는 컴포넌트가 언마운트될 때 실행된다.

📍useEffect는 어떻게 의존성 배열이 변경된 것을 알고 실행될까?

  • 컴포넌트가 처음 렌더링될 때, useEffect는 실행되고 의존성 배열(dependency array) 내의 값을 기록해둔다.
  • 이후 컴포넌트가 다시 렌더링되면 리액트는 새로운 렌더링에서 의존성 배열의 값과 이전 렌더링 시 기록해둔 값을 비교한다.
  • 리액트는 얕은 비교(참조 비교)를 사용하여 의존성 배열의 각 요소를 비교한다.
  • 의존성 배열의 값 중 하나라도 이전 값과 다른 경우, 리액트는 해당 useEffect를 다시 실행한다.
  • 부수 효과가 실행될 때마다 정리(clean-up) 함수가 실행된다(정리 함수가 반환된 경우).
  • 렌더링할 때마다 의존성에 있는 값을 보면서 이 의존성의 값이 이전과 다른 게 하나라도 있으면 부수효과를 실행하는 평범한 함수다.
import React, { useState, useEffect } from 'react';

function ExampleComponent() {
  const [count, setCount] = useState(0);
  const [text, setText] = useState('');

  useEffect(() => {
    console.log('부수 효과 실행: count 또는 text가 변경됨');
    
    return () => {
      console.log('정리 작업');
    };
  }, [count, text]); // count 또는 text가 변경될 때마다 실행됨

  return (
    <div>
      <p>카운트: {count}</p>
      <button onClick={() => setCount(count + 1)}>카운트 증가</button>
      <input
        type="text"
        value={text}
        onChange={(e) => setText(e.target.value)}
      />
    </div>
  );
}

export default ExampleComponent;

 

📍useEffect를 사용할 때 주의할 점

  1. eslint-disable-line react-hooks/exhaustive-eps 주석 자제
    useEffect 인수 내부에서 사용하는 값 중 의존성 배열에 있지 않는 경우 경고를 발생시켜주는데 의도치 못한 버그발생의 원인이 될 수 있다.
  2. useEffect의 첫번째 인수에 함수명 부여
    useEffect를 사용하는 많은 코드에서 useEffect의 첫 인수로 익명 함수를 부여한다. 하지만 useEffect의 수가 많아지거나 로직이 복잡해지면 적절한 이름을 붙여주자.
useEffect(function fetchData() { // 비동기 작업 처리 }, [dependency]);

 

3. 거대한 useEffect를 만들지 말자 useEffect의 부수효과가 커질수록 성능에 악영향을 미친다. 큰 useEffect를 만들더라도 작은 useEffect들로 분리하는것이 좋다.

 

4. 불필요한 외부 함수를 만들지 말자 useEffect가 실행하는 콜백 또한 불필요하게 존재하면 안된다.

// 나쁜 예: 불필요한 외부 함수 생성
const fetchData = () => { // 비동기 작업 처리 }; 
useEffect(() => { fetchData(); }, [dependency]); 

// 좋은 예: 필요할 때만 함수 정의 
useEffect(() => { 
	const fetchData = () => { // 비동기 작업 처리 }; fetchData(); }, [dependency]);

🤔useEffect 콜백으로 비동기함수를 못넣는 이유

useEffect(() => {
  async function fetchData() {
    const result = await fetch('<https://api.example.com/data>');
    setData(result.data);
  }

  fetchData(); // 비동기 함수를 직접 호출

  // 다음 코드는 fetchData가 완료되기를 기다리지 않고 실행될 수 있음
  console.log('Fetch 데이터 설정 후 실행');

  // 콜백 내에서의 비동기 함수 호출은 정리 함수가 실행되기 전에 완료될 수 있음
  return () => {
    console.log('정리 함수');
  };
}, []);

useEffect의 인자로 비동기 함수 사용이 가능하다면 함수의 응답속도에 결과가 이상하게 나올 수 있다.. 비동기 useEffect는 state의 경쟁 상태를 야기할 수 있고 cleanup함수 실행 순서를 보장할 수 없기 때문에 만들지 않는다.

fetchData 함수를 독립적으로 정의하고, useEffect 내에서 그 함수를 호출하여 데이터 설정하기 👇🏻👇🏻

useEffect(() => {
  async function fetchData() {
    const result = await fetch('<https://api.example.com/data>');
    return result.data;
  }

  fetchData().then(data => {
    setData(data); // 데이터 설정
  });

  return () => {
    console.log('정리 함수');
  };
}, []);

✨useMemo

  • 비용이 큰 연산에 대한 결과를 저장(메모이제이션)해 두고, 이 저장된 값을 반환하는 훅
  • 생성함수, 배열을 인수로 받는다.
  • 렌더링 발생 시 의존성 배열의 값이 변경되지 않았으면 함수를 재실행하지 않고 이전에 기억해 둔 해당 값을 반환하고, 의존성 배열의 값이 변경됐다면 첫 번째 인수의 함수를 실행한 후에 그 값을 반환하고 그 값을 다시 기억해 둔다.

✨useCallback

  • 인수로 넘겨받은 콜백 자체를 기억한다.
  • 특정 함수를 새로 만들지 않고 다시 재사용한다.

🤔memo를 사용함에도 전체 자식 컴포넌트가 리렌더링 되는 예제

import React, { memo, useEffect, useState } from "react";

const ChildComponent = memo(({ name, value, onChange }: any) => {
  useEffect(() => {
    console.log("render!!!!", name);
  });

  return (
    <>
      <h1>
        {name} {value ? "켜짐" : "꺼짐"}
      </h1>
      <button onClick={onChange}> toggle</button>
    </>
  );
});

const MyComponent = () => {
  const [status1, setStatus1] = useState(false);
  const [status2, setStatus2] = useState(false);

  const toggle1 = () => {
    setStatus1(!status1);
  };

  const toggle2 = () => {
    setStatus2(!status2);
  };
  return (
    <div>
      <ChildComponent name="1" value={status1} onChange={toggle1}></ChildComponent>
      <ChildComponent name="2" value={status2} onChange={toggle2}></ChildComponent>
    </div>
  );
};

export default MyComponent;

👊🏻Props 변경 MyComponent에서 ChildComponent에게 전달하는 name, value, onChange Props는 모두 부모 컴포넌트의 상태에 의존적입니다. 따라서 status1 또는 status2 상태가 변경될 때마다 해당 ChildComponent의 모든 Props가 변경된다.

👊🏻메모이제이션된 Props memo로 ChildComponent를 메모이제이션하더라도, 함수나 객체 같은 참조 타입의 Props가 변경될 때마다 새로운 Props 객체가 생성되므로, React.memo의 최적화 효과를 제대로 볼 수 없다.

const toggle1 = useCallback(() => {
  setStatus1((prevStatus) => !prevStatus);
}, []);

const toggle2 = useCallback(() => {
  setStatus2((prevStatus) => !prevStatus);
}, []);

Props가 변경되는 경우, 새로운 Props 객체가 생성되어 메모이제이션된 ChildComponent에 새로 전달될 수 있다. useCallback으로 toggle1 함수가 setStatus1을 호출할 때마다 새로운 함수가 생성되는 것을 막아 성능을 최적화한다.

 

📍useMemo vs. useCallback

  useMemo  useCallback
목적 값 메모이징 함수 메모이징
사용 시기 성능 향상이 필요한 복잡한 연산 함수를 props로 전달할 때
예시 큰 데이터 세트에서 필터링/정렬 자식 컴포넌트에 콜백 함수 전달
import { useState, useMemo, useCallback } from 'react';

function MyComponent() {
  const [numbers, setNumbers] = useState([1, 2, 3, 4, 5]);
  const [count, setCount] = useState(0);

  // numbers 배열의 합계를 계산하고 메모이징한다.
  // numbers 배열이 변경되지 않는 한 재계산되지 않는다.
  const total = useMemo(() => {
    console.log('총 계산 중...');
    return numbers.reduce((acc, num) => acc + num, 0);
  }, [numbers]);

  // handleClick 함수를 메모이징한다.
  // count 값이 변경되지 않는 한 새로운 함수가 생성되지 않는다.
  const handleClick = useCallback(() => {
    setCount(count + 1);
  }, [count]);

  return (
    <div>
      <p>총 합계: {total}</p>
      <button onClick={handleClick}>증가</button>
      <p>카운트: {count}</p>
    </div>
  );
}

✨useRef

  • 컴포넌트 내부에서 렌더링이 일어나도 변경 가능한 상태값을 저장한다.
  • 반환값인 객체 내부에 있는 current로 값에 접근, 변경할 수 있다.
  • 값이 변하더라도 렌더링을 발생시키지 않는다.

**🤔useRef 쓰는 이유

  1. DOM 접근 및 조작**
  const inputRef = useRef(null); // useRef를 사용해 input 요소에 대한 참조 생성

  const focusInput = () => {
    inputRef.current.focus(); // 버튼 클릭 시 input 요소에 포커스를 줌
  };

  return (
    <>
      <input ref={inputRef} type="text" />
      <button onClick={focusInput}>입력란에 포커스 주기</button>
    </>
  );

2. 값의 변경 감지 및 보존

useRef는 값이 변경되더라도 컴포넌트가 다시 렌더링되지 않고 값을 유지할 수 있다.

const previousValueRef = useRef(value); // useRef를 사용해 이전 값 저장

  useEffect(() => {
    if (previousValueRef.current !== value) { // 이전 값과 현재 값 비교
      console.log('값이 변경되었습니다:', previousValueRef.current, '에서', value, '로'); // 값이 변경될 때 콘솔에 출력
      previousValueRef.current = value; // 이전 값 업데이트
    }
  }, [value]); // value가 변경될 때 useEffect가 실행되도록 설정

  return <p>현재 값: {value}</p>;

✨useContext

  • 부모 컴포넌트에서 자식 컴포넌트로 데이터를 전달할 때 사용하는 훅

❓Props Drilling
여러 컴포넌트를 거쳐 데이터를 전달하는 것 중간에 위치한 컴포넌트에서는 필요없는 props를 전달해야할 수 있어 가독성이 떨어질 수 있다. → Context 등장

<A props={something}>
	<B props={something}>
    	<C props ={something}>
        	<D props = {something}>
            </D>
        </C>
    </B>
</A>

❓Context
props 전달 없이도 선언한 하위 컴포넌트 모두에서 자유롭게 원하는 값을 사용할 수 있다.

// ThemeContext.js
// createContext로 ThemeContext를 생성하고 기본값으로 'light'를 설정
import { createContext } from 'react';

const ThemeContext = createContext('light');

export default ThemeContext;
// App.js
import React, { useState } from 'react';
import ThemeContext from './ThemeContext';
import ChildComponent from './ChildComponent';

const App = () => {
  const [theme, setTheme] = useState('light');

  return (
	  // ThemeContext.Provider를 사용하여 하위 컴포넌트에 theme 값을 제공
    <ThemeContext.Provider value={theme}>
      <div>
        <h1>Current Theme: {theme}</h1>
        <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
          Toggle Theme
        </button>
        <ChildComponent />
      </div>
    </ThemeContext.Provider>
  );
};

export default App;

// ChildComponent.js
// useContext로 ThemeContext의 값을 읽어오기
import React, { useContext } from 'react';
import ThemeContext from './ThemeContext';

const ChildComponent = () => {
  const theme = useContext(ThemeContext);

  return (
    <div>
      <p>Current Theme: {theme}</p>
    </div>
  );
};

export default ChildComponent;

 

📍usecontext를 사용할 때 주의할 점

  • useContext를 활용한 컴포넌트는 재활용이 어려워 진다.
  • 컨텍스트와 useContext는 상태관리를 위한 API가 절대 아니다. 컨텍스트는 상태를 주입해주는 API다.
    • 어떤 상태를 기반으로 다른 상태를 만들어 낼 수 있어야 한다.
    • 필요에 따라 이런 상태변화를 최적화 할 수 있어야 한다.

→ 상태관리 라이브러리를 위한 두가지 조건을 useContext는 충족시켜주지 못하고 단순히 props 값을 하위로 전달해 줄 뿐이다.

✨useReducer

  • useState의 심화 버전 (상태 관리를 위해 사용되는 훅)
  • 상태 관리가 복잡하고 여러 액션에 따라 상태를 업데이트해야 할 때 유용하다.
  • 반환값
    • state: 현재 useState가 가진 값
    • dispatcher: state를 업데이트 하는 함수
  • 3개의 인수
    • reducer : useReducer의 기본 action을 정의하는 함수
    • initialState : useReducer의 초기값
    • init : 초기값을 지연해서 생성하고 싶을 때 사용하는 함수
import React, { useReducer } from 'react';

// 초기 상태 정의
const initialState = { count: 0 };

// 리듀서 함수 정의
const reducer = (state, action) => {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 };
    case 'decrement':
      return { count: state.count - 1 };
    case 'reset':
      return initialState;
    default:
      throw new Error();
  }
};

const Counter = () => {
  // useReducer로 상태와 리듀서를 연결
  const [state, dispatch] = useReducer(reducer, initialState);

  return (
    <div>
      <p>Count: {state.count}</p>
      <button onClick={() => dispatch({ type: 'increment' })}>Increment</button>
      <button onClick={() => dispatch({ type: 'decrement' })}>Decrement</button>
      <button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
    </div>
  );
};

export default Counter;

✨useImperativeHandle

  • 부모에게서 넘겨받은 ref를 원하는 대로 수정할 수 있는 훅

❓forwardRef
리액트 컴포넌트에서 ref를 직접 전달할 수 있게 해주는 메커니즘

리액트에서 일반적으로 자식 컴포넌트에 직접적으로 ref를 전달할 수 없다.

forwardRef를 사용하면 부모 컴포넌트에서 자식 컴포넌트에 ref를 직접 전달할 수 있다. (DOM 요소에 직접 접근해야 할 때 유용)

import React, { forwardRef, useRef, useImperativeHandle } from 'react';

// forwardRef로 외부에서 ref를 직접 전달받을 수 있는 컴포넌트 생성
const FancyInput = forwardRef((props, ref) => {
  // useRef로 내부에서 관리할 변수 생성
  const inputRef = useRef();

  // useImperativeHandle을 사용하여 외부로 노출할 메서드 정의
  useImperativeHandle(ref, () => ({
    focus: () => {
      inputRef.current.focus(); // 내부 input 요소에 포커스
    }
  }));

  return <input ref={inputRef} {...props} />;
});

// App 컴포넌트에서 FancyInput을 사용합니다.
const App = () => {
  const inputRef = useRef();

  const handleClick = () => {
	  // FancyInput 컴포넌트의 focus 메서드를 호출해 포커스
    inputRef.current.focus();

  return (
    <div>
      <FancyInput ref={inputRef} placeholder="Type something..." />
      <button onClick={handleClick}>Focus Input</button>
    </div>
  );
};

export default App;

✨useLayoutEffect

  • useEffect와 동일하나 모든 DOM의 변경 후에 동기적으로 발생한다.
  • 모든 DOM의 변경 후에 useLayoutEffect의 콜백 함수 실행이 동기적으로 발생한다.
  • DOM은 계산됐지만 화면에 반영되기 전에 하고싶은 작업이 있을 때 씀. ex) 사용자가 특정 버튼을 클릭하면 페이지의 특정 위치로 스크롤을 이동하고 싶은 경우, 특정 요소의 크기나 위치를 계산하여 다른 요소의 스타일을 업데이트해야 하는 경우, DOM 변경에 따른 애니메이션을 동기적으로 실행해야 하는 경우


    1. 리액트가 DOM 업데이트
    2. useLayoutEffect 실행
    3. 브라우저에 변경 사항 반영
    4. useEffect 실행

✨useDebugValue

  • 컴포넌트의 디버깅을 돕기 위해 사용
  • 사용자 정의 훅 내부의 내용에 대한 정보를 남길 수 있는 훅
  • 두 번째 인수로 포매팅 함수를 전달하면 이에 대한 값이 변경됐을 때만 호출되어 포매팅된 값을 노출한다.
import { useDebugValue } from 'react';

function useCustomHook(value) {
  // value 값에 따라 다른 디버그 레이블을 설정
  useDebugValue(value > 10 ? 'High' : 'Low');

  // 실제 커스텀 훅의 로직
  // ...
}

✨훅의 규칙

  • 최상위에서만 훅을 호출해야 한다.
  • 훅을 호출할 수 있는 것은 리액트 함수 컴포넌트, 사용자 정의 훅 두 가지 경우 뿐.

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

✨사용자 정의 훅

  • 서로 다른 컴포넌트 내부에서 같은 로직을 공유하고자 할 때 사용됨
  • use로 시작하는 함수를 만들어야 한다.
// 외부 API 데이터 가져오기 훅
import { useState, useEffect } from 'react';

function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    const fetchData = async () => {
      try {
        const response = await fetch(url);
        const result = await response.json();
        setData(result);
        setLoading(false);
      } catch (error) {
        console.error('Error fetching data:', error);
        setLoading(false);
      }
    };

    fetchData();
  }, [url]);

  return { data, loading };
}

✨고차 컴포넌트

  • 컴포넌트 자체의 로직을 재사용하기 위한 방법
  • 사용자 정의 훅은 리액트 훅을 기반으로 해 리액트에서만 사용할 수 있지만, 고차 컴포넌트는 고차 함수의 일종으로 자바스크립트의 일급객체, 함수의 특징을 이용하므로 굳이 리액트가 아니더라도 자바스크립트 환경에서 널리 쓰인다.

대표적인 고차함수: Array.prototype.map

const list = [1, 2, 3]
const doubledList = list.map((item) => item * 2)
import React, { ComponentType } from "react";

// LoginProps 인터페이스 정의: 로그인 필요 여부를 나타내는 옵셔널 속성
interface LoginProps {
  loginRequired?: boolean;
}

// 고차 컴포넌트 함수 정의
function withLoginComponent<T>(Component: ComponentType<T>) {
  return function (props: T & LoginProps) {
    const { loginRequired, ...restProps } = props;
    
    // 로그인 필요 여부 체크
    if (loginRequired) {
      return <div>로그인이 필요합니다.</div>; // 로그인이 필요한 경우 메시지 출력
    }
    
    // 로그인이 필요하지 않은 경우 전달받은 컴포넌트 렌더링
    return <Component {...(restProps as T)}></Component>;
  };
}

// 고차 컴포넌트로 감싼 컴포넌트 정의
const Component = withLoginComponent((props: { value: string }) => {
  return <h3>{props.value}</h3>; // value 속성을 받아서 제목을 출력하는 함수형 컴포넌트
});

// MyComponent 함수형 컴포넌트 정의
const MyComponent = () => {
  const isLogin = true; // 로그인 상태를 표시하는 변수
  
  // Component 컴포넌트 렌더링, value 속성 전달 및 로그인 필요 여부 설정
  return <Component value="나는 보리" loginRequired={isLogin} />;
};

export default MyComponent;

→ withLoginComponent 고차 컴포넌트를 사용하여 여러 컴포넌트에서 로그인 필요 여부를 체크할 수 있다.

  • 고차 컴포넌트는 컴포넌트 전체를 반환하기 때문에 사용자 정의 훅보다 더 큰 영향력을 미친다.
  • with로 시작해야 한다.
  • 부수효과를 최소화하자 고차 컴포넌트는 컴포넌트를 인자로 받게 되는데 컴포넌트의 props를 임의로 수정,추가,삭제하는 일은 없어야 한다. 만약 고차컴포넌트에서 컴포넌트의 props를 임의로 변경하면 예측하지 못한 상황에서 props가 변경될 수 있고 이는 버그로 이어질 수 있다.
  • 고차 컴포넌트를 과도하게 중첩하는 경우 오히려 컴포넌트의 복잡성이 증가할 수 있다.

📍사용자 정의 훅 vs. 고차 컴포넌트

  사용자 정의 훅 고차 컴포넌트
개념 함수 컴포넌트에서 사용할 수 있는 재사용 가능한 로직을 캡슐화한 함수 다른 컴포넌트를 입력으로 받아 새로운 컴포넌트를 반환하는 함수
구현 방식 함수 컴포넌트 내부에서 다른 훅(useState, useEffect 등)을 호출하여 구현 기존 컴포넌트를 입력으로 받아 새로운 컴포넌트를 반환하는 함수로 구현
사용 방법 함수 컴포넌트 내에서 직접 호출하여 사용 기존 컴포넌트를 새로운 컴포넌트로 감싸서 사용
컴포넌트 구조 함수 컴포넌트 내부에 포함되어 있어 컴포넌트 트리에 영향을 미치지 않음 새로운 컴포넌트를 반환하므로 컴포넌트 트리에 영향을 미침
디버깅 함수 컴포넌트 내부에 있어 디버깅이 상대적으로 쉬움 새로운 컴포넌트가 생성되므로 디버깅이 상대적으로 복잡할 수 있음
  • useEffect,useState와 같이 리액트에서 제공하는 훅만으로 공통 로직을 짤 수 있다면 사용자 정의 훅을 사용하는 것이 좋다. (렌더링에 영향을 미치지 않아서)
  • 애플리케이션 관점에서 어떤 사용자가 컴포넌트에 접근할 때 무언가 해야 하는 경우에는 고차 컴포넌트가 더 유리할 수 있다.