Published 20 Nov 2023

建立一個 Modal 狀態控制中心

告別散亂的 Modals,用 React Context 管理 Application 中所有 Modal

ui architecutre
modal
context
建立一個 Modal 狀態控制中心

小弟仍然在網頁開發的學習道路上,因此本文並不算是教程,只是小弟在學習過程中發現到的覺得值得分享的編程做法。由於寫作水平有限,很多術語實在不知道怎麼翻譯,因此會有不少中英夾雜的情況,實在抱歉。如文中的語句或邏輯不清晰,請見諒。歡迎對文章分享您的看法以及聯絡交流,謝謝!

Modal(對話窗)是前端開發中必不可少的組件,很多時候雨用戶互動需要用到 Modal。接下來以用戶在網上購物的流程作一個例子:

  1. 當用戶要刪除購物車中的物件時,會彈出 Alert Modal 讓用戶再次確認
  2. 當需要用戶提供寄送地址以完成購物時,會彈出 Form Modal
  3. 當用戶成功付款後,會彈出 Success Modal

僅僅是購物的流程就已經出現了三個不同的 Modal,如果要構建一個更大規模的網站,需要用到的 Modal 種類可能有 10 種以上,而且有些 Modal 會很相似,可能只是修改了標題和提醒信息。在剛開始寫 React 時,每次需要用到 Modal 的時候,對於結構相似的 Modal,會把 Modal 的結構拆分成 title, message, confirm button text, onConfirmCallback 等等的 props,讓 Modal 在不同的場景重用,同時也方便維護。但是還是隨著網站的規模逐漸變大的時候,每個新功能幾乎都需要添加新的 Modal。Modal 的數量和種類就會越來越多。這時候如果仍然是像例子一中,在所有需要用到 Modal 的組件中插入 Modal 的話,

例子一:
ComponentA.ts
 
import React, { useState } from 'react'
 
const ComponentA = () => {
const [openModal, setOpenModal] = useState<boolean>(false)
 
  return (
    <>
    <ConfirmModal
    open={openModal}
    setOpen={setOpenModal}
    title={title}
    onPressConfirm={confirmHandler}
 
    ...other props
    />
 
    ...other component code
    </>
  )
}
 
export default ComponentA
 

個人覺得會不利於 Modal 的管理,在參考了網上其他程序員的各種解決方法後,發現用 React Context 把 Modal 的類型和狀態集中管理是個不錯的方法。以下是用 Context 管理 Modal 的例子:

首先創建管理 Modal States 的 Context Provider,為了更清晰地管理 Modal。這個 Provider 實際上包括了 2 個 Context, 其中 ModalContext 管理 isModalActive(是否打開 Modal)和 activeModals(打開 Modal 的種類)兩個狀態。ModalControlContext 管理 openModal(打開 Modal 的動作)和 closeModal(關閉 Modal 的動作)。需要留意的是,每個 Modal 的都需要有 name 這個 prop,因為稍後會用 name 來找出應該打開的 Modal Component

src/contexts/modals/index.ts
 
import React from 'react'
 
// 把所有Modal的Type都放在這裡方便管理,每個這裡以ConfirmModal和AlertModal為例子:
export type ConfirmModal = {
  name: 'confirm'
  title: string
  message: string | (() => JSX.Element)
  onPressConfirm: () => void | Promise<void>
  onPressCancel?: () => void | Promise<void>
  confirmBtnText?: string
  confirmBtnStyle?: string
  cancelBtnText?: string
}
 
export type AlertModal = {
  name: 'Alert'
  title: string
  message: string | (() => JSX.Element)
  onPressConfirm: () => void | Promise<void>
  onPressCancel?: () => void | Promise<void>
  confirmBtnText?: string
  confirmBtnStyle?: string
  cancelBtnText?: string
}
 
// Modal 的Type是選擇性的,方便在其他部件中取用Modal時會按Modal類型提供不同的props提示
export type Modal = ConfirmModal | AlertModal | other modals here...
 
type ModalContext = {
  isModalActive: boolean
  activeModals: Modal[]
}
 
type ModalControlContext = {
  openModal: (modal: Modal) => void
  closeModal: () => void
}
 
const ModalContext = React.createContext<ModalContext>({
  isModalActive: false,
  activeModals: [],
})
 
const ModalControlContext = React.createContext<ModalControlContext>({
  openModal: () => {},
  closeModal: () => {},
})
 
export const Provider = ({ children }: React.PropsWithChildren<{}>) => {
  const [isModalActive, setIsModalActive] = React.useState(false)
  const [activeModals, setActiveModals] = React.useState<Modal[]>([])
 
  const openModal = React.useCallback(
    (modal: Modal) => {
      setActiveModals(activeModals => [...activeModals, modal])
      setIsModalActive(true)
    },
    [setIsModalActive, setActiveModals],
  )
 
  const closeModal = React.useCallback(() => {
    let totalActiveModals = 0
    setActiveModals(activeModals => {
      activeModals = activeModals.slice(0, -1)
      totalActiveModals = activeModals.length
      return activeModals
    })
    setIsModalActive(totalActiveModals > 0)
  }, [setIsModalActive, setActiveModals])
 
  const state = React.useMemo(
    () => ({
      isModalActive,
      activeModals,
    }),
    [isModalActive, activeModals],
  )
  const methods = React.useMemo(
    () => ({
      openModal,
      closeModal,
    }),
    [openModal, closeModal],
  )
 
  return (
    <ModalContext.Provider value={state}>
      <ModalControlContext.Provider value={methods}>
        {children}
      </ModalControlContext.Provider>
    </ModalContext.Provider>
  )
}
 
