React와 Modal

Johny Kim·2022년 2월 4일
4
post-thumbnail

리액트에서 Modal을 띄울 때 여러분은 어떤 방법으로 띄우고 있나요? 저도 여러가지 고민을 했었고 몇가지 방법으로 Modal을 사용해보았어요. 어떤 방법을 사용해보았고 지금은 어떤 방법을 가장 선호하는지에 대해 공유하고자 이 글을 씁니다.

제가 사용해본 방법들은 아래와 같습니다. 이름은 제 마음대로...

  1. Trigger component
  2. Modal hook
  3. Toggle state modal

Trigger component

Modal 과 Trigger를 하나의 컴포넌트 안에 주입시키는 방법입니다. 이러한 방법은 Sementic-UI React를 통해 처음 접하게 되었습니다.

어떻게 사용하는지 먼저 코드를 볼까요? 아래 코드는 Sementic-UI React 홈페이지에서 복사해왔습니다.

const [open, setOpen] = useState(false);

<Modal
  onClose={() => setOpen(false)}
  onOpen={() => setOpen(true)}
  open={open}
  trigger={<Button>Show Modal</Button>}
>
  <Modal.Header>Select a Photo</Modal.Header>
  <Modal.Content>
    ...
  </Modal.Content>
  <Modal.Actions>
    ...
  </Modal.Actions>
</Modal>

위와 같이 Modal 이라는 컴포넌트 안에, children으로 Modal 내용들을 전달하고, Modal을 띄워줄 버튼 등을 trigger prop으로 전달합니다. 꽤 직관적이고 심플하게 사용할 수 있는 것 같습니다.

하지만 이 방법에서 제가 느끼는 단점이 있습니다. 그게 뭔지는 Toggle state 방법을 설명할 때 같이 설명하겠습니다.

useModal 이라는 custom hook을 이용한 방법입니다. hook 안에 모든 재료들을 준비해놓고, 컴포넌트에서는 사용하기만 하면 되는 구조 입니다.

참고로 이 방법의 경우 단점이 꽤 많아서, 이제는 사용하지 않습니다.

// useModal.ts
const [isOpen, setIsOpen] = useState(false);

const openModal = useCallback(() => setIsOpen(true), [isOpen]);
const closeModal = useCallback(() => setIsOpen(false), [isOpen]);
const toggleModal = useCallback(() => setIsOpen(!isOpen), [isOpen]);

const renderModal = <Modal>
  <Modal.Header>{headerText}</Modal.Header>
  <Modal.Content>
    {modalContent}
  </Modal.Content>
  <Modal.Actions>
    {modalActions}
  </Modal.Actions>
<Modal>
  
return {
  isOpen,
  openModal,
  closeModal,
  toggleModal,
  renderModal,
}
// Component.tsx
const { openModal, renderModal } = useModal({
  headerText: 'Select a Photo',
  modalActions: <>
    ...
  </>,
  modalContent: <>
    ...
  </>
});

return (
  <div>
    <button onClick={openModal}>Open</button>
    {renderModal}
  </div>
)

처음에는 custom hook을 이용해 Modal을 구현해보고 싶었고, 준비 된 재료들을 hook에서 바로 사용할 수 있어서 좋다고 생각했습니다. 하지만 오래 못가, 많은 단점들이 발견되었고 더 이상 사용하지 않게 되었습니다.

어떠한 단점들이 있을까요?

1. Component.tsx 파일 내에서 기능이 들어가야 할 영역에 View 를 포함하게 된다.

useModal에 props 로 전달하게 되는 modalActions와 modalContent는 모두 React.Element입니다. 즉, View를 담당하게 될 부분이라는거죠. 보통 View는 컴포넌트 가 반환하는 return (...) 코드 내에 존재한다고 생각하게 되죠. 컴포넌트 로직이 존재하는 위치에 View가 존재하게 되다보니 혼동이 오게 됐습니다.

2. 코드를 찾기가 어렵다.

