본문 바로가기
이론/Frontend

리액트의 추가 훅 (React Additional Hooks)

by 유세지 2022. 5. 17.
이전 포스팅인 리액트의 기본 훅에서 이어집니다.

 

리액트의 추가 훅

지난번에 소개한 리액트의 기본 훅 세 가지에 더해, 리액트에서는 총 일곱 가지의 추가 훅을 제공합니다. 추가 훅은 기본 훅들에 약간의 변경을 더하거나, 특별한 경우에 사용할 수 있도록 설계되었습니다. 하나씩 알아보겠습니다.

 

 

useReducer

import { useReducer } from 'react';

const [state, dispatch] = useReducer(reducer, initialArg, init);

 

useReducer는 useState를 대체하는 훅입니다. useState가 단순히 상태를 선언하고, setState 함수를 반환해 준 것에 더해서 useReducer는 다양하고 복잡한 로직들을 dispatch를 통해 처리할 수 있도록 하는 방법을 제공합니다. 컴포넌트 트리의 많은 계층에 걸쳐 생성되어있고, 여기저기에 모두 상태와 메서드를 내려주기 힘든 상황이라면 공식 문서에서 useReducer를 사용할 것을 권장하고 있습니다.

 

먼저 useReducer의 첫 번째 인자인 reducer에 이 상태가 수행할 수 있는 작업들을 정의하여 넘겨주고, 두 번째 반환값으로 나오는 dispatch 메서드를 이용해 해당 상태가 수행할 로직을 { type: 'TYPE_NAME' } 의 객체 형태로 전달해줍니다.

 

이러한 방식은 Redux가 동작하는 것과 같은데, 이에 대해서는 추후에 다른 포스팅으로 자세히 정리하겠습니다.

 

공식 문서의 예제를 참고하여, 지난 포스트의 예시였던 클릭 프로그램을 useReducer를 이용해 살짝 수정해 몇 번 클릭이 일어났는지 확인할 수 있는 카운터로 만들어보겠습니다.

 

 

App.jsx

import React, { useReducer } from "react";
import Click from "./Click";

const initialState = { message: "not clicked", value: 0 };

function reducer(state, action) {
  switch (action.type) {
    case "click":
      return { message: "clicked", value: state.value + 1 };
    default:
      throw new Error("잘못된 type입니다.");
  }
}

export const AppContext = React.createContext(null);

export function App() {
  const [state, dispatch] = useReducer(reducer, initialState);

  return (
    <AppContext.Provider value={dispatch}>
      <div className="App">
        <Click state={state} />
      </div>
    </AppContext.Provider>
  );
}

 

Click.jsx

import React, { useContext } from "react";
import { AppContext } from "./App";

const Click = ({ state }) => {
  const dispatch = useContext(AppContext);

  const onClickButton = () => {
    dispatch({ type: "click" });
  };

  return (
    <>
      <h1>{state.message}</h1>
      <p>{state.value}</p>
      <button onClick={() => onClickButton()}>눌러주세요</button>
    </>
  );
};

export default Click;

 

최상위 컴포넌트인 App.jsx에서 Context를 선언하고, useReducer로 만든 dispatch를 value에 담아 하위 컴포넌트에 전달해줍니다. 이렇게 하면 모든 하위 컴포넌트들은 useContext를 이용해 전역에 선언된 dispatch를 사용할 수 있게 됩니다.

 

실행 결과 화면

 

 

 

useCallback

import { useCallback } from "react";

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

 

useCallback은 메모이제이션 처리된 콜백을 반환하는 훅입니다. 컴포넌트가 리렌더링되면, 컴포넌트 내부에 선언된 함수들 또한 재선언 작업이 일어납니다. 이때 가장 좋은 방법은 컴포넌트 외부에 함수를 선언하고, 그것을 불러와서 사용하면 좋겠지만 일부 상태에 직접적으로 관련되어 있다면 어쩔 수 없이 컴포넌트 내부에 선언해주어야 합니다.

 

이때, useCallback 훅을 사용하면 리렌더링 시 발생하는 이러한 이슈를 해결할 수 있습니다. 함수와 연관된 상태가 변하지 않았다면 굳이 함수를 재선언 해 줄 필요가 없다는 의미이므로, 사전에 메모이제이션 되어있던 함수를 그대로 사용할 수 있게 됩니다.

 

이러한 특성 덕분에 useCallback은 컴포넌트 단위의 최적화에 많이 사용됩니다.

 

첫 번째 인자로 메모이제이션의 대상이 될 콜백 함수를, 두 번째 인자로 해당 콜백에 영향을 줄 수 있는 상태를 배열로 넘겨주면 됩니다. 콜백의 인자로 들어가지 않더라도, 콜백 내에서 참조하는 값들은 생략하지 말고 반드시 배열에 포함시켜 주시는 것이 좋습니다.

 

 

useMemo

import { useMemo } from "react";

const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);

 

