Lighthouse of FE biginner

[React] Context.API를 사용해 Provider 패턴 구현하기 본문

[WEB] 프론트엔드

[React] Context.API를 사용해 Provider 패턴 구현하기

[FE] Lighthouse 2024. 9. 6. 17:30

Overview

최근 업무에서 구현된 AlertDialog를 Swal 라이브러리와 비슷한 형태로 사용 가능하게 POC가 가능하냐는 요청이 들어왔습니다.
 
Swal 라이브러리는 Sweet Alert의 약자로 Alert 컴포넌트를 Promise 단위로 다루어 선언적이고 쉽게 Alert를 다룰 수 있는 라이브러리입니다.
 
구현된 Alert Dialog 컴포넌트는 radix-ui/react-alert 라이브러리를 활용해 구현이 됐으며 해당 컴포넌트를 사용하기 위해서는 컴포넌트를 랜더링 시키고 props를 넘겨줘야 합니다.
AlertDialog를 사용하는 곳 페이지 혹은 컴포넌트 마다 랜더링 하도록 구현하는 것은 상대적으로 많은 코드 중복이 발생할 뿐더러 개발 경험이 저하됩니다.
 
하여 Swal 라이브러리 처럼 사용처에서 랜더링을 하지 않고 선언적으로 사용할 수 있는 방법이 있을까 고민하던 중 Context API의 Provider 패턴을 활용해 AlertDialog를 싱글턴 패턴과 비슷하게 구현해보자 라는 아이디어를 고안했고 이런 방식으로 구현해 요구사항 POC를 성공했습니다.
 
이번 포스팅을 통해 해당 과정을 살펴보도록 하겠습니다.

이번 포스팅에서는 Context API에 대해서 자세하게 다루고 있지 않습니다. 만약 Context API가 궁금하시다면 아래 공식 문서를 살펴봐주세요!

Context API 공식문서

 

기술 선정 이유

먼저 Provider 패턴을 활용해 컴포넌트를 싱글턴 패턴으로 랜더링 하려면 전역 상태를 사용해야 합니다.
요즘 사용하는 전역 상태 관리 라이브러리는 굉장히 다양합니다. Redux, Zustand, Jotai, Recoil 등.. 여러가지 라이브러리가 존재합니다.
 
하지만 저는 특정 라이브러리에 의존성을 부여하고 싶지 않았고 내장된 API인 Context API를 사용해 간단하게 구현할 수 있을 것이라고 판단했습니다.
 
특히 특정 라이브러리를 설치한다면 해당 컴포넌트를 사용하기 위해 사용하는 프로젝트에서 해당 상태관리 라이브러리를 설치해야 하기 때문에 좋지 못한 의존성 관계를 갖게 됩니다. 이런 상황들을 예방하기 위해 Context API 를 사용하게 됐습니다.
 

설계

Context Type

사용할 Context의 타입은 아래와 같습니다.

type AlertDialogContextProps = {
   alertDialogProps: AlertDialogProps;
   open: (value: AlertDialogProps) => void;
   close: () => void;
};
PropertyDescription
alertDialogPropsAlertDialog에 넘겨줄 Props (AlertDialog를 사용하는 컴포넌트에서 기존의 props를 필요로 할 소지가 있어서 추가했습니다. 추후 필요 없다고 판단 시 제거해도 괜찮습니다.)
openAlertDialogProps 를 전달받아 Context에 넘겨주는 함수입니다. AlertDialog를 열 때 isOpen 값은 항상 true이기에 해당 값은 props로 전달받지 않습니다.
close파라미터와 리턴 값이 없는 함수입니다. AlertDialog를 닫을 때 사용합니다.

 

Context

const init = () => {};
const initialProps: AlertDialogProps = {
   isOpen: false,
   children: undefined,
   onClickClose: () => {},
};
const AlertDialogContext = createContext<AlertDialogContextProps>({
   open: init,
   close: init,
   alertDialogProps: initialProps,
});

 
초기 값을 null과 undefined을 지정해주지 않고 initValue를 넣어줍니다. null | undefined를 사용할 경우 해당 Context를 사용하는 곳에서 값의 체크가 필요해 사용처의 코드가 복잡해집니다. Provider 컴포넌트에서 close, open을 재정의 해서 값을 넣어주기 때문에 사용하는 측에서 함수 재정의가 필요하지 않습니다.
 

Provider

const AlertDialogProvider = (props: PropsWithChildren) => {
   const [alertDialogProps, setAlertDialogProps] = useState<AlertDialogProps>(initialProps);
   const open = useCallback(
      (props: AlertDialogProps) => setAlertDialogProps({ ...props, isOpen: true }),
      []
   );
   const close = useCallback(() => setAlertDialogProps((prev) => ({ ...prev, isOpen: false })), []);
   return (
      <AlertDialogContext.Provider value={{ open, close, alertDialogProps }}>
         {props.children}
         <AlertDialog {...alertDialogProps} />
      </AlertDialogContext.Provider>
   );
};

 
Provider에서는 open, close 함수를 재정의 해서 Context.Provider에 value로 넘겨줍니다. 해당 Provider에서 state로 alertDialogProps의 상태를 관리하고 AlertDialog를 랜더링 하여 Props를 넘겨주고 있습니다.
 