컴포넌트 내에 여러가지의 Modal들과 기능들이 붙다보면, 관련 된 코드들이 서로 떨어지게 되어, 코드를 찾는것도 함들어집니다.

3. renderModal을 return 코드에 포함해야 한다.

굳이 renderModalreturn <...>{renderModal}</...> 이와 같이 return 안에 넣어줘야 했습니다. 물론 전역 store를 이용해 해결할 수 있겠지만, 모달 안에 여러 로직과 상태들이 존재해야 하는 경우 컴포넌트간 데이터를 공유하는 것이 쉽지 않다고 느꼈습니다.

Toggle state

마지막 소개해드릴 방법은 Toggle state 입니다. 첫번째로 설명한 Trigger compoenet 방법을 사용했을 때, 제가 해결하고 싶은 문제가 있었습니다. 그것은 컴포넌트 관심사 분리 입니다.

예를 들어, 어떠한 글 리스트를 보여주고 있는 화면에서 글 새로 추가하거나 수정하는 폼을 Modal로 띄워야 하는 경우를 가정합니다.

// PostList.tsx
const [openEdit, setOpenEdit] = useState(false);
const [openCreate, setOpenCreate] = useState(false);

const postList = useSelector(state => state.post.postList);
const {...} = useEditPost();
const {...} = useCreatePost();

<Modal
  onClose={() => setOpenEdit(false)}
  onOpen={() => setOpenEdit(true)}
  open={openEdit}
  trigger={<Button>Edit Post</Button>}
>
  <Modal.Header>Edit Post</Modal.Header>
  <Modal.Content>
    ...
  </Modal.Content>
  <Modal.Actions>
    ...
  </Modal.Actions>
</Modal>

<Modal
  onClose={() => setOpenCreate(false)}
  onOpen={() => setOpenCreate(true)}
  open={openCreate}
  trigger={<Button>Create Post</Button>}
>
  <Modal.Header>Create Post</Modal.Header>
  <Modal.Content>
    ...
  </Modal.Content>
  <Modal.Actions>
    ...
  </Modal.Actions>
</Modal>

...

<PostListItems list={postList} />

위처럼 두가지 이상의 기능들을 가진 컴포넌트를 첫 번째 방법으로 하게 되면, 어떤 단점들이 있을까요?

  1. List 컴포넌트 안에 Create, Edit 기능이 함께 존재해야하고, View와 기능이 섞이게 됩니다.
  2. 1번을 해결하고자 Modal과 기능을 하나씩 각각 묶게 되면 기능이 여러 컴포넌트로 흩어지게 되고 앱이 더 복잡해지게 되면 기능을 찾기가 어려워집니다.
  3. api call 함수를 따로 분리한다 하더라도, 한 컴포넌트 내에서 onEdit, onCreate, list 등의 서로 관련 없는 여러 props를 받아줘야 하는 문제가 있습니다.
  4. Button과 Modal이 결합되어 있어, 레이아웃 변경이 무겁고 복잡합니다.
  5. Modal 위에서 다른 Modal을 띄워야 하는 경우가 있었는데, 그 경우 기능분리가 어렵습니다.

여기서 저는 Create, Edit, List 각 기능을 분리하고 싶었고 View 컴포넌트는 오직 View의 역할만 하고 기능은 Container에서 따로 관리하고 싶었습니다.

  • PostContainer.tsx : 기능과 뷰를 연결해주는 컨테이너 컴포넌트
  • useEditAndCreatePost.tsx : 글 생성, 수정 API 콜과 기능을 담은 커스텀 훅
  • PostForm.tsx : 글 생성, 수정의 입력을 다루는 뷰 컴포넌트
  • PostList.tsx : 글 리스트를 보여주는 뷰 컴포넌트

코드를 볼까요?

// PostContainer.tsx
// 컨테이너 역할의 컴포넌트에는 기능을 담당하는 코드들이 존재해요.
// 기능들이 여러 컴포넌트로 흩어지지 않게 한 곳에서 관리해요.

