본문 바로가기
Front-End/React

[React] 리엑트 디자인 패턴: Return Component From Hooks

by SeanK 2022. 6. 30.

Image from Envato Elements (License Code: W2Q8UYJCM6)

React Design Patterns: Return Component From Hooks

"Partical Application"으로 부터 영감을 받은 새로운 형식의 패턴: Material-UI와 타입스크립트를 이용한 예제와 함께
 
훅이 리엑트 커뮤니티에 소개된 지 수년이 흘렀고, 이로 인해 코딩 패턴에 많은 변화가 생겼다. 이번 글에서는, 'partial application'에서 영감을 받은 새로운 패턴에 대해 공유해보고자 한다. 필자는 이를 'Return Component From Hooks'라고 부른다. 
 
간단한 예제로 시작해보자:
  • 팝업 메뉴를 여는 버튼을 추가하자
  • 메뉴를 열고 닫는 상태 관리
  • 메뉴 아이템 클릭 핸들러
import React from "react";
import Button from "@mui/material/Button";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";

const ExampleA: React.FC = () => {
  const [anchorEl, setAnchorEl] = React.useState<HTMLElement | null>(null);

  const openMenu = React.useCallback((event: React.MouseEvent<HTMLElement>) => {
    event.stopPropagation();
    setAnchorEl(event.currentTarget);
  }, []);

  const closeMenu = React.useCallback((event: React.MouseEvent<HTMLElement>) => {
    event.stopPropagation();
    setAnchorEl(null);
  }, []);

  return (
    <React.Fragment>
      <Button onClick={openMenu} variant="contained">
        Example A
      </Button>

      <Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={closeMenu}>
        <MenuItem onClick={closeMenu}>Option A</MenuItem>
        <MenuItem onClick={closeMenu}>Option B</MenuItem>
      </Menu>
    </React.Fragment>
  );
};

export default ExampleA;

한 곳에서만 팝업 메뉴가 필요하다면 위 코드는 괜찮아 보인다. 하지만 보통은 여러 곳에서 많은 메뉴가 필요하다. 이를 위해선, 보일러 플레이트 코드를 여러 곳에 복사해야 하는데 이는 매우 재미없는 작업이다. 


First iteration: Share logic with hooks

공통된 로직을 추출하는 작업은 명확해야 함으로 이를 useMenu라고 부르자:

import React from "react";
import { MenuProps } from "@mui/material/Menu";

export const useMenu = () => {
  const [anchorEl, setAnchorEl] = React.useState<HTMLElement | null>(null);

  const openMenu = React.useCallback((event: React.MouseEvent<HTMLElement>) => {
    event.stopPropagation();
    setAnchorEl(event.currentTarget);
  }, []);

  const closeMenu = React.useCallback((event: React.MouseEvent<HTMLElement>) => {
    event.stopPropagation();
    setAnchorEl(null);
  }, []);

  const menuProps = React.useMemo((): MenuProps => {
    return {
      anchorEl,
      open: Boolean(anchorEl),
      onClose: closeMenu
    };
  }, [anchorEl, closeMenu]);

  return {
    openMenu,
    closeMenu,
    menuProps
  };
};

이 훅은 반복되는 공통 부분을 은닉화하고 있다:

  • <Menu /> 컴포넌트에 전달되어야 하는 props: menuProps (anchorEl, open, onClose)
  • 클릭을 핸들과 상호작용할 엘리먼트에 전달되어야 하는 콜백: openMenu, closeMenu

위를 토대로 위 예제를 아래와 같이 고쳐볼 수 있다:

import React from "react";
import Button from "@mui/material/Button";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import { useMenu } from "./useMenu";

const ExampleB: React.FC = () => {
  const { menuProps, openMenu, closeMenu } = useMenu();

  return (
    <React.Fragment>
      <Button onClick={openMenu} variant="contained">
        Example B
      </Button>

      <Menu {...menuProps}>
        <MenuItem onClick={closeMenu}>Option A</MenuItem>
        <MenuItem onClick={closeMenu}>Option B</MenuItem>
      </Menu>
    </React.Fragment>
  );
};

export default ExampleB;

훅에서 반환된 공통된 props를 Menu 컴포넌트에 스프레딩 하고 있는 점에 주목해야 한다:

<Menu {...menuProps}>

이런 패턴은 이미 많은 훅 기반 라이브러리 예를 들어 react-table과 react-hook-form에서 사용되어 왔다. 

 

이것만으로도 좋아 보이지 않는가? 하지만 좀더 깊게 들어갈 필요가 있을 것 같다. menuProps를 이미 가지고 있는 <Menu /> 컴포넌트를 useMenu 훅을 통해 리턴하는 것은 어떨까?


