Skip to content

gs18004/declaritive-modal

Repository files navigation

Velog

프론트엔드 개발에 있어 모달은 빼놓을 수 없는 요소이다. 오늘은 모달을 보다 선언적으로 관리하는 방법에 대해 다루어보고자 한다.

모달을 관리하는 방법은 매우 많은데, 가장 간단한 방식은 모달을 사용하는 곳마다 상태를 선언하는 것이다.

다음과 같이 말이다.

import { useState } from 'react';

const App = () => {
  const [isModalOpen, setIsModalOpen] = useState(false);
  const openModal = () => {
    setIsModalOpen(true);
  };
  const closeModal = () => {
    setIsModalOpen(false);
  };
  return (
    <div>
      {isModalOpen ? <div>This is a modal</div> : null}
      <button onClick={openModal}>Open modal</button>
    </div>
  );
};

직관적이다. 하지만, 모달이 100개 있다면 어떨까? 항상 isModalOpen 상태를 만들고, openModalcloseModal 함수를 만들어줘야 한다. 이러한 방식은 쓸데없는 중복 코드만 늘릴 뿐이고, 결국 유지보수가 어려워진다. 어떻게 하면 더 효과적으로 관리할 수 있을까?

우리에게는 커스텀 훅이 있다. useModal이라는 커스텀 훅을 하나 만들어보자.

import { useState } from 'react';

const useModal = () => {
  const [isModalOpen, setIsModalOpen] = useState(false);
  const openModal = () => {
    setIsModalOpen(true);
  };
  const closeModal = () => {
    setIsModalOpen(false);
  };
  return {
    ModalWrapper: ({ children }: { children: React.ReactNode }) =>
      isModalOpen ? (
        <ModalWrapper close={closeModal}>{children}</ModalWrapper>
      ) : null,
    openModal,
    closeModal,
  };
};
export default useModal;

여기서 ModalWrapper는 Portal을 이용하여 최상단으로 모달 레이어를 올려주고, 모달 배경을 설정해주는 컴포넌트이다. 이 때, close 함수를 prop으로 받아 모달 배경을 클릭했을 때 모달을 닫아주는 역할을 하게 한다.

// ModalWrapper.tsx

import ModalPortal from '@src/portals/ModalPortal';
import styles from './ModalWrapper.module.css';

type ModalWrapperProps = {
  children?: React.ReactNode;
  close: () => void;
};

const ModalWrapper = ({ children, close }: ModalWrapperProps) => {
  useEffect(() => {
    const handleEsc = (e: KeyboardEvent) => {
      if (e.key === 'Escape') {
        close();
      }
    };
    window.addEventListener('keydown', handleEsc);
    return () => {
      window.removeEventListener('keydown', handleEsc);
    };
  }, [close]);

  return (
    <ModalPortal>
      <div
        className={styles.Backdrop}
        onClick={(e) => {
          e.stopPropagation();
          close();
        }}
      >
        <div
          onClick={(e) => {
            e.stopPropagation();
          }}
        >
          {children}
        </div>
      </div>
    </ModalPortal>
  );
};

export default ModalWrapper;

잠깐 ModalPortal을 보고 가자면, 다음과 같다. createPortal로 Portal을 만들어주고 있다. 이 Portal을 사용하기 위해서는 index.html의 body에 <div id="modal"></div>를 추가해야 한다.

// ModalPortal.tsx

import { useEffect, useState } from 'react';
import ReactDOM from 'react-dom';

type ModalPortalProps = {
  children: React.ReactNode;
};

export default function ModalPortal({
  children,
}: ModalPortalProps): React.ReactPortal | null {
  const [el, setEl] = useState<HTMLElement | null>(null);

  useEffect(() => {
    let element = document.getElementById('modal');
    let created = false;

    if (!element) {
      element = document.createElement('div');
      element.id = 'modal';
      document.body.appendChild(element);
      created = true;
    }

    setEl(element);

    return () => {
      if (created && element) {
        document.body.removeChild(element);
      }
    };
  }, []);

  if (!el) return null;
  return ReactDOM.createPortal(children, el);
}