useMemo는 useCallback의 원형이라고 볼 수 있습니다. 정확히는 useCallback이 useMemo가 편의상 콜백을 반환하도록 만들어진 훅 입니다. 마찬가지로 첫 번째 인자에 기억해야 할 값을 계산하는 함수를, 두 번째 인자에는 그 함수에 필요한 값들을 배열의 형태로 넘겨주시면 됩니다.

 

 

 

useRef

import { useRef } from 'react';

const refContainer = useRef(initialValue);

 

useRef는 컴포넌트가 다시 렌더링 되더라도 유지되는 값을 담을 수 있는 ref 객체를 반환하는 훅입니다. useRef가 반환하는 객체는 current라는 속성을 갖고 있는데, 여기에 저장된 값은 컴포넌트가 unmount 되지 않는 한 계속해서 유지됩니다.

 

ref는 공식 문서에서도 특별한 경우를 제외하고서는 사용하지 않을 것을 권장하고 있습니다. 몇 가지 특별한 경우를 예시로 드는데, 그 중 가장 흔히 사용되는 케이스로는 DOM 요소를 조작하는 경우입니다. 

 

Ref를 사용해야 할 때와 남용하지 말 것을 당부하고 있습니다.

 

useRef를 권장되는 상황에 적절하게 사용하는 예시를 하나 만들어보겠습니다.

 

 

App.jsx

import { useRef } from "react";

export default function App() {
  const inputRef = useRef(null);

  const handleButton = () => {
    inputRef.current.focus();
    console.log("input을 focus 하였습니다.");
  };

  return (
    <div className="App">
      <div>
        <h1>input 요소를 focus 해봅시다.</h1>
        <button onClick={handleButton}>Focus 하기!</button>
      </div>
      <input type="text" ref={inputRef} />
    </div>
  );
}

 

버튼을 누르면 input 요소에 focus 처리가 되는 간단한 예제입니다.

 

먼저 useRef(null) 를 통해 ref 객체를 하나 만들어 준 뒤, 원하는 요소의 ref 속성에 넣어주면 바인딩이 완료됩니다. 요소에 대한 접근은 ref 객체 내부의 current 속성을 통해 해주시면 됩니다.

 

focus가 잘 작동하네요

 

 

위의 예시처럼 특정한 DOM 요소나 클래스 컴포넌트에는 ref를 지정하여 원하는대로 조작할 수 있으나, 함수형 컴포넌트에는 ref를 지정해 줄 수 없으니 주의해야합니다. DOM 요소는 자기 자신을 ref.current의 프로퍼티 값으로서 받고, 컴포넌트의 경우 마운트 된 컴포넌트의 인스턴스를 받게 됩니다. 클래스 컴포넌트와 다르게 함수형 컴포넌트의 경우 ref에 바인딩 할 인스턴스 자체가 존재하지 않기 때문에 ref 속성을 사용할 수 없기 때문입니다.

 

하지만, 굳이 함수 컴포넌트에 ref를 넘기고 싶거나 그래야만 할 때가 있습니다. 이럴때는 forwardRef 라는 함수를 이용합니다.

 

forwardRef는 두 번째 인자로 상위의 ref를 받아 자식들에게 넘겨줄 수 있습니다. 이 함수를 이용하면 기존의 방식 그대로 ref 속성에 원하는 Ref 객체를 받을 수 있게 됩니다.

 

import { forwardRef, useRef } from "react";

const InputFunctionalComponent = forwardRef((_, ref) => (
  <input type="text" ref={ref} />
));

export default function App() {
  const inputRef = useRef(null);

  const handleButton = () => {
    inputRef.current.focus();
    console.log("input을 focus 하였습니다.");
  };

  return (
    <div className="App">
      <div>
        <h1>input 요소를 focus 해봅시다.</h1>
        <button onClick={handleButton}>Focus 하기!</button>
      </div>
      <InputFunctionalComponent ref={inputRef} />
    </div>
  );
}

 

함수 컴포넌트로 넘겨도 잘 작동합니다.

 

 

useImperativeHandle

import { useImperativeHandle } from 'react';

useImperativeHandle(ref, createHandle, [deps]);

 

useImperativeHandle은 ref를 사용할 때 부모 컴포넌트에서 접근하는 인스턴스에 사용자 지정 메서드를 넣어주는 역할을 합니다. 보통 위에서 설명한 forwardRef와 함께 사용하게 됩니다. 첫 번째 인자에 대상이 되는 ref, 두 번째 인자에 선언해 줄 함수, 세 번째 인자에는 디펜던시를 배열 형태로 넣어줍니다.

 

위의 예시를 조금 더 발전시켜서, useImperativeHandle 훅을 이용해 여러 개의 input 중 비어있는 input만 포커싱 하는 기능을 구현해보겠습니다.

 

App.jsx

import { useRef } from "react";
import InputFunctionalComponent from "./InputFunctionComponent";

export default function App() {
  const inputRef = useRef(null);

  const handleButton = () => {
    inputRef.current.autoFocus();
  };

  return (
    <div className="App">
      <div>
        <h1>비어있는 input 요소를 focus 해봅시다.</h1>
        <button onClick={handleButton}>Focus 하기!</button>
      </div>
      <InputFunctionalComponent ref={inputRef} />
    </div>
  );
}

 