const postList = useSelector(state => state.post.postList);

const { 
  isOpenModal, // boolean
  initPost, // Post | null
  onOpenCreate,
  onSelectPost,
  onSubmit,
  onCloseModal,
} = useEditAndCreatePost();

return (
  <main>
    
    <button onClick={onOpenCreate}>새 글 쓰기</button>
    
    <PostList
      list={postList}
      onSelectPost={onSelectPost}
    />
    
    <Modal
      isOpen={isOpenModal}
      onClose={onCloseModal}
      children={
        <PostForm
          initPost={initPost}
          onSubmit={editPost}
          onClose={toggleEdit.close}
        />
      }
    />
  </main>
)
// useEditAndCreatePost.ts
const toggleEdit = useToggle();
const selectedPost = useObject<Post>();

const onSubmit = (values: Post) => {
  // selectedPost가 있으면 PUT, 없으면 POST를 날립니다.
}

const onCloseModal = useCallback(() => {
  toggleEdit.close();
  selectedPost.clear();
}, []);

return {
  isOpenModal: toggleEdit.value || !!selectedPost.item,
  initPost: selectedPost.item, // Post | null
  onOpenCreate: toggleEdit.open,
  onSelectPost: selectedPost.select,
  onSubmit,
  onCloseModal,
}
// PostForm.tsx (View)
// 뷰를 담당하는 컴포넌트에서는 input에 연결 된 controlled values state는 존재해도 됩니다.

interface Props {
  initPost?: Post || null; // null | undefined 의 경우 Create 모드.
  onSubmit: (values: Post) => void;
  onClose: () => void;
}

...

const isEditing = !init;
const [values, setValues] = useState({
  title: initPost.title || '',
  content: initPost.content || ''
});

const handleSubmit = useCallback((e) => {
  e.preventDefault();
  onSubmit(values as Post);
}, [values]);

return (
  <Modal.Container>
    <Modal.Header onClose={onClose}>{isEditing ? 'Edit' : 'Create'}</Modal.Header>
    <Modal.Content>
      <form onSubmit={handleSubmit}>
        ...
      </form>
    </Modal.Content>
    <Modal.Actions>
      ...
    </Modal.Actions>
  </Modal.Container>
)
// PostList.tsx (View)
// 뷰를 담당하는 컴포넌트. 
// onSelectPost는 props로 받아서 필요한 곳에 연결만 해주면 됩니다.

interface Props {
  list: Post[];
  onSelectPost: (selected: Post) => void;
}

...

return (
  <ul>
    { list.map(item => (
      <PostListItem data={item} onClick={() => onSelectPost(item)} />
    )}
  </ul>
)

어떤가요? 사실 첫번째 방법으로도 위와 비슷하게 관심사에 따라 코드 분리를 할 수 있긴 합니다. 하지만 한계가 있습니다. Button(Trigger)과 Modal이 결합되어 있기 때문에, 드물지만 Modal 위에 Modal을 띄워야 하는 경우 무조건 2depth 이상 컴포넌트를 연결 해줘야 합니다.

하지만 마지막 방법으로 하게 되면, onOpenModal 이벤트만 버튼에 연결해주면 되기 때문에 Modal과 Button을 자유롭게 분리할 수 있습니다. 하나의 Container 컴포넌트에, 존재하는 모든 Modal들을 1차원적으로 깔아놓을 수 있어서 2depth 이상 넘어갈 필요도 없습니다.

이 방법을 사용하면서 Container 컴포넌트의 길이가 길어질 수는 있지만 어떤 Modal들이 존재하는지 찾기가 쉽다고 느꼈습니다.


이상 글을 마칩니다.. 아마도 여러분들은 Modal을 컨트롤 하는 더 멋진 방법들을 많이 알고 계시겠죠? 좋은 의견이 있다면 댓글로도 남겨주세요. 글을 읽어주셔서 감사합니다.

profile
작고 단단한 컴포넌트를 만들자.

0개의 댓글