Модальные окна - важная часть UI современных веб-приложений. Управление ими в React может вызвать трудности, в частности, когда нужно избежать одновременного появления нескольких окон. Для этого и существует хук useModalControl, который облегчает эту задачу.

Проблема

Разработчики регулярно сталкиваются с задачей контроля за состояниями множества модальных окон. Отсутствие централизованного управления может привести к путанице в процессах открытия и закрытия окон, что, в свою очередь, увеличивает риск возникновения ошибок и ухудшает общий пользовательский опыт.

Решение

Хук useModalControl предоставляет практичное решение для управления модальными окнами. Этот инструмент дает разработчикам возможность контролировать открытие и закрытие окон через простой и интуитивно понятный API. С useModalControl вы можете без труда предотвратить одновременное открытие нескольких окон, что способствует поддержанию порядка и чистоты в коде.

Live Demo

Результат
Результат

Как это работает

Хук useModalControl использует единый флаг в глобальном хранилище для управления всеми модальными окнами приложения. Это исключает риск случайного открытия неправильного окна или одновременного появления нескольких окон. Благодаря строгой типизации, вы всегда будете в курсе, какие варианты окон доступны для использования. Кроме того, useModalControl позволяет передавать в модальное окно дополнительные данные, которые будут актуальны исключительно для текущего открытого окна и сохранятся до его закрытия. Типизация передаваемых данных обеспечивает дополнительную уверенность и удобство в использовании хука.

Пошаговая инструкция: Реализация на стеке React, Redux Toolkit, TypeScript

Шаг 1.
Создаем файл с уникальными идентификаторами для наших модальных окон. Этот файл будет служить централизованным хранилищем имен окон, и его необходимо обновлять при добавлении новых модальных окон в приложение.

// modalNames.ts

export const ModalNames = {
  reset: "reset" as const,
  loading: "loading" as const,
  success: "success" as const,
  error: "error" as const,
  warning: "warning" as const,
  product: "product" as const,
}

Ключевое слово as const в конце необходимо для того, чтобы хук useModalControl возвращал точные названия доступных модальных окон. Это обеспечивает строгую типизацию и помогает избежать ошибок при работе с модальными окнами. Далее вы увидите, как это применяется на практике.

Благодаря возможности хука useModalControl передавать дополнительные данные в конкретное модальное окно, мы можем улучшить типизацию этих данных. Для каждого модального окна мы точно определим тип данных, который оно может принять. Если модальное окно не предполагает принятия данных, используем тип void. Этот подход облегчает понимание, какие данные доступны для каждого конкретного модального окна, и исключает риск передачи некорректных данных.

// modalNames.ts

type ModalNameChecker<T extends { [K in keyof typeof ModalNames]: unknown }> = T

export type SpecificModalDataType = ModalNameChecker<{
  reset: void
  loading: void
  success: string
  error: string
  warning: void
  product: ProductType
}>

Весь файл целиком

// modalNames.ts

import { ProductType } from "../../types"

export const ModalNames = {
  reset: "reset" as const,
  loading: "loading" as const,
  success: "success" as const,
  error: "error" as const,
  warning: "warning" as const,
  product: "product" as const,
}

type ModalNameChecker<T extends { [K in keyof typeof ModalNames]: unknown }> = T

export type SpecificModalDataType = ModalNameChecker<{
  reset: void
  loading: void
  success: string
  error: string
  warning: void
  product: ProductType
}>

Шаг 2.
Создаем срез modalSlice в глобальном хранилище, который будет отвечать за хранение уникального имени активного модального окна и, при необходимости, дополнительных данных, связанных с этим окном.

// modalSlice.ts

import type { PayloadAction } from "@reduxjs/toolkit"
import { createSlice } from "@reduxjs/toolkit"
import {
  ModalNames,
  SpecificModalDataType,
} from "../hooks/useModalControl/modalNames"

export type ModalSliceType<
  T extends keyof typeof ModalNames = keyof typeof ModalNames,
> = T extends infer K
  ? K extends T
    ? {
        modalData: {
          name: K
          value: SpecificModalDataType[K]
        }
      }
    : never
  : never

const initialState: ModalSliceType = {
  modalData: { name: ModalNames.reset, value: undefined },
}

export const modalSlice = createSlice({
  name: "modal",
  initialState,
  reducers: {
    setModalData: (state: ModalSliceType, action: PayloadAction<any>) => {
      state.modalData = action.payload
    },
  },
  selectors: {
    selectModalData: state => state.modalData,
  },
})

// Action creators
export const { setModalData } = modalSlice.actions

// Selectors
export const { selectModalData } = modalSlice.selectors

Шаг 3. хук useModalControl

// useModalControl.ts

