• 2022. 2. 24.

    by. 문익점

    반응형

    React에서 모달을 띄우는 방식을 선언적으로 처리하는법을 소개합니다.

    모달을 띄우는 방식

    기존의 모달을 띄우던 방식은 명령형 방식에 따르고 있습니다. 내부 함수나 로직들은 선언적으로 처리하고 모달 처리는 명령형으로 되어있는게 무언가 미적(?)으로 안 좋아 보이며 모달이 켜져있는지 꺼져있는지에 대한 여부는 hook로 숨기고 싶어 선언형으로 바꿔보았습니다.

    기존 명령형 방식

    ... 
     const [isOpen, setIsOpen] = useState();
      return (
        <div>
          <button onClick={()=>setIsOpen(true)}>모달 오픈</button>
          {
            isOpen ? <Modal /> : null
          }
        </div>
      );
    ...

    새로운 선언형 방식

    ...
      const { open: openComfirmDialog } = useComfirmDialog();
    
      return (
        <div>
          <button onClick={openComfirmDialog}>모달 오픈</button>
        </div>
      );
    ...

     

    간단하게 hook에서 open 함수만 가져와서 사용하면 됩니다. 열려진 상태인지 아닌지는 hook에 숨기고 열기 버튼만 가져와서 사용하는 모습입니다. 이런식으로 처리한다면 좀 더 선언적으로 깔끔하게 처리가 가능합니다.

    구현하기

    React Portal과 함께 사용

    React에는 다음과 같이 컴포넌트를 랜더링합니다.

    render() {
      return (
        <div>
          <Modal />
        </div>
      );
    }

    하지만 Modal 컴포넌트의 위치를 더 상위 트리에 위치 시키고 싶을 때가 있는데요. 그럴때는 말 그대로 포탈을 열어주면 된니다. React에는 이러한 Portal을 제공합니다.

    https://ko.reactjs.org/docs/portals.html

    Portal을 이용할 수 있는 컴포넌트 만들기

    import { ReactNode, useEffect, useRef, useState } from "react";
    import { createPortal } from "react-dom";
    
    export function Portal({ children }: { children: ReactNode }) {
      const [mounted, setMounted] = useState(false);
      const portalEle = useRef<HTMLDivElement | null>(null);
    
      useEffect(() => {
        setMounted(true);
        portalEle.current = document.createElement("div");
        portalEle.current.id = "portal";
        document.body.appendChild(portalEle.current);
        return () => {
          if (portalEle.current != null) {
            document.body.removeChild(portalEle.current);
          }
        };
      }, []);
    
      if (!mounted || portalEle.current == null) {
        return null;
      }
    
      return createPortal(children, portalEle.current);
    }

    children으로 컴포넌트를 받고 body 태그 밑에 포탈을 연다. 그 다음 그 포탈안으로 children으로 들어온 컴포넌트를 넣어준다. 그렇다면 아래와 같이 사용하면 된다.

    <Portal>
      <Modal />
    </Portal>;

    모달 전용 Context Provider 만들기

    interface DialogContextProps {
      children: ReactNode;
    }
    
    interface DialogContextValue {
      open: boolean;
      openDialog: ({ node }: { node: ReactNode }) => void;
      closeDialog: () => void;
    }
    
    export const DialogContext = createContext<DialogContextValue>({
      open: false,
      openDialog: () => {},
      closeDialog: () => {},
    });
    
    export function DialogProvider({ children }: DialogContextProps) {
      const [isOpen, setIsOpen] = useState(false);
      const [node, setNode] = useState<ReactNode>(null);
      useLockBodyScroll(isOpen);
    
      const openDialog = useCallback(({ node }: { node: ReactNode }) => {
        setNode(node);
        setIsOpen(true);
      }, []);
    
      const closeDialog = useCallback(() => {
        setNode(null);
        setIsOpen(false);
      }, []);
    
      const value = useMemo((): DialogContextValue => {
        return {
          open: isOpen,
          openDialog,
          closeDialog,
        };
      }, [isOpen]);
    
      return (
        <DialogContext.Provider value={value}>
          {children}
          {isOpen ? (
            <Portal>
              <DialogBackground>{node}</DialogBackground>
            </Portal>
          ) : null}
        </DialogContext.Provider>
      );
    }
    
    const DialogBackground = styled.div`
      position: fixed;
      top: 0;
      left: 0;
      right: 0;
      bottom: 0;
      background: rgba(0, 0, 0, 0.7);
      z-index: 1000;
    `;
    
    export function useDialog() {
      const context = useContext(DialogContext);
      return context;
    }

    이 Provider 컴포넌트를 자식에서 useContext로 접근하여 사용하기 위해서 해당 provider을 최상단에서 묶어줍니다.

    context에 넣어준 value는 열려있는지에 대한 상태, 닫기, 열기 함수가 있습니다. 이 값들로 Modal을 컨트롤하게 됩니다. 이제 모달별 커스텀 훅스 예시를 보면 됩니다.

    모달별로 custom hook 만들기

    const useComfirmDialog = () => {
      const { openDialog, closeDialog } = useDialog();
    
      const openTestModal = useCallback(() => {
        openDialog({
          node: (
            <DialogWrapper role="dialog">
              <h2>모달</h2>
              <p>테스트 모달입니다.</p>
              <button onClick={closeDialog}>닫기</button>
            </DialogWrapper>
          ),
        });
      }, [openDialog, closeDialog]);
    
      return useMemo(
        () => ({
          open: openTestModal,
        }),
        [openTestModal]
      );
    };
    
    const DialogWrapper = styled.div`
      position: fixed;
      z-index: 1000;
      top: 50%;
      left: 50%;
      margin: 0;
      transform: translate(-50%, -50%);
      background: #fff;
      box-shadow: 0 3px 7px rgba(0, 0, 0, 0.3);
      color: var(--main-bg-color);
    `;
    
    export default useComfirmDialog;
    

    Context에 저장된 openDialog 함수를 가져오고 파라메타 node에 어느 모달을 띄울지 전달 합니다. 이 모달은 openDialog 함수 실행시 Portal에 전달되어 body 밑에 랜더링 될 것이고 closeDialog 함수 실행시에 조건부랜더링에 걸려 Portal이 사라지며 모달이 사라지게 됩니다.

     

     

    반응형