Second iteration: Return component from hooks

바운딩된 props를 가진 컴포넌트를 리턴한다는 발상 자체는 사실 함수형 프로그래밍 패러다임인 partial application으로부터 왔다. 

 

세 개의 숫자를 더할 수 있는 add라는 함수가 있다고 가정해 보자:

const add = (x, y, z) => x + y + z;add(1, 2, 3); // x=1,y=2,z=3

위 함수를 아래와 같이 x를 우선적으로 바운드해 y와 z를 추가적으로 받는 새로운 함수를 생성하도록 변형할 수 있다. 

const addX = (x) => (y, z) => x + y + z;const addOne = addX(1); // bound x=1
addOne(2, 3); // x=1,y=2,z=3
addOne(3, 4); // x=1,y=3,z=4

리엑트 컴포넌트의 props는 사실 함수의 아규먼트가 아니지만, 생각하는 방식 및 패턴은 동일하다 - 공통된 props 부분을 묶어 버리자.

 

간단한 방법으로는 useMenu안에 <Menu /> 컴포넌트를 생성하고 <Menu /> 컴포넌트가 공통된 props에 다이내믹하게 이전에 설정한 menuProps(anchorEl, open, onClose) 값과 묶이도록 하는 것이다:

import React from "react";
import { MenuProps } from "@mui/material/Menu";
import MuiMenu from "@mui/material/Menu";

export const useMenu = () => {
  const [anchorEl, setAnchorEl] = React.useState<HTMLElement | null>(null);

  const openMenu = React.useCallback((event: React.MouseEvent<HTMLElement>) => {
    event.stopPropagation();
    setAnchorEl(event.currentTarget);
  }, []);

  const closeMenu = React.useCallback((event: React.MouseEvent<HTMLElement>) => {
    event.stopPropagation();
    setAnchorEl(null);
  }, []);

  const Menu = React.useMemo(() => {
    const MenuComponent: React.FC<Omit<MenuProps, "open" | "anchorEl" | "onClose">> = ({ children, ...props }) => {
      return (
        <MuiMenu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={closeMenu} {...props}>
          {children}
        </MuiMenu>
      );
    };
    return MenuComponent;
  }, [anchorEl, closeMenu]);

  return {
    Menu,
    openMenu,
    closeMenu
  };
};

따라서 새로운 예제에서는, menuProps를 더이상 스프레딩 할 필요가 없어진다. 

import React from "react";
import Button from "@mui/material/Button";
import MenuItem from "@mui/material/MenuItem";
import { useMenu } from "./useMenu";

const ExampleC: React.FC = () => {
  const { Menu, openMenu, closeMenu } = useMenu();

  return (
    <React.Fragment>
      <Button onClick={openMenu} variant="contained">
        Example C
      </Button>

      <Menu>
        <MenuItem onClick={closeMenu}>Option A</MenuItem>
        <MenuItem onClick={closeMenu}>Option B</MenuItem>
      </Menu>
    </React.Fragment>
  );
};

export default ExampleC;

지엽적으로는 이 방식이 통한다. 

 

하지만 실제로 코드를 실행해보면, 메뉴를 닫을 때 페이드 아웃 애니메이션이 사라진 것을 확인할 수 있다. 무슨 일이 발생한 것일까?

 

이를 알아보기 위해, Menu 컴포넌트를 MUI로 바꾸어서 로그를 확인해 보자. 

import React from "react";
import MuiMenu, { MenuProps } from "@mui/material/Menu";

