함수 컴포넌트와 리액트 훅

Created
May 15, 2024
Tags
React

리액트 훅

리액트 16.8.0 버전에서 도입된 리액트 훅(React Hooks)은 함수형 컴포넌트에서 상태나 생명주기 함수와 같은 리액트 기능을 사용할 수 있게 해준다.

탄생 배경

이전에는 클래스 컴포넌트에서만 상태 관리나 생명주기 함수를 사용할 수 있었다. 그런데 클래스 컴포넌트는 코드가 직관적이지 않으며, 구현 방법이 복잡하고 모호하다는 문제가 있었다. 재사용성도 떨어진다.

리액트 훅은 클래스 컴포넌트가 갖는 문제들을 해결하고, 함수 컴포넌트에 다양한 기능을 구현할 수 있게 해준다.

리액트 훅 함수의 특징

  1. 함수 컴포넌트에서만 사용할 수 있다.
  2. 함수 컴포넌트에서 호출해야 하며, 커스텀 훅에서 또한 호출이 가능하다.
  3. 반복문, 조건문 혹은 중첩된 함수(스코프) 내에서 호출하면 안 된다. 반드시 함수 컴포넌트의 최상위 레벨에서만 호출해야 한다.

리액트 훅 종류

목적
컴포넌트 데이터 관리
useMemo, useCallback, useState, useReducer
컴포넌트 생명 주기 대응
useEffect, useLayoutEffect
컴포넌트 메서드 호출
useRef, useImperativeHandle
컴포넌트 간의 정보 공유
useContext

커스텀 훅

리액트의 훅 시스템을 사용하여 개발자가 자신만의 특정한 로직을 재사용할 수 있게 해주는데, 이렇게 조합한 새로운 훅 함수를 커스텀 훅(Custom Hook)이라고 한다.

useMemo와 useCallback

데이터를 캐시하는 useMemo

useMemo는 계산 비용이 많이 드는 함수의 결과를 캐싱하여 성능을 최적화하는 데 사용된다. Memo는 Momoization의 줄임말로 과거에 계산한 값을 반복해서 사용할 때, 과거에 계산한 값을 캐시해 두는 방법이다. 이는 전체 계산 속도를 높이는 코드 최적화 기법이다.

const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);
  • memoizedValue: 메모이제이션된 값이다. 이 값은 연산 결과를 저장하고, 의존성 배열의 값이 변경될 때마다 새로 계산된다.
  • 첫 번째 매개변수(콜백 함수): 계산 비용이 높은 작업을 수행하는 콜백 함수이다. 콜백함수 computeExpensiveValueab에 의존한다.
  • 두 번째 매개변수(의존성 배열): 배열에 포함된 값이 변경될 때마다 새로운 값을 계산하고 반환한다. 만약 배열이 빈 값으로 주어지면, 처음 렌더링될 때만 한 번 실행된다.

useMemo의 반환값은 메모이제이션된 값이다. 즉, 첫 번째 매개변수로 전달된 함수의 결과이다. useMemo는 이 함수를 호출하여 그 결과를 저장하고, 이후에 컴포넌트가 리렌더링될 때마다 이전에 계산된 결과를 재사용한다. 이전에 계산된 값을 기억하고, 의존성 배결의 값이 변경될 때마다 함수를 재실행하여 새로운 값을 계산한다.

콜백 함수를 캐시하는 useCallback

사용 개념은 useMemo와 같다, 다만 useMemo가 데이터를 캐시한다면 useCallback은 콜백 함수를 캐시한다는 차이가 있다.

const memoizedCallback = useCallback(() => {
  doSomething(a, b);
}, [a, b]);
  • memoizedCallback은 메모이제이션된 콜백 함수이다.
  • 첫 번째 매개변수(콜백 함수): 메모이제이션할 콜백 함수이다. 콜백함수 doSomething은 콜백 함수는 ab에 의존한다.
  • 두 번째 매개변수(의존성 배열): 배열에 있는 값이 변경될 때마다 doSomething 함수가 다시 호출되어 새로운 값이 계산된다. 만약 의존성 배열이 비어있으면 콜백 함수는 컴포넌트의 렌더링 사이클 동안에 변경되지 않는 함수가 된다.
  • 이전에 생성된 콜백 함수가 재사용되어 자식 컴포넌트의 불필요한 리렌더링을 방지한다.

useCallback은 주로 자식 컴포넌트에 props로 전달되는 콜백 함수를 최적화할 때 사용된다.

예제

import { Title } from '../components';
import { Button } from '../theme/daisyui';
import * as data from '../data';
import { useCallback, useMemo } from 'react';

