Posts
리액트 Tooltip 컴포넌트 만들기

리액트 Tooltip 컴포넌트 만들기

useReducer와 컴파운드 컴포넌트 패턴으로 만든 툴팁 컴포넌트

1. 개요

React나 Vue처럼 컴포넌트 기반의 기술을 사용하면 같은 UI 컴포넌트라도 아주 다양한 방식으로 컴포넌트를 만들 수 있다. 컴포넌트 설계를 어떻게 했는지, 어떤 디자인 패턴을 적용했는지에 따라 그 사용성과 기능 확장성, 가독성 등 다양한 요소들의 차이가 발생한다.

이 글을 통해 useReducer로 복잡한 상태를 관리하고, 컴파운드 컴포넌트 패턴을 사용해서 컴포넌트를 작성하는 방법을 적어두려고 한다.

2. Tooltip 컴포넌트 기능 정의

우선 툴팁(Tooltip)의 기능 명세를 정의해보자.

  1. 어떤 버튼에 커서를 hover 시 툴팁이 보인다.
  2. 버튼을 클릭 시 툴팁이 고정된다.
  3. 버튼 밖을 클릭 시 고정된 툴팁이 닫힌다.

툴팁 컴포넌트의 기능은 어렵지 않다. 상태로 나타낸다면 closed, opened, fixed처럼 3가지 상태로 정의할 수 있고, 특정 유저 동작(이벤트)에 따라 상태가 바뀐다.

3. 상태 관리하기

useState의 한계점

별 고민 없이 useState 상태 관리 훅을 이용해서 툴팁 컴포넌트를 작성한다면 아래와 비슷하게 코드를 작성할 것이다.

import { MouseEvent } from 'react';
 
const Tooltip = () => {
  const [opend, setOpend] = useState(false);
  const [fixed, setFixed] = useState(false);
 
  const handleClick = (e: MouseEvent<HTMLButtonElement>) => {
    event.stopPropagation();
    setFixed(true);
  };
  const handleEnter = () => {
    setOpened(true);
  };
  const handleLeave = () => {
    setOpened(false);
  };
 
  useEffect(() => {
    function handleClick() {
      setFixed(false);
      setOpened(false);
    }
 
    window.addEventListener('click', handleClick);
 
    return () => {
      window.removeEventListener('click', handleClick);
    };
  }, [fixed]);
 
  return (
    <div>
      <button onMouseEnter={handleEnter} onMouseLeave={handleLeave} onClick={handleClick}>
        Button
      </button>
      {(opend || fixed) && <p>Tooltip Message</p>}
    </div>
  );
};

현재 요구 사항에 대해서는 잘 동작하는 나쁘지 않은 코드이다. 하지만 추후 요구 사항이 추가되어서 로딩 중인 상태도 추가가 된다면 관리하기가 점점 더 힘들어진다. 이처럼 useState를 사용해서 상태를 관리할 경우, 상태 변수가 많아질수록 코드가 복잡해지는 문제가 있다.

그리고 상태 변화와 관련한 로직들이 각각의 handler 함수로 분리되어 있어서 코드의 응집도가 떨어진다. 프론트엔드 코드에서 응집도가 떨어진다면 유지 보수 측면에서 단점으로 작용한다.

useReducer를 사용하면 어떻게 될까?

리액트 상태 관리 훅 useReducer

공식문서에 따르면 리액트 컴포넌트에 상태를 추가하기위해서 사용할 수 있는 2가지 Hook이 있다. 리액트 상태 관리 내장 훅 중 하나는 useState이고 다른 하나는 useReducer라는 훅이다.

useReducer를 간단하게 설명하면 아래와 같이 세 요소로 이루어져있다.

  1. reducer 함수: 상태를 업데이트하는 함수로 현재 상태와 액션(action)을 인자로 받아서 새로운 상태를 반환
  2. state: reducer 함수에서 반환된 현재 상태
  3. dispatch 함수: 액션을 발생시키는 함수. dispatch를 호출할 때 어떤 액션을 전달하면 reducer 함수가 그 액션에 맞게 상태를 변경

이 훅은 상태 변경이 복잡할 때, 상태 변경 로직들을 한 곳에서 관리할 때 유용하다고 한다. useState로 작성했던 로직을 useReducer로 바꿔보면서 그 차이점을 찾아보자.

