Lighthouse of FE biginner

[React] 사례로 살펴보는 커스텀 훅 본문

[WEB] 프론트엔드

[React] 사례로 살펴보는 커스텀 훅

[FE] Lighthouse 2024. 7. 26. 20:26

들어가며

리액트로 프론트엔드 개발을 하다보면 여러 컴포넌트에서 공통으로 사용하는 로직과 아주 동일한 코드는 아니지만 비슷한 비즈니스 로직을 여러 컴포넌트에서 사용하는 케이스가 있습니다.
단순한 순수 함수로써 리액트의 생명주기와 관련이 없는 로직이라면 유틸성 함수로 분리할 수 있지만, 해당 로직에 리액트 컴포넌트의 생명주기 혹은 리액트 API가 포함되어 있다면 일반 함수로 분리할 수 없습니다.
그 이유는 리액트의 훅(Hook)은 반드시 훅이나 컴포넌트 내부에서 사용해야 하기 때문입니다.
 

커스텀 훅(Custom Hook)이란?

커스텀 훅이란 말 그대로 리액트의 API를 사용하는 사용자 정의 훅입니다.
요즘의 리액트는 대부분 함수 컴포넌트로 개발을 합니다. 주류였던 클래스형 컴포넌트를 비주류로 내려오게 만든 업데이트가 16.8버전에 출시된 리액트의 훅 입니다. 우리가 보통 사용하는 useState, useEffect API 모두 리액트의 훅이며 함수 컴포넌트에 생명을 불어넣어준 소중한 친구들입니다.
 
커스텀 훅을 정의하려면 중요한 규칙이 있습니다. 함수명은 use의 prefix를 가져야 한다는 것 입니다.
 

커스텀 훅을 사용해야 할 케이스

커스텀 훅은 보통 컴포넌트에서 비즈니스 로직을 감추고 싶을때 사용합니다. 컴포넌트 내부에 작성된 비즈니스 로직을 도메인 (관심사) 별로 훅으로 작성한다면 코드의 중복을 줄일 수 있으며 컴포넌트에는 컴포넌트만을 위한 비즈니스 로직이 담기게 되어 가독성이 좋아집니다.
그렇다고 모든 비즈니스 로직을 추상화하여 커스텀 훅으로 분리하는 것도 좋은 케이스는 아닙니다. 자칫 잘못하면 잘못 설계된 커스텀 훅만이 늘어나게되어 유지보수가 어려워질 수 있습니다.
 
아래와 같은 상황이라면 커스텀 훅으로 분리하는 것을 한번 고민해보세요.
 
1. 여러 컴포넌트에서 중복되는 비즈니스 로직
2. 공통된 UX로 사용되는 비즈니스 로직
3. 특정 컴포넌트를 사용하는데 있어 반드시 필요한 비즈니스 로직이나 기능들을 기능 단위로 분리해야 할 경우
4. 팀의 컨벤션에 따른 훅 (예를 들면 react-query를 사용할 때 query 훅 파일)
5. 특정 훅을 사용하면서도 여러 컴포넌트에서 사용 가능해 추상화가 가능할 경우
 

여러 컴포넌트에서 중복되는 비즈니스 로직

만약 여러분이 개발하는 프로젝트에서 페이지, 컴포넌트마다 권한이 있는지 체크해야 한다고 가정합시다.
로그인을 한 시점에 서버에서 권한에 관련된 데이터를 받아서 전역 상태로 관리하고 있습니다.
이런 케이스에서는 비즈니스 로직을 커스텀 훅으로 분리해 사용하는 것이 좋습니다.

import { useEffect, useState } from "react";

type Props = {
  role: string;
};

const ROLE = ["user", "admin"];

const useCheckRole = (props: Props) => {
  const [role] = useState(ROLE);
  const [hasAuthority, setHasAuthority] = useState(false);

  useEffect(() => {
    setHasAuthority(role.includes(props.role));
  }, [props.role, role]);

  return hasAuthority;
};

 
상황을 가정한 예제를 작성해봤습니다. role state를 전역 상태로 가정해봅시다.
 
props로 해당 컴포넌트, 페이지단에서의 역할을 받아서 권한이 있는지 체크한 후 boolean값을 리턴하는 훅 입니다.
이런 비즈니스 로직을 추상화 하여 컴포넌트에서 비즈니스 로직을 캡슐화 하고 코드의 중복을 줄여 가독성 있는 컴포넌트를 제작할 수 있습니다.
 

특정 컴포넌트를 사용하는데 있어 반드시 필요한 비즈니스 로직이나 기능들을 기능 단위로 분리해야 할 경우

제목은 좀 복잡하지만 간단합니다.
 
예를 들어서 제작한 input 컴포넌트에서 value를 다루기 위해 리액트의 state를 사용해야 한다고 가정합시다.
이 경우 컴포넌트/페이지 마다 state를 선언할 수 있지만 state를 변화시키기 위해 사용하는 change 함수에서 validation을 거쳐야 한다면 많은 코드가 중복될 수 있습니다.
이런 경우 코드를 선언적으로 분리한다면 커스텀 훅으로 분리했을때의 이점을 명확하게 누릴 수 있습니다.

import { useCallback, useState } from "react";

type Props = {
  initValue?: string;
};