// Wrapped Menu component to add logs
const Menu: React.FC<MenuProps> = React.forwardRef((props, ref) => {
  React.useEffect(() => {
    console.log("MuiMenu::mounnt");
    return () => {
      console.log("MuiMenu::unmounnt");
    };
  }, []);

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

export default Menu;

@mui/material/Menu를 ./Menu로 import 선언을 바꾸면, 기능은 유지된 채 마운트와 언마운트 로그를 확인할 수 있게 된다. 

 

근데 메뉴가 열고 닫힐 때 마다, 콘솔에선 Menu가 unmounted 되었다가 remounted 된다. 왜일까?

 

공식 리엑트 문서에서는 이렇게 말한다:

루트 엘리먼트가 다른 타입을 가지게 될 때마다, 리엑트는 이전의 트리를 허물고 새 트리를 빌드한다. 

즉, Menu 컴포넌트를 useMemo 훅으로부터 반환할 때, 의존 배열이 변할 때마다 새로운 컴포넌트를 반환하게 되고 이전과 type이 다르기 때문에 리엑트는 새로운 컴포넌트 트리를 만들게 되는 것이다. 

 

완벽하진 않지만 일단은 동작함으로 불평없이 바운드된 props를 가진 컴포넌트를 훅으로부터 반환하는 방식을 사용할 수 있을 듯하다. 


Third iteration: Return statically defined component from hooks

정적으로 정의된 컴포넌트를 props 바운드를 유지한채 훅으로부터 반환하는 것이 가능할까? 도전해 보자. 

 

동적인 값을 전달할 방법을 생각해보면, props가 유일한 방법인 것은 아니다. Context도 있다. 값을 props가 아닌 Context와 바인드 한 컴포넌트를 생성한다면 어떻게 될까?

 

우선은 Context와 Provider를 먼저 생성해보자:

import { MenuProps } from "@mui/material/Menu";
import React from "react";

type ContextValue = { menuProps: MenuProps; setAnchorEl: (el: HTMLElement | null) => void };

export const MenuContext = React.createContext<ContextValue>({
  menuProps: {
    open: false,
    anchorEl: null,
    onClose: () => {
      console.log("MenuContext::menuProps.onClose");
    }
  },
  setAnchorEl: () => {
    console.log("MenuContext::setAnchorEl");
  }
});
import React from "react";
import { MenuContext } from "./MenuContext";

export const MenuContextProvider: React.FC = ({ children }) => {
  const [anchorEl, setAnchorEl] = React.useState<HTMLElement | null>(null);

  return (
    <MenuContext.Provider
      value={{
        menuProps: {
          anchorEl,
          open: Boolean(anchorEl),
          onClose: () => setAnchorEl(null)
        },
        setAnchorEl: (el: HTMLElement | null) => {
          setAnchorEl(el);
        }
      }}
    >
      {children}
    </MenuContext.Provider>
  );
};

그러면 이제 MenuContext를 이용해 컴포넌트를 생성하고 useMenu 훅을 이용해 리턴할 수 있다. 

import React from "react";
import { MenuProps } from "@mui/material/Menu";
import { MenuContext } from "./MenuContext";
import MuiMenu from "../Menu";

const Menu: React.FC<Omit<MenuProps, "open" | "anchorEl" | "onClose">> = (props) => {
  const { menuProps } = React.useContext(MenuContext);

  return <MuiMenu {...menuProps} {...props} />;
};

export const useMenu = () => {
  const { setAnchorEl } = React.useContext(MenuContext);

  const openMenu = React.useCallback(
    (event: React.MouseEvent<HTMLElement>) => {
      event.stopPropagation();
      const target = event.currentTarget;
      setAnchorEl(target);
    },
    [setAnchorEl]
  );

  const closeMenu = React.useCallback(
    (event: React.MouseEvent<HTMLElement>) => {
      event.stopPropagation();
      setAnchorEl(null);
    },
    [setAnchorEl]
  );

  return {
    Menu,
    openMenu,
    closeMenu
  };
};

위 코드에 따르면:

  • Menu 컴포넌트는 정적으로 정의된 useMenu 훅을 반환한다. 
  • menuProps는 MenuContext 관리하에 묶인다. 
  • 산하의 MuiMenu 컴포넌트는 menuProps에 부분적으로 묶인 props를 가진다. 

따라서, 이러한 종류의 useMenu 훅을 사용하면, menuProps를 일일이 전달 할 필요가 없고 컴포넌트 트리안의 Menu 컴포넌트가 바운드된 props가 변하지 않음으로 type 변경이 일어나지 않아 마운트-리마운트 동작도 발생하지 않을 것이다. 

import React from "react";
import Button from "@mui/material/Button";
import MenuItem from "@mui/material/MenuItem";
import { useMenu } from "./useMenu";

const ExampleD = () => {
  const { Menu, openMenu, closeMenu } = useMenu();

  return (
    <React.Fragment>
      <Button onClick={openMenu} variant="contained">
        Example D
      </Button>

      <Menu>
        <MenuItem onClick={closeMenu}>Option A</MenuItem>
        <MenuItem onClick={closeMenu}>Option B</MenuItem>
      </Menu>
    </React.Fragment>
  );
};

export default ExampleD;

하지만 Context에 의존하고 있음으로, Provider를 상위 컴포넌트 트리에 넣는 것을 까먹지 말자.

 

 

 

 

 

 

 

 

출처:https://blog.bitsrc.io/new-react-design-pattern-return-component-from-hooks-79215c3eac00

 

React Design Patterns: Return Component From Hooks

A Potential New Pattern Inspired by “Partial Application”: With practical examples using Material-UI and TypeScript

blog.bitsrc.io