본문 바로가기

개발

React에서 UI 컴포넌트와 결합된 코드를 다루는 패턴들

MUI 같은 라이브러리를 사용하거나, 꼭 그렇지 않더라도 재사용 가능한 컴포넌트를 만들다보면 특정 컴포넌트와 거의 항상 같이 쓰이는 코드가 있기 마련이다.

 

MUI의 Popover가 대표적.

특정 컴포넌트(주로 버튼)를 클릭하면 해당 컴포넌트의 위치를 기준으로 팝업창을 띄우는 용도로 사용된다.

 

MUI Popover 동작
MUI Popover 동작

 

이 때 MUI에서 권장하는 Popover를 열고 닫는 코드는 다음과 같다.

 

const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(null);

const handleClickButton = (event: React.MouseEvent<HTMLButtonElement>) => {
  setAnchorEl(event.currentTarget);
};

const handleClose = () => {
  setAnchorEl(null);
};

const open = Boolean(anchorEl);

// ...

 

Popover의 기준이 될 엘리먼트를 상태(anchorEl)로 두고 버튼을 클릭하면 해당 버튼으로, 창을 닫을 때는 null로 set해서 창이 열린 상태인지 아닌지(open)를 판단한다.

 

이후 Popover 컴포넌트를 호출할 때는 props로 open, anchorEl, handleClose 를 모두 넣어준다.

 

Popover를 쓸 때마다 이렇게 작성하려면 상당히 번거롭다. 여러 개의 Popover를 쓴다면 문제가 더 심각해진다.

무엇보다 컴포넌트(Popover)를 쓸 때 반드시 따라오는 코드(열고 닫기)를 굳이 매번 작성해야 한다는 점이 비효율적이다.

 

해결 방법  1-1.

반복되는 코드를 hooks로 분리한다.

 

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

    const openPopover = (event: React.MouseEvent<HTMLElement>) => {
      setAnchorEl(event.currentTarget);
    };

    const closePopover = () => {
      setAnchorEl(null);
    };
    
    return { anchorEl, openPopover, closePopover };
}

// 컴포넌트에서는 아래와 같이 사용
const SomeComponent = () => {
  const { anchorEl, openPopover, closePopover } = usePopover();
  
  return (
    <Page>
      <Button onClick={openPopover}>열기</Button>
      <Popover
        anchorEl={anchorEl}
        open={Boolean(anchorEl)}
        onClose={closePopover}
      />
    </Page>
   );
}

 

이렇게 작성해두고, Popover를 사용할 때 hooks도 같이 호출하여 리턴된 값들을 앵커 버튼, Popover 컴포넌트 등에 props로 넣어준다.

(openPopover, closePopover 등의 레퍼런스를 유지할 필요가 있다면 useCallback을 사용해야 한다.)

 

코드가 비교적 간결해졌지만, Popover에 여전히 props를 일일이 넣어줘야 한다는 점이 여전히 번거롭다.

 

해결 방법 1-2.

다음과 같이 props를 한 데 묶어서 리턴해줄 수 있다.

 

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

    const openPopover = (event: React.MouseEvent<HTMLElement>) => {
      setAnchorEl(event.currentTarget);
    };

    const closePopover = () => {
      setAnchorEl(null);
    };
    
    const popoverProps = { anchorEl, open: Boolean(anchorEl), onClose: closePopover };
    
    return { popoverProps, openPopover };
}

// 컴포넌트에서는 아래와 같이 사용
const SomeComponent = () => {
  const { popoverProps, openPopover } = usePopover();
  
  return (
    <Page>
      <Button onClick={openPopover}>열기</Button>
      <Popover {...popoverProps} />
    </Page>
   );
}

 

세부적인 props가 컴포넌트 단에서는 다 감춰져버리는 것이 이 방법의 장점이자 단점이다. 만약 popoverProps 중 하나를 별도로 사용해야 하거나 디테일한 로직이 드러나야 하는 경우라면 적절하지 않을 수 있다.

 

번외.

위 1-2의 코드를 보면 다음과 같은 의문이 생긴다.

어차피 훅에서 리턴 받은 props를 그대로 뿌려주는데, 처음부터 훅에서 props를 넣은 컴포넌트를 리턴하면 안 되는 걸까?

 

(스포) 안 된다.

 

코드를 보자.

 

import { Popover as MuiPopover, type PopoverProps } from "@mui/material";

const usePopover = () => {
    // 생략 (위와 동일)
    
    const Popover = (props: Omit<PopoverProps, "open" | "anchorEl">) => (
      <MuiPopover open={Boolean(anchorEl)} anchorEl={anchorEl} {...props} />
    );
    
    return { Popover, openPopover };
}

const SomeComponent = () => {
  const { Popover, openPopover } = usePopover();
  
  return (
    <Page>
      <Button onClick={openPopover}>열기</Button>
      <Popover sx={ } transformOrigin={ } /> // 다른 props 사용
    </Page>
   );
}

 