위 Provider 컴포넌트에서 AlertDialog를 랜더링하고 Context API를 활용해 alertDialogProps를 전역 상태로 사용하고 있어 사용하는 각 페이지 마다 컴포넌트를 랜더링 하지 않아도 됩니다.
 

useAlertDialog Hook

AlertDialog를 사용하기 위한 비즈니스 로직을 모아놓은 훅 입니다.
 
Context API를 사용해 alertContext를 받아서 Dialog를 사용하는 비즈니스 로직을 구현합니다. 커스텀 훅으로 한번 더 래핑한 이유는 AlertDialog를 사용하기 위한 비즈니스 로직을 한번 더 추상화 해 코드 중복을 줄이기 위함입니다.

type OpenAlertDialogProps = Omit<AlertDialogProps, 'isOpen' | 'onClickClose'> & {
   onClickClose?: (e?: React.MouseEvent<HTMLButtonElement | HTMLDivElement>) => void;
};
type UseAlertDialogResult = {
   open: (props: OpenAlertDialogProps) => void;
   close: () => void;
};

const useAlertDialog = (): UseAlertDialogResult => {
   const alertContext = useContext<AlertDialogContextProps>(AlertDialogContext);

   const closeDialog = useCallback(() => {
      alertContext.close();
   }, [alertContext]);

   const openDialog = useCallback(
      (props: OpenAlertDialogProps) => {
         const onClickClose = (e?: React.MouseEvent<HTMLButtonElement | HTMLDivElement>) => {
            if (props?.onClickClose instanceof Function) {
               props.onClickClose(e);
            }
            closeDialog();
         };
         alertContext.open({
            ...props,
            onClickClose,
            isOpen: true,
         });
      },
      [alertContext, closeDialog]
   );

   return {
      open: openDialog,
      close: closeDialog,
   };
};

 

Use Case

export const Test: Story = {
   ...Template,
   render: () => {
      return (
         <AlertDialogProvider>
            <Sample />
            <Sample2 />
         </AlertDialogProvider>
      );
   },
};

 
Provider로 App을 래핑합니다. 실제 프로젝트라면 index.tsx에서 Root를 래핑합니다.

const Sample = () => {
   const { open } = useAlertDialog();

   const onClickConfirm = () => {
      alert('ok');
   };

   const onClick = () => {
      open({
         onClickConfirm,
         title: 'Default Alert',
         children: <>this is child</>,
         cancelButtonText: '취소',
      });
   };
   return (
      <div>
         <Button onClick={onClick}>Use Alert Dialog1</Button>
      </div>
   );
};

const Sample2 = () => {
   const { open } = useAlertDialog();

   const onClickClose = () => {
      alert('Close Callback');
   };

   const onClickConfirm = () => {
      alert('ok');
   };

   const onClick = () => {
      open({
         onClickClose,
         onClickConfirm,
         title: 'Default Sample2',
         children: <>this is sampe2</>,
         cancelButtonText: '취소',
      });
   };
   return (
      <div>
         <button onClick={onClick}>Sample2 Alert</button>
      </div>
   );
};

 
실제 AlertDialog를 사용하는 컴포넌트 입니다. 2개의 다른 컴포넌트에서 Alert를 사용합니다.
 

POC 이후 요구사항 반영

POC를 팀원들에게 한 이후 몇 가지 요구사항을 받았습니다.

  • confirmButtonText props의 기본 값 “확인”을 초기 값으로 넣어달라는 요청사항이 있어 initialProps에 값을 추가했습니다. open 할 때 기존의 값을 유지시킬 수 있도록 setState 로직을 변경했습니다.
const initialProps: AlertDialogProps = {
   isOpen: false,
   children: undefined,
   onClickClose: init,
   confirmButtonText: '확인',
};

const open = useCallback((props: AlertDialogProps) => {
   setAlertDialogProps((prev) => ({ ...prev, ...props, isOpen: true }));
}, []);
  • onClickClose 함수의 기본 동작은 AlertDialog를 닫아주는 역할입니다. 하여 컴포넌트에서 해당 Callback을 정의해 넘기지 않아도 컴포넌트를 닫는 기본 동작이 가능 하도록 수정했습니다. 만약 Close 시점에 특정 Callback이 실행되는 것이 필요하다면 onClickClose을 정의해 넘기고 컴포넌트가 닫히기 전 해당 함수가 실행되도록 수정했습니다. (onClickClose 콜백 함수에 close를 구현하지 않아도 괜찮습니다. 추가적인 로직만 구현해서 넘겨주세요.)
const openDialog = useCallback(
    (props: OpenAlertDialogProps) => {
      const onClickClose = (e?: React.MouseEvent<HTMLButtonElement | HTMLDivElement>) => {
         if (props?.onClickClose instanceof Function) {
            props.onClickClose(e);
         }
         closeDialog();
      };
      alertContext.open({
         ...props,
         onClickClose,
         isOpen: true,
      });
   },
   [alertContext, closeDialog]
);

 

마치며

이번 포스팅을 통해 Context API를 사용해 Provider 패턴을 구현해봤습니다.
React를 사용하며 디자인 패턴을 구현해볼 기회가 있었고 특정 패턴들을 활용해 문제를 쉽게 해결할 수 있었습니다.
저와 같은 문제를 해결하고자 하시는 분들에게 좋은 공유가 되었으면 좋겠습니다.
읽어봐주셔서 감사합니다.