export default function HighOrderCallback() {
  const onButtonClick = useCallback(
    (name: string) => () => {
      alert(`${name} button clicked!`);
    },
    []
  );

  const buttons = useMemo(
    () =>
      data
        .makeArray(3)
        .map(data.randomName)
        .map((name, index) => (
          <Button
            key={index}
            className="normal-case btn-primary btn-wide"
            onClick={onButtonClick(name)}>
            {name}
          </Button>
        )),
    [onButtonClick]
  );

  return (
    <div className="mt-4">
      <Title>Callback</Title>
      <div className="flex mt-4 justify-evenly">{buttons}</div>
    </div>
  );
}
  • useMemo
    • useMemo를 사용하여 버튼을 생성할 때마다 랜덤한 이름의 배열을 만드는 작업을 최적화한다.
    • 만약 이를 최적화하지 않는다면, 새로운 버튼을 렌더링할 때마다 매번 랜덤한 이름의 배열을 생성해야 한다.
    • 그러나, useMemo를 사용하면 이전에 생성된 배열을 저장하고, 배열이 변경될 때만 새로운 배열을 생성한다. 즉, 버튼을 다시 렌더링할 때마다 매번 새로운 배열을 생성하지 않고 이전에 생성된 배열을 재사용함으로써 성능을 최적화할 수 있다.
    • onButtonClick 함수가 변경될 때만 새로운 버튼 배열이 생성되도록 의존성 배열에 onButtonClick을 포함시켜 성능을 최적화한다.
  • useCallback
    • 각 버튼에는 onButtonClick 함수가 할당되는데, 만약 이 함수가 매 렌더링마다 새로 생성된다면, 각 버튼마다 새로운 콜백 함수가 호출되어 성능 저하가 발생할 수 있다.
    • 따라서, useCallback을 사용하여 onButtonClick 함수가 변경될 때만 새로운 함수가 생성되도록 하여 성능을 최적화한다.

useState

앞서 알아본 useMemo는 불변 상태를 캐시하지만, useState는 가변 상태를 캐시한다. 상태란 시간이 지남에 따라 변경될 수 있는 값으로 이 값을 저장하고 관리하는 데 useState가 사용된다.

const [상태, 상태를 변경하는 함수] = useState(초기값);

useEffect와 useLayoutEffect

useEffect

useEffect은 컴포넌트의 생명 주기에 특정한 작업을 수행하도록 도와준다. 이 훅은 컴포넌트가 마운트되거나 업데이트될 때마다 특정 효과를 발생시킬 수 있다.

useEffect(() => {
  // 효과를 발생시키는 작업
  return () => {
    // 효과를 정리하는 작업
  };
}, [dependencies]);
  • 첫 번째 매개변수: 컴포넌트가 렌더링될 때마다 실행될 콜백 함수이다.
  • 두 번째 매개변수(의존성 배열): 배열로써, 이 배열에 포함된 값이 변경될 때마다 효과를 다시 적용하게 된다. 만약 배열이 빈 값으로 주어지면, 컴포넌트가 마운트될 때에만 한 번 실행된다.
  • return 구문 (선택적): 효과를 정리하는 함수를 반환한다. 이 함수는 컴포넌트가 언마운트되기 전이나 업데이트되기 직전에 실행된다.

useLayoutEffect

useEffect과 사용법은 같다. 하지만 주요한 차이점이 존재하는데 useLayoutEffect의 효과가 DOM 업데이트 직전에 동기적으로 발생한다는 것이다. 이것은 useEffect와 다르게, 브라우저가 화면을 업데이트하기 전에 작업을 수행할 수 있다는 것을 의미한다. 예를 들어, DOM 업데이트 이전에 값을 읽어오고 그 값에 기반하여 DOM을 조작해야 하는 경우에 사용될 수 있다.

여기서 잠깐, 동기와 비동기 쉽게 이해하기.
  • 동기 방식은 서버에서 요청을 보냈을 때 응답이 돌아와야 다음 동작을 수행할 수 있다. 즉 A작업이 모두 진행 될때까지 B작업은 대기해야한다.
  • 비동기 방식은 반대로 요청을 보냈을 때 응답 상태와 상관없이 다음 동작을 수행 할 수 있다. 즉 A작업이 시작하면 동시에 B작업이 실행된다. A작업은 결과값이 나오는대로 출력된다.
image

useRef와 useImperativeHandle

ref 속성이란