InputFunctionalComponent.jsx

import { useImperativeHandle, createRef, forwardRef, useMemo } from "react";

const InputFunctionalComponent = forwardRef((_, ref) => {
  const multipleInput = useMemo(
    () => Array.from({ length: 4 }).map(() => createRef()),
    []
  );

  useImperativeHandle(ref, () => ({
    autoFocus: () => {
      multipleInput.every((input) => {
        const currentInput = input.current;

        if (currentInput.value === "") {
          currentInput.focus();
          console.log("비어있는 input을 focus 하였습니다.");
          return false;
        }

        return true;
      });
    }
  }));

  return (
    <form ref={ref}>
      {Array.from({ length: 4 }).map((_, i) => (
        <input type="text" ref={multipleInput[i]} key={i} />
      ))}
    </form>
  );
});

export default InputFunctionalComponent;

 

우선 파일이 너무 길어져서 컴포넌트를 분리했습니다. 덕분에 App.jsx는 짧아졌고, autoFocus() 라는 우리가 만든 메서드만 불러와서 사용하고 있습니다. 부모 컴포넌트인 App.jsx에서는 autoFocus() 가 어떻게 구현되어 있는지 세부적인 내용은 알지 못하고, 그럴 필요도 없어졌습니다.

 

다음으로 분리되었던 자식 컴포넌트인 InputFunctionalComponent.jsx를 보겠습니다. 여기에서는 useImperativeHandle 훅을 이용해 autoFocus 메서드를 구현해주었습니다. 이 덕분에 부모 컴포넌트에서 별다른 import 없이 접근할 수 있었습니다.

 

비어있는 input에 자동적으로 포커싱 되었습니다.

 

 

useLayoutEffect

import { useLayoutEffect } from "react";

useLayoutEffect(didUpdate, [deps]);

 

useLayoutEffect은 useEffect와 똑같이 동작하고, 사용 방법도 동일하지만 동작이 일어나는 시점이 다릅니다. 정확한 시점을 알아보기 위해서는 먼저 렌더링이 어떻게 진행되는지 그 과정을 알아야 하는데, 리액트 컴포넌트의 렌더링 단계는 크게 두 가지가 있습니다.

 

1. Virtual DOM을 조작하는 Render Phase

2. 조작된 Virtual DOM을 실제 DOM에 적용하는 Commit Phase

 

이때 useEffect가 동작하는 시점은 렌더링 결과를 실제 DOM에 적용하는 Commit Phase 단계이고, useLayoutEffect는 변경사항에 따라 Virtual DOM을 조작하는 Render Phase 단계에서 동작하게 됩니다. 아래 그림을 보시면 정확한 실행 시점을 알 수 있습니다.

 

 

react hook flow diagram by hook-flow

 

이러한 실행 시점 덕분에 useLayoutEffect를 사용하면 state 값의 변경에 의한 리렌더링 중 생기는 깜빡임(flicker) 현상이 나타나지 않습니다. 이러한 깜빡임 현상을 방지할 수 있는 경우가 아니면, 대부분의 경우에서 (특히 서버 렌더링의 경우) 공식 문서에서는 useEffect를 사용할 것을 권장하고 있습니다.

 

 

 

useDebugValue

import { useDebugValue } from "react";

useDebugValue(label);

 

useDebugValue는 사용하고 있는 훅에 라벨을 붙일 수 있는 디버깅 전용 훅 입니다. label 이름을 붙여주면 이후 React dev tool 에서 라벨이 붙어있는 것을 확인할 수 있습니다. 간단히 커스텀 훅을 하나 만들어서 테스트 해보겠습니다.

 

App.jsx

import { useState, useDebugValue } from "react";

const useCustomHook = () => {
  const [isOnline, setIsOnline] = useState(true);
  useDebugValue(isOnline ? "Online" : "Offline");
};

export default function App() {
  const customHook = useCustomHook();

  return (
    <div className="App">
      <h1>Hello CodeSandbox</h1>
      <h2>Start editing to see some magic happen!</h2>
    </div>
  );
}

 

 

"Online" 라벨이 붙었다!

 

 

여러 훅들이 복잡하게 적용되어 있다면, 어떤 훅이 적용되어있는지 확인하는 용도로 사용할 수 있을 것 같습니다. 그렇다고 모든 커스텀 훅에 꼭 사용해야 할 필요는 없어 보이네요.

 

 

마무리

이렇게 두 포스트에 나누어 리액트가 기본적으로 제공하는 기본 훅 3가지와 추가 훅 7가지를 모두 살펴보았습니다. useReducer의 경우 이후에 redux를 설명할 때 함께 볼 flux 패턴을 충실히 따르는 훅이므로 잘 기억해두었다가 사용하면 좋을 것 같습니다. 틀린 내용이나 보충 할 내용은 언제든 댓글로 알려주시면 감사하겠습니다.

 

 

참고 문서

Hooks API Reference - React docs

hook-flow by donavon

반응형

댓글