export function useModals() {
  return React.useContext(ModalContext)
}
 
export function useModalControls() {
  return React.useContext(ModalControlContext)
}

創建 ConfirmModal, 在 ConfirmModal 調用剛剛創建的 type ConfirmModal,useModalControls 和 useModals, 以控制 Modal 的狀態

src / components / common / modals / confirm - modal.tsx
 
import { useState } from "react"
 
import ErrorMessage from "../error-message"
 
import { Button } from '@/components/ui/button'
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
} from '@/components/ui/dialog'
 
// 調用剛剛創建
import { ConfirmModal, useModalControls, useModals } from '@/contexts/modals'
import { cleanError, cn } from '@/lib/utils'
 
export const Component = ({
  title,
  message,
  onPressConfirm,
  onPressCancel,
  confirmBtnText,
  confirmBtnStyle,
  cancelBtnText,
}: ConfirmModal) => {
  const { closeModal } = useModalControls()
	const { isModalActive } = useModals()
 
  const [error, setError] = useState<string>('')
 
  const onConfirm = async () => {
    try {
      setError('')
      await onPressConfirm()
      closeModal()
    } catch (e: any) {
      setError(cleanError(e))
    }
  }
 
  const onCancel = () => {
    onPressCancel?.()
    closeModal()
  }
 
   return (
    <Dialog open={true}>
      <DialogContent className="sm:max-w-[425px]">
        <DialogHeader>
          <DialogTitle>{title}</DialogTitle>
        </DialogHeader>
 
        {typeof message === 'string' ? (
          <DialogDescription>{message}</DialogDescription>
        ) : (
          message()
        )}
 
        {error && <ErrorMessage error={error} />}
 
        <DialogFooter>
          <Button onClick={onCancel} size="sm" variant={'outline'}>
            {cancelBtnText || 'Cancel'}
          </Button>
          <Button
            className={cn(confirmBtnStyle)}
            isLoading={isProcessing}
            onClick={onConfirm}
            size="sm"
          >
            {confirmBtnText || 'Confirm'}
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  )
}
 

有了控制中心後,還需要一個存放所有 Modal 的倉庫,這個倉庫叫 ModalContainer(名字可按需修改)。

src / components / common / modals / index.tsx;
 
// 引入ConfirmModal 在ModalContainer 登記
import * as ConfirmModal from "./src/components/common/modals/confirm-modal";
 
// 從剛剛做好的Modal Provider 取得Context
import { useModals } from ".src/contexts/modals";
 
export const ModalsContainer = () => {
  const { activeModals } = useModals();
 
  // 創建空的element
  let element;
 
  // 找到應該打開的 Modal
  const activeModal = activeModals[activeModals.length - 1];
 
  // 按照Modal的name找到Modal並放到element變數
  switch (activeModal?.name) {
    case "confirm":
      element = <ConfirmModal.Component {...activeModal} />;
      break;
    default:
      element = null;
  }
 
  // 返回element => 渲染需要打開的Modal
  return <>{element}</>;
};

最後,在 app 中插入 ModalStateProvider 和 ModalsContainer, 讓整個 App 都可以調用全部 Modals

/src/pages/_app.tsx
 
import { Provider as ModalStateProvider } from 'src/contexts/modals';
import { ModalsContainer } from 'src/components/common/modals';
 
export default function App({ Component, pageProps }: AppProps) {
  return (
        <ModalStateProvider>
              <Component {...pageProps} />
            <ModalsContainer />
        </ModalStateProvider>
  );
}

例如在 Home Page 頁面調用 Confirm Modal。這時,只需要用 openModal 函數既即可以打開 Confirm Modal, 並設定該 Modal 的內容。

/src/pages/index.tsx
 
import {  useModalControls } from '@/contexts/modals'
 
export default function Home() {
  const {openModal} = useModalControls()
 
  const handleOpenModal = ()=>{
    openModal({
      name:'confirm',
      title: 'Purchase Success'
      message: 'Congrets! You have successfully paid the bill!'
      onPressConfirm: ()=>{ ...do something here }
      confirmBtnText?: 'Ok'
      cancelBtnText?: 'Cancel'
    })
  }
 
    return (
      <div>
        <button onClick={handleOpenModal}>Open Modal</button>
      </div>
    );
  }
 

有了這個ModalStateProvider之後,所有 Modal 都可以統一放在Modal Container,在 App 的任何組件中都可以方便調用,而不需要再重新再組件中設定 Modal 的 active 狀態。

Lion Shi
Lion ShiWeb Developer