모든 리액트 컴포넌트는 ref 속성을 제공한다. ref는 초기에는 null이었다가 마운트되는 시점에서 DOM 객체의 값이 된다. 즉, refDOM 객체의 참조이다.

쉽게 말해서, refDOM 요소나 컴포넌트 인스턴스에 접근하기 위한 방법을 제공한다. ref를 사용하면 DOM 요소를 직접 조작하거나, 함수 컴포넌트에서 클래스 컴포넌트처럼 인스턴스 변수를 사용할 수 있다.

리액트 공식 문서에 따르면 ref의 바람직한 사용은 아래와 같다. 1. 포커스, 텍스트 선택 영역, 혹은 미디어의 재생을 관리할 때 2.애니메이션을 직접적으로 실행히시킬 때 3. 서드 파티 DOM 라이브러리를 리액트와 같이 사용할 때

DOM 요소에 접근하기

import React, { useRef, useEffect } from 'react';

function MyComponent() {
  // useRef 훅을 사용하여 ref 생성
  const inputRef = useRef(null);

  useEffect(() => {
    // 컴포넌트가 마운트될 때 input 요소에 포커스 설정
    inputRef.current.focus();
  }, []); // 빈 배열은 의존성이 없으므로 컴포넌트가 처음 렌더링될 때만 실행됨

  return (
    <div>
      {/* ref 속성에 inputRef를 할당하여 ref 생성 */}
      <input ref={inputRef} type="text" />
    </div>
  );
}

위 코드를 보면 useRef를 사용해서 inpurRef라는 ref를 생성했다. 이 ref<input> 요소를 참조한다. 이후 useEffect를 사용하여 컴포넌트가 마운트될 때 <input> 요소에 포커스를 설정하도록 했다.

함수 컴포넌트에서 인스턴스 변수 사용하기

import React, { useRef } from 'react';

function MyComponent() {
  // useRef 훅을 사용하여 인스턴스 변수 생성
  const countRef = useRef(0);

  const incrementCount = () => {
    countRef.current += 1;
    console.log('Current count:', countRef.current);
  };

  return (
    <div>
      <button onClick={incrementCount}>Increment Count</button>
    </div>
  );
}

위 코드에서는 useRef를 사용해서 countRef라는 인스턴스 변수를 생성했다. 이 변수는 함수 컴포넌트의 렌더링 사이클 동안 유지된다. incrementCount 함수에서 이 변수를 수정하고 그 값을 콘솔에 출력했다.

useRef

useRef는 컴포넌트에서 DOM 요소에 접근하거나 컴포넌트의 인스턴스 변수를 생성하는 데 사용된다. 이는 일반적으로 DOM 요소에 직접 접근해야 하는 경우나 이전 값과 새 값의 차이를 비교할 때 유용하다.

const refContainer = useRef(initialValue);
console.log(refContainer.current); // 현재 참조된 값 출력

<div ref={refContainer}>
...
</div>
  • useRef를 사용하여 생성된 refContainer<div> 요소를 참조한다.
  • .current: ref 객체의 핵심 속성이다. 초기값으로 설정된 값으로 초기화된다. 위 예제에서 보면 refContainer.current 속성은 참조된 <div> 요소를 가리킨다. 이때, .current 속성을 변경하면 리액트는 컴포넌트를 리렌더링하지 않는다.
  • useRef를 통해 생성된 ref 객체는 컴포넌트의 렌더링과는 독립적으로 존재하며, 값의 변경이 발생해도 컴포넌트가 리렌더링되지 않는다.
  • useRef를 사용하여 생성된 ref 객체는 함수형 컴포넌트 내에서 보존되며, 컴포넌트의 렌더링 사이에 값을 보존할 수 있다.
  • 쉽게 말해, useRef.current 프로퍼티 속성을 통해 어느 값이든 보존할 수 있는 상자와 같은 역할이다.
  • initialValue: useRef의 초기값으로 사용할 수 있다. 이 값을 지정하지 않으면 undefined가 된다.

useImperativeHandle

useImperativeHandle은 부모 컴포넌트가 자식 컴포넌트의 인스턴스를 조작할 수 있는 특정한 메서드를 노출할 때 사용된다. 이는 보통 리팩토링이나 라이브러리로 만들어진 컴포넌트를 사용할 때 유용하다. (코드)

useImperativeHandle(ref, () => {
  return {
    exposedMethod1,
    exposedMethod2,
    // ...
  };
}, [dependencies]);
  • ref: 노출할 메서드를 가리키는 ref 객체이다.
  • 두 번째 매개변수: 노출할 메서드를 정의하는 콜백 함수이다. 이 함수는 부모 컴포넌트가 접근할 수 있는 메서드를 반환해야 한다.
  • 세 번째 매개변수: 이 배열에 포함된 값이 변경될 때마다 노출할 메서드가 재정의된다.

