Context API 활용한 모달 상태관리

강동욱·2024년 8월 12일
0

적용 계기

개인설정 페이지를 작업하는 중 일반적으로 모달을 적용하려면 아래와 같은 불편함이 있었습니다.

 const [isOpen1, setOpen1] = useState(false);
  const [isOpen2, setOpen2] = useState(false);
  const [isOpen3, setOpen3] = useState(false);
  const [isOpen4, setOpen4] = useState(false);const handleClick1 = () => {
    setOpen1(true);
  };const handleClick2 = () => {
    setOpen2(true);
  };const handleClick3 = () => {
    setOpen3(true);
  };const handleClick4 = () => {
    setOpen4(true);
  };return (
    <div className="App">
      <button onClick={handleClick1}>모달1 열기</button>
      <button onClick={handleClick2}>모달2 열기</button>
      <button onClick={handleClick3}>모달3 열기</button>
      <button onClick={handleClick4}>모달4 열기</button>
      <Modal1 isOpen={isOpen1} />
      <Modal2 isOpen={isOpen2} />
      <Modal3 isOpen={isOpen3} />
      <Modal4 isOpen={isOpen4} />

한 컴포넌트안에 여러개의 모달을 띄워야 할때 다음과 같이 반복되는 현상이 일어날 수 있습니다.
이러한 코드의 불필요한 반복을 피하기 위해 Context API를 활용해 useModal이라는 커스텀훅을 작성하려합니다.

Context API

Context Provider는 다음과 같이 코드를 구성했습니다.

function ModalProvider({ children }: PropsWithChildren) {
  const [openModalState, setModalState] = useState<ModalStateContextType[]>([])

  const open = (element: ReactNode, id: number) => {
    setModalState((pre) => {
      return [...pre, { Component: element, modalId: id }]
    })
  }

  const close = (id: number) => {
    setModalState((pre) => {
      return pre.filter((element) => element.modalId !== id)
    })
  }

  const dispatch = useMemo(() => ({ open, close }), [])

  return (
    <ModalDispatchContext.Provider value={dispatch}>
      {children}
      {openModalState.map(({ Component, modalId }) => (
        <React.Fragment key={modalId}>{Component}</React.Fragment>
      ))}
    </ModalDispatchContext.Provider>
  )
}

일단 모달을 전역적으로 관리하기위해 배열형태로 만들기로 생각했습니다. 그 배열은 컴포넌트와 모달 ID 객체로 이루어진 배열입니다. 코드를 보시면 Modal들을 children옆에 map을 이용하여 렌더링해주고 있습니다. 이러한 이유는 간단히 말하자면 먼저 children이 무엇인가를 알아야합니다. children으로 전달된 컴포넌트는 React.createElement의 리턴값인 element 즉 불변 객체입니다. 그러므로 openModalState에 의해서 리렌더링이 되어도 하위컴포넌트들은 렌더링이 되지 않습니다. 만약 Provider value prop에 openModalState를 넣었다면 그 값을 사용하는 컴포넌트의 하위 컴포넌트까지 렌더링이 됩니다. 이러한 이유로 children 옆에 값을 넣어놨습니다. children과 관련된 렌더링은 제가 정리한 children, 자식 컴포넌트의 리렌더링에 관하여를 참고하시면 될 것 같습니다.

useModal

const useModal = () => {
  const context = useContext(ModalDispatchContext)

  if (context == null) {
    throw new Error('ModalDispatProvider 안에서만 사용할 수 있습니다.')
  }

  const { open, close } = context

  const openModal = (modalComponent: CreateModalElement) => {
    const modalId = Date.now()

    open(
      <ModalController
        modalElement={modalComponent}
        exitFromModalArr={() => close(modalId)}
      />,
      modalId,
    )
  }

  return { openModal }
}

openModal은 인자로 활성화할 모달컴포넌트를 받습니다.
open함수를 이용해서 ModalController를 exitFromModalArr에는 저희가 아까 Context provider에서 작성한 close를 실행시킬 함수를 넘깁니다.

ModalController

function ModalController(
  { modalElement: ModalElement, exitFromModalArr }: ModalPropsType,
) {
  const [isOpen, setIsOpen] = useState(true)
  const handleModalClose = useCallback(() => {
    setIsOpen(false)
    // 메모리에서 완전히 모달을 삭제 하기 위함이고 아래 코드를 추가 안하면 모달 animation이 바로 적용이 안된다.
    setTimeout(() => {
      exitFromModalArr()
    }, 100)
  }, [exitFromModalArr])

  return <ModalElement isOpen={isOpen} close={handleModalClose} />
}

ModalController는 모달을 열고 닫는 기능을 담당하기 위한 컴포넌트 입니다. 이렇게 Controller 컴포넌트로 한단계 추상화하면서 일정한 틀안에서 자유롭게 각각의 모달들을 선언할 수 있었습니다. 또한 모달의 열고 닫는 상태 관리에 대한 반복적인 코드의 사용을 줄일 수 있습니다. 모달을 사용하는 코드 예시는 다음과 같습니다.

function Modal () {
  	const {openModal} = useModal()
	const openNicknameHandler = () => {
    	openModal(({ isOpen, close }) => (
      		<UserSettingModal
        	isOpen={isOpen}
        	title="닉네임 변경"
        	submitHandler={(value: unknown) => {
          	mutate({ value, type: 'nickname', email: userEmail })
          	close()}}
        	closeHandler={close}
          />
        )}
      />
    ))
  }
  
  const openJobHandler = () => {
    openModal(({ isOpen, close }) => (
      <UserSettingModal
        isOpen={isOpen}
        title="산업분야 변경"
        submitHandler={(value: unknown) => {
          mutate({ value, type: 'occupation', email: userEmail })
          close()
        }}
        closeHandler={close}
        renderItem={(setPostValue) => (
          <UserSettingList
            listData={USER_INFO_OCCUPATION}
            wrap
            setPostValue={setPostValue}
            initialItem={userProfile?.occupation as string}
          />
        )}
      />
    ))
  }
  
  return <button onclick={openNicknameHandler}>모달 열기</button>
}