import { useDispatch, useSelector } from "react-redux"
import { ModalNames, SpecificModalDataType } from "./modalNames"
import { capitalizeFirstLetter } from "./utils/capitalizeFirstLetter"
import {
  ModalSliceType,
  selectModalData,
  setModalData,
} from "../../store/modalSlice"

type ModalNameKeys = keyof typeof ModalNames
type ModalKeysType = {
  [K in ModalNameKeys as `is${Capitalize<K>}Modal`]: boolean
}
type ModalDataType = {
  [K in ModalNameKeys as `${K}ModalData`]?: SpecificModalDataType[K]
}

type ModalControlType = ModalKeysType & {
  options: {
    modalData: ModalDataType
    openModal: <K extends ModalNameKeys>(
      key: K,
      data?: SpecificModalDataType[K],
    ) => void
    closeModal: () => void
  }
}

export const useModalControl = (): ModalControlType => {
  const currentModalData = useSelector(selectModalData)

  const dispatch = useDispatch()

  const matches = {} as ModalKeysType
  let ModalKey: keyof typeof ModalNames
  for (ModalKey in ModalNames) {
    const key =
      `is${capitalizeFirstLetter(ModalKey)}Modal` as keyof ModalKeysType
    matches[key] = ModalNames[ModalKey] === currentModalData.name
  }

  return {
    ...matches,
    options: {
      modalData: {
        [`${currentModalData.name}ModalData`]: currentModalData.value,
      },
      openModal: (key, data) => {
        dispatch(
          setModalData({
            name: key,
            value: data,
          } as ModalSliceType["modalData"]),
        )
      },
      closeModal: () => {
        dispatch(
          setModalData({
            name: ModalNames.reset,
            value: undefined,
          }),
        )
      },
    },
  }
}

Вспомогательная функция для перевода первой буквы в верхний регистр capitalizeFirstLetter

// capitalizeFirstLetter.ts

export const capitalizeFirstLetter = (str?: string | undefined) => {
  if (!str) return ""

  return str.replace(/^\w/, c => c.toUpperCase())
}

useModalControl возвращает объект, который не только содержит идентификаторы для каждого окна, а также объект options, который содержит: openModal — функция для активации открытия модального окна, и closeModal — функция для его закрытия, объект modalData предоставляет детализированную информацию для каждого окна.

Возвращаемые значения
Возвращаемые значения
options
options
modalData
modalData

После того как вы выполните указанные шаги, хук useModalControl будет готов к использованию в вашем приложении.

Примеры использования

1. Открытие и закрытие модальных окон

Добавляем обработчики событий onClick
Добавляем обработчики событий onClick
С помощью хука useModalControl для каждого модального окна задается свой уникальный флаг
С помощью хука useModalControl для каждого модального окна задается свой уникальный флаг
Интегрируем компоненты с модальными окнами и кнопками в компонент App
Интегрируем компоненты с модальными окнами и кнопками в компонент App
Результат открывания окон по нажатию на кнопки
Результат открывания окон по нажатию на кнопки
  1. Пример с передачей данных в модальное окно

    На примере таблицы с продуктами реализуем открытие модального окна для каждой позиции и передачу в него данных.

Добавляем идентификатор для модального окна, затем определяем тип данных, которые будем передавать.
Добавляем идентификатор для модального окна, затем определяем тип данных, которые будем передавать.
Навешиваем обработчик на клик по каждому ряду таблицы, передавая в функцию открытия окна идентификатор и данные, соответствующие ожидаемому типу.
Навешиваем обработчик на клик по каждому ряду таблицы, передавая в функцию открытия окна идентификатор и данные, соответствующие ожидаемому типу.
Принимаем переданные данные в модальном окне для каждой продуктовой позиции.
Принимаем переданные данные в модальном окне для каждой продуктовой позиции.
Размещаем модальное окно с информацией о продукте в компоненте App
Размещаем модальное окно с информацией о продукте в компоненте App
Результат!!!
Результат!!!

Резюме

Хук useModalControl делает работу с модальными окнами простой и удобной.
Благодарю за внимание к статье! Ваша обратная связь будет очень ценной!

Репозиторий с приложением можно найти на GitHub
Демонстрация приложения здесь
Мой профиль Linkedin