const useInput = (props: Props) => {
  const [value, setValue] = useState(props?.initValue ?? "");

  const onChangeValue = useCallback(
    (event: React.ChangeEvent<HTMLInputElement>) => {
      let value = event.currentTarget.value;

      if (value.includes("비즈니스 로직")) {
        value = "";
      }
      setValue(value);
    },
    []
  );

  return [value, onChangeValue];
};

 
간단한 예제를 작성해봤습니다.
 
props로 초기 값을 받아서 state를 초기화 합니다.  change 함수에서는 간단한 비즈니스 로직을 섞었고 value, onChange만을 포함하는 배열을 반환합니다.
 

기능들을 기능 단위로 분리해야 할 경우

단순한 컴포넌트가 아닌 여러 기능을 제공하는 테이블 컴포넌트를 제작한다고 가정합시다.
테이블 컴포넌트에서는 행 선택, 페이징, 검색 등의 기능을 제공합니다. 이런 값들을 컨트롤 하려면 리액트의 state가 필요하고 테이블 컴포넌트는 해당 값들을 props로 받아서 컨트롤 합니다.
 
이런 경우 기능 단위로 분리할 수 있습니다. 아래 코드들은 컴포넌트 라이브러리에서 작성한 기능 단위 커스텀 훅 입니다.

import { PaginationState } from '@tanstack/react-table';
import { Dispatch, SetStateAction, useEffect, useState } from 'react';

type Pagination = PaginationState;
type UseTablePaginationProps = {
   initialState?: Pagination;
   onChangePage?: (pagination: Pagination) => void;
};
type UseTablePaginationResult = {
   pagination: Pagination;
   setPagination: Dispatch<SetStateAction<Pagination>>;
   initializePagination: () => void;
};

const initialState: Pagination = {
   pageIndex: 0,
   pageSize: 10,
};

const useTablePagination = (props?: UseTablePaginationProps): UseTablePaginationResult => {
   const [pagination, setPagination] = useState<Pagination>(props?.initialState ?? initialState);

   useEffect(() => {
      if (props && props.onChangePage) {
         props.onChangePage(pagination);
      }
   }, [pagination]);

   return {
      pagination,
      setPagination,
      initializePagination: () => setPagination(initialState),
   };
};
import { RowSelectionState } from '@tanstack/react-table';
import { Dispatch, SetStateAction, useEffect, useState } from 'react';

type RowSelection = RowSelectionState;
type UseTableSelectionProps = {
   initialState?: RowSelection;
   onChangePage?: (selection: RowSelection) => void;
};
type UseTableSelectionResult = {
   rowSelection: RowSelection;
   initializeSelection: () => void;
   setRowSelection: Dispatch<SetStateAction<RowSelection>>;
};

const initialState: RowSelection = {};

const useTableSelection = (props?: UseTableSelectionProps): UseTableSelectionResult => {
   const [rowSelection, setRowSelection] = useState<RowSelection>(
      props?.initialState ?? initialState
   );

   useEffect(() => {
      if (props && props.onChangePage) {
         props.onChangePage(rowSelection);
      }
   }, [rowSelection]);

   return {
      rowSelection,
      setRowSelection,
      initializeSelection: () => setRowSelection(initialState),
   };
};

 
이렇게 기능을 사용하는 경우 정의된 기능 커스텀 훅을 사용하기만 하면 되기에 개발 경험이 상승하고 코드의 복잡도가 줄어들게 됩니다.
 
팀의 컨벤션에 따른 훅 (예를 들면 react-query를 사용할 때 query 훅 파일)
리액트 쿼리를 사용하다보면 비즈니스 로직이 당연하게 섞일 수 밖에 없습니다.
이런 비즈니스 로직을 추상화하여 감추고 싶을때 커스텀 훅을 사용하면 쉽게 비즈니스 로직을 캡슐화 할 수 있습니다.

import { useQuery, UseQueryOptions } from "@tanstack/react-query";
import { ApiError } from "@/common/types/api-error";
import {
  getCharacterFavorite,
  CHARACTER_FAVORITE,
  CharacterFavorite,
  FavoriteQuery,
} from "..";

export const useGetCharacterFavorite = (
  query: FavoriteQuery,
  options?: Omit<
    UseQueryOptions<CharacterFavorite[], ApiError>,
    "queryFn" | "queryKey"
  >
) => {
  return useQuery<CharacterFavorite[], ApiError>({
    queryKey: [CHARACTER_FAVORITE, query.startTime, query.endTime],
    queryFn: () => getCharacterFavorite(query),
    ...options,
  });
};

 
위 케이스가 react-query를 사용할 때 커스텀 훅을 활용하는 케이스 입니다.
조금 더 복잡한 비즈니스 로직을 작성한다면 도메인 단위로 비즈니스 로직을 분리할 수 있다는 이점이 생깁니다.
 

마치며

두 가지 케이스에 대한 예시는 보여드리지 않았습니다.
 
공통된 UX로 사용되는 비즈니스 로직 케이스는 위 예시와 어느정도 겹치는 케이스들이 있다고 생각했고, 마지막 케이스는 (특정 훅을 사용하면서도 여러 컴포넌트에서 사용 가능해 추상화가 가능할 경우) 최근 업무에서 훅을 분리할 때 왜 이런 설계를 했는지 구체적인 설명과 함께 다음 포스팅에 남겨볼 예정입니다.
 
읽어봐주셔서 감사합니다.