다시 useModal로 돌아가, 이를 어떻게 사용하면 될까?

아래와 같이 간단하게 사용할 수 있다.

import useModal from '@src/hooks/useModal';

const App = () => {
  const { ModalWrapper, openModal, closeModal } = useModal();
  return (
    <div>
      <ModalWrapper>
        <SampleModal close={closeModal} />
      </ModalWrapper>
      <button onClick={openModal}>Open modal</button>
    </div>
  );
};

export default App;

중복이 줄었고, 코드가 많이 깔끔해졌다. 하지만, 이렇게 끝낼 거라면 글을 쓰지 않았을 것이다.

우리가 만들 모달은 다음과 같이 생겼다.

모달을 열면 input에 이름을 입력한 뒤 Confirm을 누르면 기존 페이지에 입력된 값이 보이고, Cancel을 누르면 반영이 되지 않는 간단한 플로우로 작동한다.

어떻게 모달 내 input의 값을 기존 페이지에서 받아올 수 있을까?

일단, 어떤 방법을 사용하든 필수로 있어야 하는 상태를 생각해보자. 화면에서 동적으로 렌더링되어야 하는 First name과 Last name은 App.tsx에서 상태로 관리해야 한다.

const [firstName, setFirstName] = useState<string | null>(null);
const [lastName, setLastName] = useState<string | null>(null);

가장 생각하기 쉬운 방법은, NameModalsetFirstNamesetLastName을 props로 넘겨주는 것이다.

<NameModal
  close={closeModal}
  onChangeFirstName={setFirstName}
  onChangeLastName={setLastName}
/>

이 방법은 단점이 명확하다. 모달 내에서 다루는 값이 많아질수록 props로 넘겨야 하는 값이 많아진다. 코드가 읽기 힘들어질 가능성이 높다.

이를 보다 선언적으로 처리해보자.

필자는 openModal이 비동기적으로 모달 내의 값을 넘겨주는 방식을 채택했다. 다음과 같이 말이다.

const value = await openModal();

이렇게 하면 NameModal에 props를 많이 넘겨주지 않고도 모달 내의 값을 받아올 수 있다.

이제, useModal 훅에서 openModal이 비동기적으로 값을 넘겨줄 수 있도록 코드를 작성해보자.

useModal은 제네릭을 사용하는 함수여야 한다. openModal이 반환할 값의 타입이 무엇일지 모르기 때문이다. 이 제네릭을 T라고 하자.

그리고, resolve라는 상태를 만들자.

const [resolve, setResolve] = useState<(value: T | null) => void>();

openModal과 closeModal 함수는 다음과 같다.

closeModal에서 인자로 값을 넘겨주면 openModal을 호출한 곳에서 비동기적으로 해당 값을 받아올 수 있다.

setResolve(res)가 아니라 setResolve(() => res))로 해야 하는 이유는 res를 바로 실행시키지 않게 하기 위해서이다. 함수형 업데이트가 헷갈린다면 setState(prev => prev + 1)을 생각하면 이해가 편할 것이다.

const openModal = (): Promise<T | null> => {
  setIsModalOpen(true);
  return new Promise((res) => setResolve(() => res));
};

const closeModal = (value: T | null) => {
  setIsModalOpen(false);
  if (resolve) resolve(value);
};

useModal.tsx의 전체 코드는 다음과 같다.

// useModal.tsx

import ModalWrapper from '@src/components/ModalWrapper/ModalWrapper';
import { useState } from 'react';