궁금점

context api를 open을 통해서 모달을 열고 닫는데 왜 또 다시 ModalController에 isOpen을 통해서 모달을 열고 닫는 state를 추가했나요?

ModalController와 같은 기능이 없다면 현재 열린 모달의 상태를 추적할 수 없습니다. 왜냐하면 저희는 리렌더링을 피하기 위해 children 옆에 Modal state를 설정해주었기 때문입니다. 물론 추적할 수 있는 다른 방식도 있습니다 예를 들면 아래와 같습니다.

export default function ModalProvider({ children }: PropsWithChildren) {
  const [openModalState, setModalState] = useState<ModalStateContextType[]>([])

  const open = (element: ReactNode, id: number) => {
    setModalState((pre) => {
      return [...pre, { Component: element, modalId: id }]
    })
  }

  const close = (id: number) => {
    setModalState((pre) => {
      return pre.filter((element) => element.modalId !== id)
    })
  }

  const dispatch = useMemo(() => ({ open, close }), [])

  return (
    <ModalDispatchContext.Provider value={dispatch}>
  	  <ModalStateContext.Provider value={openModalState}>
      {children}
	  </ModalStateContext.Provider>
    </ModalDispatchContext.Provider>
  )
}

위와같이 작성하면 ModalState 컨텍스트를 따로 만들어주고 사용하면 추적할 수 있습니다.

export default Page () {

retunr <Modals/>
}

// Modals.tsx
export default Modals () {
	const state = useContext(ModalStateContext)
    
    return 
  	  <div>
      {openModalState.map(({ Component, modalId }) => (
        <React.Fragment key={modalId}>{Component}</React.Fragment>
      ))}
      <div>
}
// Modal      
function Modal () {
  	const {open, close} = useContext(ModalDispatch)
	const openNicknameHandler = () => {
      const modalId = Date.now()
    	open(
      		<UserSettingModal
          		modalId={modalId}
        		isOpen={isOpen}
        		title="닉네임 변경"
        		submitHandler={(value: unknown) => {
	          		mutate({ value, type: 'nickname', email: userEmail })
          			close()
              	}}
        		closeHandler={close}
          />, modalId)
	}
  }
  
// UserSettingModal
function UserSettingModal({modalId}) {
  const state = useContext(ModalState)
  const [isOpen, setIsOpen] = useState(() => {
  	return state.filter((el) => el.modalId === modalId).length !== 0
  })
  
  
  return <div> 모달과 관련된 코드</div>
}

ModalController를 사용하지 않을때는 다음과 같은 불편함이 있습니다.

  • 전체적인 모달들을 렌더링해 줄 컴포넌트를 만들어야하고 하위 컴포넌트 리렌더링이 안되기위한 Provider와 같은 컴포넌트를 만든 뒤 감싸줘야한다.
  • 모달이 열렸을 때 그에관한 로직을 작성하려고 상태를 추적하기 위해서는 매번 배열을 순회해서 가져와야한다.(반복되는 코드 작성)

위와 같은 이유로 ModalController 같은 컴포넌트로 모달의 열고 닫는 기능을 추상화 해주면 위와같은 문제를 해결할 수 있습니다.

profile
차근차근 개발자

0개의 댓글