useReducer로 툴팁 상태 관리하기

useReducer로 바꾸고 로직 부분을 useTooltip이라는 훅으로 분리하면 다음과 같다.

import { useEffect, useReducer, MouseEvent } from 'react';
 
type TooltipState = {
  opened: boolean;
  fixed: boolean;
};
 
type TooltipAction =
  | {
      type: 'open';
    }
  | {
      type: 'close';
    }
  | {
      type: 'fix';
    }
  | {
      type: 'unfix';
    };
 
const tooltipReducer = ({ opened, fixed }: TooltipState, action: TooltipAction) => {
  switch (action.type) {
    case 'open': // 버튼 위 커서 hover
      return { opened: true, fixed };
    case 'close': // 버튼에서 커서 leave
      return { opened: false, fixed };
    case 'fix': // 버튼 클릭
      return { opened, fixed: true };
    case 'unfix': // 버튼 외부 클릭
      return { opened, fixed: false };
  }
};
 
const useTootltip = () => {
  const [{ opened, fixed }, dispatch] = useReducer(tooltipReducer, {
    opened: false,
    fixed: false,
  });
 
  useEffect(() => {
    function handleClick() {
      dispatch({ type: 'unfix' });
    }
 
    window.addEventListener('click', handleClick);
 
    return () => {
      window.removeEventListener('click', handleClick);
    };
  }, [fixed]);
 
  return {
    isOpened: opened || fixed,
    handleEnter() {
      dispatch({ type: 'open' });
    },
    handleLeave() {
      dispatch({ type: 'close' });
    },
    handleClick(event: MouseEvent<HTMLButtonElement>) {
      event.stopPropagation();
      dispatch({ type: 'fix' });
    },
  };
};
 
const Tooltip = () => {
  const { isOpened, handleEnter, handleLeave, handleClick } = useTooltip();
 
  return (
    <div>
      <button onMouseEnter={handleEnter} onMouseLeave={handleLeave} onClick={handleClick}>
        Button
      </button>
      {isOpened && <p>Tooltip Message</p>}
    </div>
  );
};

useState에서 useReducer로 바꾸면서 TooltipAction 타입 등 추가적인 코드들이 필요하여 작성해야할 코드가 늘었다. 하지만 opened와 fixed라는 상태를 정의하고, 그 두 상태의 조합으로 발생 가능한 action들을 useReducer에 작성했다. useReducer에 정의된 action.type에 의해서만 상태 변경이 발생하기 때문에 fixed = true이면서 opend = false인 잘못된 상태가 되는것을 방지할 수 있다.

그리고 여러 상태를 하나의 로직으로 처리하여 코드의 응집도가 향상되었고, 복잡한 상태를 더 명시적으로 관리할 수 있게 되었다. 추가적인 요구 사항이 발생하더라도 useState에 비하면 기존 로직에 기능 확장이 간편하여 유지보수 측면에서도 득이된다.

이처럼 상황에 따라서 useReducer를 활용한 상태 관리를 고민해보는게 좋을것 같다.

4. 컴포넌트 패턴 설계

툴팁의 상태 관리는 useReducer라는 리액트 내장 훅으로 처리를 해보았다. 다음으로는 툴팁 컴포넌트 구현부가 아닌, 사용부에서의 사용성을 고민해보자.

일반적인 컴포넌트

위에서 만든 툴팁 컴포넌트를 사용하려면 보통 버튼 텍스트와 툴팁에 표시할 메세지를 프로퍼티로 받는 방식으로 작성한다.

const Tooltip = ({ buttonText: string, tooltipMessage: string }) => {
  const { isOpened, handleEnter, handleLeave, handleClick } = useTooltip();
 
  return (
    <div>
      <button onMouseEnter={handleEnter} onMouseLeave={handleLeave} onClick={handleClick}>
        {buttonText}
      </button>
      {isOpened && <p>{tooltipMessage}</p>}
    </div>
  );
};

만약 버튼 스타일이 추가되고 특정 예외 케이스가 생기면 컴포넌트는 점점 더 복잡해지고 if문이 남발하게 될 것이다. 툴팁 컴포넌트에 디자인 패턴을 적용해보면 뭐가 달라질까? 내가 이 상황에 적합하다고 생각하는 Compound Component 패턴을 적용해보자.

Compound Component

5. 최종 결과물