const useModal = <T,>() => {
  const [isModalOpen, setIsModalOpen] = useState(false);
  const [resolve, setResolve] = useState<(value: T | null) => void>();

  const openModal = (): Promise<T | null> => {
    setIsModalOpen(true);
    return new Promise((res) => setResolve(() => res));
  };

  const closeModal = (value: T | null) => {
    setIsModalOpen(false);
    if (resolve) resolve(value);
  };

  return {
    ModalWrapper: ({ children }: { children: React.ReactNode }) =>
      isModalOpen ? (
        <ModalWrapper
          close={() => {
            closeModal(null);
          }}
        >
          {children}
        </ModalWrapper>
      ) : null,
    openModal,
    closeModal,
  };
};

export default useModal;

NameModalclose를 prop으로 받아서 모달을 닫을 때 이를 사용한다. 값을 저장하고 닫으려면 인자로 값을 넘겨주고, 값을 저장하지 않고 닫으려면 인자로 null을 넘겨주면 된다.

// NameModal.tsx

import { useCallback, useEffect, useRef } from 'react';

import styles from './NameModal.module.css';

export type NameModalReturn = {
  firstName: string;
  lastName: string;
};
type NameModalProps = {
  close: (value: NameModalReturn | null) => void;
};
const NameModal = ({ close }: NameModalProps) => {
  const firstNameInputRef = useRef<HTMLInputElement>(null);
  const lastNameInputRef = useRef<HTMLInputElement>(null);

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    close({
      firstName: firstNameInputRef.current?.value ?? '',
      lastName: lastNameInputRef.current?.value ?? '',
    });
  };
  const closeModalWithoutSave = () => {
    close(null);
  };

  return (
    <form className={styles.Container} onSubmit={handleSubmit}>
      <div className={styles.Content}>
        <h2>Modal</h2>
        <p>Please enter your name.</p>
        <input
          ref={firstNameInputRef}
          className={styles.Input}
          placeholder="First name"
        />
        <input
          ref={lastNameInputRef}
          className={styles.Input}
          placeholder="Last name"
        />
      </div>
      <div className={styles.Footer}>
        <button className={`${styles.Button} ${styles.Confirm}`} type="submit">
          Confirm
        </button>
        <button
          className={`${styles.Button} ${styles.Cancel}`}
          type="button"
          onClick={closeModalWithoutSave}
        >
          Cancel
        </button>
      </div>
    </form>
  );
};

export default NameModal;

이제 App에서는 NameModaluseModal을 가져다 사용하기만 하면 된다.

NameModal에서 정의해놓은 반환 타입 NameModalReturn을 import하여 useModal의 제네릭으로 넣어주었다.

// App.tsx

import NameModal, {
  NameModalReturn,
} from '@src/components/NameModal/NameModal';
import useModal from '@src/hooks/useModal';
import { useState } from 'react';

import styles from './App.module.css';
function App() {
  const [firstName, setFirstName] = useState<string | null>(null);
  const [lastName, setLastName] = useState<string | null>(null);
  const { ModalWrapper, openModal, closeModal } = useModal<NameModalReturn>();
  const handleOpenModal = async () => {
    const value = await openModal();
    if (value) {
      setFirstName(value.firstName);
      setLastName(value.lastName);
    }
  };
  return (
    <div className={styles.Container}>
      <ModalWrapper>
        <NameModal close={closeModal} />
      </ModalWrapper>
      <button className={styles.OpenButton} onClick={handleOpenModal}>
        Open
      </button>
      <h3>
        First name: {firstName}
        <br />
        Last name: {lastName}
      </h3>
    </div>
  );
}

export default App;

지금까지 모달을 선언적으로 관리하는 방법에 대해 알아보았다.

이 패턴은 코드의 간결함을 높이고, 재사용성을 강화하며 모달의 흐름을 이해하기 쉽게 만들어준다. 특히, 비동기 데이터 흐름과 상태 관리의 추상화 덕분에 여러 개의 모달도 손쉽게 추가하고 유지보수할 수 있다.

모달 구현에 어려움을 겪고 있거나 복잡한 코드를 단순화하고자 하는 개발자분들께 이 글이 도움이 되길 바란다.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published