Комментарии (18)


  1. adminNiochen
    12.06.2024 14:55

    Это джун писал?

    Всё начинает идти не так с описания шага 1. ModalNames у нас какого-то лешего с большой буквы, хотя это не тип, и даже не енум, и уж тем более не класс.

    "Строгая типизация обеспечивает дополнительную уверенность и удобство" - нет, она не работает в рантайме. Тайпскрипт никак не запретит вызвать второе окно когда открыто первое.

    Ключи в объекте, задаваемые через манипуляцию со строками, и функция capitalizeFirstLetter - это что вообще за нафиг??

    По функционалу - конечно же эта приблуда не дружит с анимациями исчезновения, это можно заметить даже визуально когда пропадает попап с данными (остаются названия полей, а значений нет) и layout shift может быть гораздо больше если там картинка какая или большой текст. А если в одном попапе юзер нажмёт условное "да", а скажем средств не хватает и юзеру нужно показать ошибку? Где закрытие одного (ждем анимацию) и только потом открытие следующего?

    И вишенка на торте - это ни разу не модалка а попап. А бизнесу может быть надо функционал именно модалки, что её не закроешь пока допустим юзер не тыкнет "да согласен".


    1. Denis_Karpiuk Автор
      12.06.2024 14:55

      И вишенка на торте - это ни разу не модалка а попап.

      В статье показал как работаю с открытием и закрытием Модальных окон, конкретно в примере испльзуется Dialog из Material UI, вот выжимка из документации:

      A Dialog is a type of modal window that appears in front of app content to provide critical information or ask for a decision. 

      По функционалу - ....,

      Все, что вы описали можно легко реализовать при помощи этого великолепного инструмента, я показал основной функционал.

       что её не закроешь пока допустим юзер не тыкнет "да согласен".

      Возможно, когда будет больше времени, специально напишу статью, о том как добавить кнопку "да согласен" в модальное окно и повесить на нее обработчик. Не думал, что это вызывает трудность в понимании.


      1. adminNiochen
        12.06.2024 14:55

        Прежде чем в меня кидаться докой из ui библиотеки, можно почитать доку родного html диалога и в чем там разница между модальным и немодальным диалогом, чтобы не мешать тёплое с мягким.

        Ну а по поводу анимации - это не опять же не придирка. Одна из главных проблем модалок/попапов - когда их может быть не одна, нужно грамотно ждать пока закроется одно прежде чем открывать другое. Хранить состояние диалогового окна в ячейке стора, как это показано в примере статьи - тут много ума не надо, это может сделать любой за 5 минут


  1. gun_dose
    12.06.2024 14:55

    Очень много кода ради очень примитивного функционала. Например, непонятно, как можно в эту модалку всунуть, к примеру, форму, которая будет что-то делать с продуктом из таблицы, а потом автоматически закрываться и обновлять строку таблицы.

    Также из такой модалки не получится открыть дочернюю модалку, а это достаточно распространённый кейс.


    1. Denis_Karpiuk Автор
      12.06.2024 14:55

      Например, непонятно, как можно в эту модалку всунуть, к примеру, форму ...

      В модалку можно всунуть что угодно, вот очередная форма в модальном окне в моей работе

      Также из такой модалки не получится открыть дочернюю модалку, 

      В нашей команде это плохой подход - модалка в модалке, но в любом случае статья показывает подход, если не подходит под ваши задачи, то жаль конечно

      Очень много кода ради очень примитивного функционала

      Если поделитесь своим решением этой проблемы, буду признателен, желательно так же подробно как я в статье


      1. gun_dose
        12.06.2024 14:55

        Если поделитесь своим решением этой проблемы

        Не надо ничего изобретать. Есть библиотека react-modal, которая отлично работает. Также модалки отлично реализованы в Material UI, Ant Design, reactstrap и т.д. Когда модалка - это компонент, то это значительно удобнее, чем хуки. Кстати говоря, в соседнем комментарии на Vue предлагают именно компонентный подход.


        1. Denis_Karpiuk Автор
          12.06.2024 14:55

          модалки отлично реализованы в Material UI, Ant Design, reactstrap и т.д. Когда модалка - это компонент, то это значительно удобнее, чем хуки


          Я испльзую Material UI, но удобного способа закрывать и открывать + передавать данные в модальные окна я не нашел и придумал свой, которым поделился.

          Статья не о создании модального окна, а об управлении сосотянием модальных окон при помощи хука.

          Если будет, что дополнить по существу, то пишите, с удовольствием вникну в ваше предложение, а просто перечислять название UI библиотек не имеет смысла.


  1. hackteck
    12.06.2024 14:55
    +1

    Пишу на Vue.js, но думаю, что это применимо и к реакту: избежать хаоса можно создав компонент, который содержит компонент для диалога, у которого если есть слот, то при нажатии на него открывается этот диалог либо же если его нет, то диалог открывается автоматически.

    Сильно упрощённый пример:

    <SomeCustomDialog v-slot="{ onShow }">
      <Button @click="onShow" />
    </SomeCustomDialog>

    SomeCustomDialog.vue

    <div class="some_dialog_wrapper">
       <slot :onShow="showDialog" />
       <Dialog
         v-if="dialogActive"
         v-model="dialogActive"
       >
         Dialog Content
       </Dialog>
    </div>


    1. Denis_Karpiuk Автор
      12.06.2024 14:55

      Спасибо, что поделились своим подходом.

      А как вы передаете данные в вашем подходе?
      Например из таблицы, как в моем примере?


      1. hackteck
        12.06.2024 14:55

        Практически всегда это обычные события, в прям очень редких исключениях использую хранилище

        <SomeCustomDialog ... @someData="onSomeCustomDialogData($event)">

        Внутри компонента опять же практически всегда событие вызываю на закрытие диалога:

        <Dialog ... @onClose="emit('someData', someData)">


      1. Neoldian
        12.06.2024 14:55

        Отвечу за свои проекты. Тоже на Vue пишу в основном, модалки используем самописные, через portal(vue2) или teleport(vue3) для глобализации. Конкретно пример с модалками "форма записи в таблице", - в слотах передаем компоненты, которые уже содержат нужные пропсы типизированные, например "id продукта", сама форма уже работает с сервисом для загрузки и сохранения. Т.е. подход для форм немного другой, всё по тому, что от бизнеса была задача сделать "выбор" - открываем в модалке или новом окне, и очевидно компонент переиспользется во вьюхе и пропсы идут из компонента обертки в роутере. Поэтому у нас модалки это просто вариант отрисовки, не более. Ну и реально довольном частый кейс это модалка в модалке, например в форме "продукт" поле-лукап содержащий сложный грид с фильтрацией и прочем, и в этот гридьмолно добавить новую запись прямо из грида через форму, итого уже 3 модалки. Пока не было проблем с логикой и перекрытием отрисовки т.к. работает простой принцип first on first out.


        1. Denis_Karpiuk Автор
          12.06.2024 14:55

           в слотах передаем компоненты ...

          Спасибо за ваш пример подхода.
          Я пишу на React и не знаю такого понятия как слот.

           Ну и реально довольном частый кейс это модалка в модалке

          У нас так не принято, но в любом случае при желании можно доработать мое решение и под такую задачу.


          1. hackteck
            12.06.2024 14:55

            Я пишу на React и не знаю такого понятия как слот.

            Самая близкая аналогия slot в react это props.children. Подход в коде другой, но смысл один и тот же: передача вложенного контента в компоненты.

            Я не силён в реакте, вот примерная реализация моего кода выше

            const ParentComponent = () => {
              return (
                <SomeCustomDialog
                  render={({ onShow }) => (
                    <button onClick={onShow}>Show Dialog</button>
                  )}
                />
              );
            }
            const SomeCustomDialog = ({ children }) => {
              const [dialogActive, setDialogActive] = useState(false);
            
              const onShow = () => setDialogActive(true);
              const onHide = () => setDialogActive(false);
            
              return (
                <div className="some_dialog_wrapper">
                  {children({ onShow })}
                  {dialogActive && (
                    <Dialog isOpen={dialogActive} onClose={onHide}>
                      Dialog Content
                    </Dialog>
                  )}
                </div>
              );
            };


            1. Denis_Karpiuk Автор
              12.06.2024 14:55

              Как применить этот подход к таблице?

              В моей реализации я при мапинге рядов таблицы вешаю обработчик на каждый клик по каждому ряду

              Само модальное окно находится в родительском компоненте, ну или даже если бы оно находилось бы здесь, то мне нужно в таком случае обернуть в модалку каждый ряд таблицы ?


              1. Denis_Karpiuk Автор
                12.06.2024 14:55

                Вот таким образом работает ваш подход в React, благодарю за ваш вариант, тоже можно рассмотреть как подход.


        1. Denis_Karpiuk Автор
          12.06.2024 14:55

          например "id продукта", сама форма уже работает с сервисом для загрузки и сохранения.

          Именно так и делаю в основном, т.е при нажатии на строку таблицы я отправляю в стор ключ нужного мне модального окна и к примеру productId. Внтури модального окна компонент, который получает этот productId из хука и выполняет нужные действия: запрашивает продукт, формирует дефолтные данные для формы, обрабатывает занчения формы и т.д

          В стате, я показал как с этим можно работать на простых примерах, но основную идею думаю донес, как применять решать уже читателям)

          Благодарю за внимание!


          1. merrick_krg
            12.06.2024 14:55

            Тоже делал свою утилитку как то для управления модальными окнами - https://www.npmjs.com/package/react-modaly

            С тех пор ее и использую. Данные передаю напрямую в компонент, который обернут модальным окном.


            1. Denis_Karpiuk Автор
              12.06.2024 14:55

              Да посмотрел ваше решение, до публикации статьи хук который я испльзую назывался useDialogControl, т.к пользуюсь Dialog из Material UI, но т.к статья про модальные окна решил назвать useModalControl)