고윤태의 개발 블로그

누구나 접근하기 쉽지만 얻어 가는 것이 있는 글을 쓰기 위해 노력 중입니다.

개발

React Native useOverlay를 만들어 리팩토링 하기 이 글은 진짜 떠야 해

고윤태 2023. 8. 22. 10:18
안녕하세요 이번에 작성하게 될 내용은 제가 react-native에서 useOverlay라는 custom hook을 구현한 과정과 어떤 식으로 구현했는지에 대해 글을 작성해 보도록 하겠습니다.

💻 개발 환경

React-native(expo) + TypeScript로 진행되었습니다.

🧐 구현하게 된 계기

저 같은 경우 기술 블로그를 보는 것을 정말 정말 좋아합니다.

작성자 분이 제가 읽고 있는 글을 작성하시기 위해 얼마나 많은 노력과 고민이 담겨 있는지도 느껴지기도 하고

그 과정을 함축하여 누구나 정보를 습득할 수 있도록 글에 옮기신 것이 너무 멋있다고 생각하기 때문입니다.

제가 몇 개월 전부터 제 것으로 만들기 위해 눈여겨보며 틈틈이 다시 읽던 블로그 글이 하나 있습니다.

출처 (https://toss.tech/)

토스 기술 블로그의 선언적인 코드 작성하기라는 글입니다.

다른 분들도 읽어보시면 좋을 것 같습니다!

https://toss.tech/article/frontend-declarative-code

 

선언적인 코드 작성하기

선언적인 코드, 토스 프론트엔드 챕터는 어떻게 생각을 하고 있을까요?

toss.tech

작성된 글 내용 자체가 너무나 도움이 되는 글이었지만

제 호기심을 자극했던 부분은 한 곳이었습니다.

 

출처 (https://toss.tech/article/frontend-declarative-code)

바로 이 useOverlay라는 custom hook에 대한 설명이었습니다.

 

이렇게 코드 예시와 함께 자세한 설명을 남겨주셨습니다.

저도 글을 읽는 순간 이런 hook을 만들어보고 싶다 생각만 하고 시간이 흘러갔습니다.

시간이 흐른 뒤 요즘 진행하고 있는 토이 프로젝트에서 제가 Dialog, BottomSheet를 사용할 일이 꽤 많이 있습니다.

비록 React와 React Native의 환경이 다르지만 여기에 적용해 보면 너무나 좋을 것 같아서 시도해 보기로 했습니다.

🤔 개발 과정

첫 번째로 이미 몇 번 Toss의 slash 레포를 통하여 useOverlay의 코드를 몇 번 본 상태이긴 했지만

개발을 진행하기 위해 리마인드를 할 겸 다시 한번 확인했습니다.

 

여기서 확인해 보실 수 있습니다.

https://github.com/toss/slash/blob/main/packages/react/use-overlay/src/useOverlay.ko.md

 

처음에는 Toss의 useOverlay와 정말 동일하게 구현을 할까 고민했습니다.

하지만 Toss의 useOverlay의 readme를 본다면

제가 느끼기엔 제가 필요한 기능에 비하면 배보다 배꼽이 더 크다고 느껴졌습니다.

그래서 제 상황에 맞게 필요한 기능에 맞게 구현하기로 마음을 먹었습니다.

기존 Toss의 useOverlay를 구현한 코드를 참고해 보면

 

이런 식으로 overlay를 Map을 활용하여 관리하고 있습니다.

하지만 저 같은 경우 사용하려는 주목적이 Dialog, BottomSheet를 사용하는 곳에 적용을 목표를 하고 있습니다.

제가 구현한 Dialog와 BottomSheet는 React Native의 Modal을 사용하여 구현한 컴포넌트입니다.

 

출처 (https://github.com/react-native-modal/react-native-modal/issues/30)

android 환경에서는 아니지만 ios 환경에서 Modal을 중첩해서 사용하는 것은 불가능한 상태입니다.

그러기에 저는 Toss처럼 Map을 사용할 필요는 없다 판단했습니다.

 

또 exit, exitOnUnmount 같은 값도 필요 없다고 판단하였습니다.

정리한 내용을 기반으로 개발을 진행했습니다.

👀 개발 코드(CODE)

OverlayContext

import React, { createContext, useState } from 'react';

interface OverlayContextProps {
  open: (overlayElement: React.ReactNode) => void;
  close: () => void;
}

export const OverlayDispatchContext = createContext<OverlayContextProps | null>(
  null
);

const OverlayStateContext = createContext<null>(null);

interface OverlayProviderProps {
  children: React.ReactNode;
}

const OverlayContext = ({ children }: OverlayProviderProps) => {
  const [overlayComponent, setOverlayComponent] =
    useState<React.ReactNode>(null);

  const close = () => {
    setOverlayComponent(null);
  };

  const open = (overlayElement: React.ReactNode) => {
    setOverlayComponent(overlayElement);
  };

  const dispatch = {
    open,
    close,
  };

  return (
    <OverlayStateContext.Provider value={null}>
      <OverlayDispatchContext.Provider value={dispatch}>
        {children}
        {overlayComponent}
      </OverlayDispatchContext.Provider>
    </OverlayStateContext.Provider>
  );
};

export default OverlayContext;

코드 설명

overlayComponent라는 상태를 관리하는 Context입니다.

open 함수는 인자로 받은 오버레이 컴포넌트를 overlayComponent 상태로 설정하여 오버레이를 엽니다.

close 함수는 overlayComponent 상태를 null로 설정하여 오버레이를 닫습니다.


useOverlay

import { useContext } from 'react';
import { OverlayDispatchContext } from './OverlayContext';

const useOverlay = () => {
  const overlayDispatch = useContext(OverlayDispatchContext);

  const open = (overlayElement: React.ReactNode) => {
    if (!overlayDispatch) {
      throw new Error('useOverlay must be used within an OverlayProvider.');
    }
    overlayDispatch.open(overlayElement);
  };

  const close = () => {
    if (!overlayDispatch) {
      throw new Error('useOverlay must be used within an OverlayProvider.');
    }
    overlayDispatch.close();
  };

  return {
    open,
    close,
  };
};

export default useOverlay;

코드 설명

useContext 훅을 사용하여 OverlayDispatchContext로부터 콘텍스트 값을 받아옵니다.
받아온 값을 단순히 return 시키는 hook입니다.

 완성 후기 및 이전과 비교

기존에 아래 사진과 같은 UI, UX를

이러한 코드로 구현을 해서 사용했었습니다.

import React, { useState } from 'react';
import { View } from 'react-native';
import Button from '../components/ui/buttons/Button';
import Dialog from '../components/ui/Dialog';

const Screen = () => {
  const [isVisible, setIsVisible] = useState(false);

  const openDialog = () => {
    setIsVisible(true);
  };

  const closeDialog = () => {
    setIsVisible(false);
  };

  return (
    <>
      <View>
        <Button label='open Dialog' onPressHandler={openDialog} />
      </View>
      <Dialog isOpened={isVisible}>
        <Dialog.Title title='title' />
        <Dialog.Buttons
          buttons={[
            {
              label: 'close',
              onPressHandler: closeDialog,
            },
          ]}
        />
      </Dialog>
    </>
  );
};

export default Screen;

하지만 Dialog 또는 BottomSheet를 사용하는 곳에서 반복되는 코드가 많아지고

그러한 이유로 useModal이라는 customhook을 구현했었습니다.

https://yun-tech-diary.tistory.com/entry/React-Native-useModal-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0

 

React Native useModal 구현하기

안녕하세요 이번에 회사 앱에 flow를 깊게 확인하기 위해 이것저것 해보는 와중 제 눈에 띈 것이 하나 있습니다. 바로 Modal 입니다. Modal은 앱에서 흔히 볼 수 있는 UI입니다. Modal을 사용하는 곳에

yun-tech-diary.tistory.com

useModal을 사용했을 때 코드 차이도 보여드리겠습니다.

import React from 'react';
import { View } from 'react-native';
import Button from '../components/ui/buttons/Button';
import Dialog from '../components/ui/Dialog';
import useModal from '../hooks/useModal';

const Screen = () => {
  const { isVisible, openModal, closeModal } = useModal();

  return (
    <>
      <View>
        <Button label='open Dialog' onPressHandler={openModal} />
      </View>
      <Dialog isOpened={isVisible}>
        <Dialog.Title title='title' />
        <Dialog.Buttons
          buttons={[
            {
              label: 'close',
              onPressHandler: closeModal,
            },
          ]}
        />
      </Dialog>
    </>
  );
};

export default Screen;

useModal를 사용한 경우에 이런 식으로 useModal 자체에 isVisible state와 openModal, closeModal 함수를 반환하기에

반복되는 코드를 줄일 수 있었습니다.

 

이번에 개발한 useOverlay를 사용한다면

App.tsx

import AppInner from './AppInner';
import { RecoilRoot } from 'recoil';
import OverlayContext from './src/hooks/OverlayContext';

export default function App() {

  return (
    <RecoilRoot>
      <OverlayContext>
         <AppInner />
      </OverlayContext>
    </RecoilRoot>
  );
}

OverlayContext를 추가한 뒤

import React from 'react';
import { View } from 'react-native';
import Button from '../components/ui/buttons/Button';
import Dialog from '../components/ui/Dialog';
import useOverlay from '../hooks/useOverlay';

const Screen = () => {
  const overlay = useOverlay();

  const openModal = () => {
    overlay.open(
      <Dialog isOpened={true}>
        <Dialog.Title title='title' />
        <Dialog.Buttons
          buttons={[
            {
              label: 'close',
              onPressHandler: overlay.close,
            },
          ]}
        />
      </Dialog>
    );
  };

  return (
    <>
      <View>
        <Button label='open Dialog' onPressHandler={openModal} />
      </View>
    </>
  );
};

export default Screen;

이런 식으로 Dialog를 사용할 수 있도록 바뀌었습니다.

😁 글 작성 후기

다른 분들이 보시기에는 엄청난 리팩토링이 아닐 수도 있다고 생각합니다만

제 생각에는 그동안 React를 하면서 불편함을 느꼈던 부분에서 속이 뻥 뚫리는 기분이었습니다.

 

 

항상 Modal을 사용하는 코드를 JSX에 넣어놓고 함수를 이용하여 open, close를 관리했었습니다.

그런 식으로 작성된 코드라면 JSX의 가독성도 떨어지고 Modal이 언제 open이 되는지 신경 써서 코드를 읽어야 했습니다. state 관리도 신경 썼어야 했어야 했습니다.

하지만 useOverlay 덕에 언제 Modal을 open 하여 사용하는지 직관적으로 코드가 변경됐습니다.

Toss 개발자 분들이 작성해 주신 useOverlay 글을 읽고 Toss의 slash 라이브러리를 설치하여 사용해 볼 의향이 있었지만

오히려 이렇게 벤치마킹하여 제 환경에 맞게 직접 구현하여 사용하게 될 줄은 몰랐습니다.

그 덕에 오히려 더 가치가 있게 느껴지는 작업이었습니다.

비록 제가 직접 생각하여 구상한 뒤 개발한 아이디어의 custom hook이 아니라 아쉽지만

이번 기회를 통해 이런 식으로도 활용할 수 있구나라는 생각의 범위가 넓어져서 좋은 것 같습니다.

좋은 글 작성해 주신 토스 개발자 분들께도 감사드리고

제 글 읽어주셔서 감사합니다.

'개발' 카테고리의 다른 글

useAsyncWithLoading custom hook  (0) 2023.09.12
React Native RadioButton 구현하기  (2) 2023.09.01
React Native 채팅방 목록 구현하기  (0) 2023.08.14
React Native ToastMessage 구현하기  (0) 2023.07.27
React Native Dropdown 구현하기  (0) 2023.07.18