usePopover에서 open, anchorEl을 넣어주고 그 외 다른 props는 사용하는 쪽에서 넣을 수 있도록 선언했다. 컴포넌트에서는 Popover와 Popover를 여는 함수만 hooks로부터 리턴받아 사용하면 된다. 상당히 깔끔하다. 하지만..

 

Popover라는 컴포넌트는 usePopover가 호출될 때마다 선언이 된다. 그렇다는 것은, anchorEl 상태가 바뀔 때마다 Popover는 mount, unmount를 반복한다는 것이다. (메모이제이션을 사용하더라도 anchorEl을 디펜던시로 넣어줘야 하기 때문에 결과는 똑같다)

 

실제로 적용을 해보면, 정상적인 동작과 달리 닫힐 때 페이드 아웃이 적용되지 않는 것을 볼 수 있다. (바로 unmount 되기 때문)

MUI Popover 동작 (이상)

 

해결 방법 2.

Context를 사용한다. React에서 데이터를 pass하는 방법에는 prop외에도 Context가 있다.

 

아래와 같이 Context와 Provider를 선언한다.

 

const PopoverContext = React.createContext({
  popoverProps: {
    open: false,
    anchorEl: null,
    onClose: () => {}
  },
  setAnchorEl: () => {}
});

const PopoverContextProvider = ({ children }) => {
  const [anchorEl, setAnchorEl] = useState(null);

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



그리고 다음과 같이 훅을 작성한다.

 

const Popover = (props: Omit<PopoverProps, "open" | "anchorEl" | "onClose">) => {
  const { popoverProps } = useContext(PopoverContext); // popoverProps를 Context에서 가져온다

  return <MuiPopover {...popoverProps} {...props} />;
};

const usePopover = () => {
  const { setAnchorEl } = useContext(PopoverContext);

  // 세부구현 생략
  const openPopover = () => {};
  const closePopover = () => {};

  return {
    Popover, // 이 때 Popover는 '번외'에서와 달리 hooks 바깥에 선언되어 있음
    openPopover,
    closePopover
  };
};

 

다음과 같이 사용해도, '번외'에서와 달리 문제가 없다.

 

const SomeComponent = () => {
  const { Popover, openPopover } = usePopover();
  
  return (
    <Page>
      <Button onClick={openPopover}>열기</Button>
      <Popover sx={ } transformOrigin={ } />
    </Page>
   );
}

 

Popover는 정적으로 선언되어 있지만, prop은 Context로부터 (변경될 때마다) 받기 때문.

 

물론 적절한 곳에 Provider를 넣어줘야 한다.

 

보통 Popover 같은 컴포넌트는 동시에 하나만 띄워지는 것이 일반적이므로 글로벌한 레벨에 넣을 수도 있고, 필요에 따라 특정 범위에서만 사용되도록 할 수도 있을 듯하다.

 

해결 방법 3.

renderProp을 사용한다. renderProp은 prop인데 'JSX Element를 리턴하는 함수'인 것을 말한다.

 

import {
  Popover as MuiPopover,
  type PopoverProps as MuiPopoverProps,
} from "@mui/material";

interface PopoverProps extends Omit<MuiPopoverProps, "open" | "anchorEl"> {
  renderTrigger: (
    openPopover: (e: MouseEvent<HTMLElement>) => void,
  ) => JSX.Element;
}

const Popover = ({ renderTrigger, ...props }: PopoverProps) => {
  const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);

  const openPopover = (event: MouseEvent<HTMLElement>) => {
    setAnchorEl(event.currentTarget);
  };

  const closePopover = () => {
    setAnchorEl(null);
  };

  return (
    <>
      {renderTrigger(openPopover)}
      <MuiPopover
        {...props}
        open={Boolean(anchorEl)}
        onClose={closePopover}
        anchorEl={anchorEl}
      />
    </>
  );
};

 

이제 사용하는 쪽에서는 아래와 같이 코드를 작성할 수 있다.

 

const SomeComponent = () => {
  return (
    <Page>
      <Popover
        renderTrigger={(openPopover) => (
          <Button
            onClick={openPopover}
            variant="outlined"
          >
            열기
          </Button>
        )}
        anchorOrigin={ }
        transformOrigin={ }
       >
         {/* Popover Contents */}
       </Popover>
     </Page>
  );
}

 

Popover와 그것을 트리거하는 컴포넌트를 한 데 묶어서 선언할 수 있다는 것이 가장 큰 장점이다. 다만 실제로 화면에 보이는 것은 Button인데, 마치 Popover가 Button의 부모 컴포넌트인 것처럼 착각을 유발(?)하는 면이 조금 있다.

 

Popover를 예시로 들었지만 이런 식의 패턴을 적용할 수 있는 컴포넌트는 많다. (MUI를 기준으로 하면 Dialog, Tab, DatePicker 등등)

 

모든 방식들이 장단점이 있으니 필요에 따라 선택하여 적용하면 될 것 같다.