forwardRef

리액트에서 사용자 정의 컴포넌트가 ref를 통해 DOM 요소에 직접 접근할 수 있도록 하는 역할이다. 이름대로 부모 컴포넌트가 자식 컴포넌트에게 ref를 전달하여 자식 컴포넌트의 DOM 요소에 접근할 수 있다.

useRef와 useState의 차이점

useContext

컴포넌트 트리를 통해 데이터를 전달하는 방법을 제공하여 중첩된 컴포넌트 간에 데이터를 공유할 수 있도록 하기 위해 컨텍스트(Context)라는 매커니즘을 사용하여 전역 데이터를 효율적으로 관리할 수 있게 해주는 훅이다.

💡
Props Drilling

컴포넌트는 부모→자식 컴포넌트로 어떤 정보를 전달하려고 할 때 사용한다. 예를 들어, 부모에서부터 손자나 증손자까지 데이터를 전달해야 하는데, 중간에 있는 많은 컴포넌트들이 이 데이터를 사용하지 않는 경우에도 해당 데이터를 모든 컴포넌트를 통과시켜야 한다. 이 패턴을 Props Drilling이라고 한다.

먼저, React.createContext()를 사용하여 컨텍스트를 생성해야 한다. 컨텍스트를 사용할 컴포넌트를 Context.Provider로 감싸고, value prop에 전달할 데이터를 설정한다. useContext훅을 사용하여 해당 컨텍스트의 값을 읽거나 업데이트 한다.

import React, { createContext, useContext, useState } from 'react';

// 1. 컨텍스트 생성
const MyContext = createContext();

// 2. Provider 사용하여 값을 전달
const ParentComponent = () => {
  const [value, setValue] = useState('initialValue');

  return (
    <MyContext.Provider value={{ value, setValue }}>
      <ChildComponent />
    </MyContext.Provider>
  );
};

// 3. useContext를 사용하여 값을 읽음
const ChildComponent = () => {
  const { value, setValue } = useContext(MyContext);

  return (
    <div>
      <p>Value: {value}</p>
      <button onClick={() => setValue('updatedValue')}>Update Value</button>
    </div>
  );
};
  • ParentComponent에서 MyContext.Provider를 사용하여 값을 전달한다.
  • ChildComponent에서 useContext를 사용하여 값을 읽고 버튼을 클릭해 값을 업데이트한다.

useReducer

useState와 비슷하게 상태를 관리할 수 있는 훅인데, 좀 더 복잡한 상태 로직이 필요한 경우에 유용하게 쓰인다. useReducer는 상태와 액션을 입력으로 받아 새로운 상태를 반환하는 리듀서(reducer) 함수를 사용하여 상태를 업데이트한다.

const [state, dispatch] = useReducer(reducer, initialArg, init);
  • reducer: 상태를 어떻게 업데이트 시킬지 명시하는 리듀서 함수. 인자로 상태와 액션을 받아야 하고, 다음 상태값을 반환해야 한다.
  • initialArg: 초기값.
  • (Optional) init : 초기값을 반환하는 초기화 함수. 초기화 함수가 전달되지 않은 경우, 초기 값은 initialArg 값으로 설정된다. 그렇지 않은 경우, 초기값은 init(initialArg)를 호출한 결과로 설정된다.
  • useReducer훅은 두 값을 가지는 배열을 반환한다.
    • 현재 상태 state: 첫 번째 렌더링 시 초기 상태는 init 또는 initialArg로 설정된다. 이후 상태는 리듀서 함수에 의해 반환된 값으로 업데이트된다.
    • dispatch 함수: dispatch 함수를 호출하면 리듀서 함수가 실행되어 상태를 변경하고, 변경된 상태에 따라 컴포넌트가 리렌더링된다.

usage

import React, { useReducer } from 'react';

// 리듀서 함수
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();
  }
};

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

// 컴포넌트
const Counter = () => {
  // useReducer 훅 사용
  const [state, dispatch] = useReducer(reducer, initialState);

  // 액션 디스패치 함수 사용
  const increment = () => dispatch({ type: 'INCREMENT' });
  const decrement = () => dispatch({ type: 'DECREMENT' });

  return (
    <div>
      Count: {state.count}
      <button onClick={increment}>Increment</button>
      <button onClick={decrement}>Decrement</button>
    </div>
  );
};