Как я могу отобразить модальный диалог в Redux, который выполняет асинхронные действия?

240

Я создаю приложение, которое должно показывать диалог подтверждения в некоторых ситуациях.

Допустим, я хочу что-то удалить, затем я отправлю действие, подобное тому, deleteSomething(id)чтобы какой-то редуктор перехватил это событие и заполнил редуктор диалога, чтобы показать его.

Мое сомнение приходит, когда этот диалог подчиняется.

  • Как этот компонент может отправлять правильное действие в соответствии с первым отправленным действием?
  • Должен ли создатель действия справиться с этой логикой?
  • Можем ли мы добавить действия внутри редуктора?

редактировать:

чтобы было понятнее:

deleteThingA(id) => show dialog with Questions => deleteThingARemotely(id)

createThingB(id) => Show dialog with Questions => createThingBRemotely(id)

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

carlesba
источник
1
Я думаю, что в вашем случае состояние диалога (скрыть / показать) является локальным. Я бы решил использовать состояние реакции для управления отображением / скрытием диалогов. Таким образом, вопрос о «правильном действии по первому действию» исчезнет.
Мин

Ответы:

516

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

Отправка действия, чтобы показать модальное

this.props.dispatch({
  type: 'SHOW_MODAL',
  modalType: 'DELETE_POST',
  modalProps: {
    postId: 42
  }
})

(Конечно, строки могут быть константами; для простоты я использую встроенные строки.)

Написание редуктора для управления модальным состоянием

Затем убедитесь, что у вас есть редуктор, который просто принимает эти значения:

const initialState = {
  modalType: null,
  modalProps: {}
}

function modal(state = initialState, action) {
  switch (action.type) {
    case 'SHOW_MODAL':
      return {
        modalType: action.modalType,
        modalProps: action.modalProps
      }
    case 'HIDE_MODAL':
      return initialState
    default:
      return state
  }
}

/* .... */

const rootReducer = combineReducers({
  modal,
  /* other reducers */
})

Большой! Теперь, когда вы отправляете действие, state.modalпроизойдет обновление, включающее информацию о видимом в данный момент модальном окне.

Написание корневого модального компонента

В корне иерархии компонентов добавьте <ModalRoot>компонент, подключенный к хранилищу Redux. Он будет слушать state.modalи отображать соответствующий модальный компонент, перенаправляя реквизиты из state.modal.modalProps.

// These are regular React components we will write soon
import DeletePostModal from './DeletePostModal'
import ConfirmLogoutModal from './ConfirmLogoutModal'

const MODAL_COMPONENTS = {
  'DELETE_POST': DeletePostModal,
  'CONFIRM_LOGOUT': ConfirmLogoutModal,
  /* other modals */
}

const ModalRoot = ({ modalType, modalProps }) => {
  if (!modalType) {
    return <span /> // after React v15 you can return null here
  }

  const SpecificModal = MODAL_COMPONENTS[modalType]
  return <SpecificModal {...modalProps} />
}

export default connect(
  state => state.modal
)(ModalRoot)

Что мы здесь сделали? ModalRootчитает текущий modalTypeи modalPropsиз state.modalкоторого он подключен, и отображает соответствующий компонент, такой как DeletePostModalили ConfirmLogoutModal. Каждый модал является компонентом!

Написание конкретных модальных компонентов

Здесь нет общих правил. Это просто компоненты React, которые могут отправлять действия, читать что-то из состояния хранилища и просто оказываться модальными .

Например, DeletePostModalможет выглядеть так:

import { deletePost, hideModal } from '../actions'

const DeletePostModal = ({ post, dispatch }) => (
  <div>
    <p>Delete post {post.name}?</p>
    <button onClick={() => {
      dispatch(deletePost(post.id)).then(() => {
        dispatch(hideModal())
      })
    }}>
      Yes
    </button>
    <button onClick={() => dispatch(hideModal())}>
      Nope
    </button>
  </div>
)

export default connect(
  (state, ownProps) => ({
    post: state.postsById[ownProps.postId]
  })
)(DeletePostModal)

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

Извлечение презентационного компонента

Было бы неудобно копировать и вставлять одну и ту же логику компоновки для каждого «конкретного» модала. Но у вас есть компоненты, верно? Таким образом, вы можете извлечь презентационный <Modal> компонент, который не знает, что делают конкретные моды, но управляет тем, как они выглядят.

Затем конкретные модалы, такие как DeletePostModalмогут использовать его для рендеринга:

import { deletePost, hideModal } from '../actions'
import Modal from './Modal'

const DeletePostModal = ({ post, dispatch }) => (
  <Modal
    dangerText={`Delete post ${post.name}?`}
    onDangerClick={() =>
      dispatch(deletePost(post.id)).then(() => {
        dispatch(hideModal())
      })
    })
  />
)

export default connect(
  (state, ownProps) => ({
    post: state.postsById[ownProps.postId]
  })
)(DeletePostModal)

Вы должны придумать набор реквизитов, которые <Modal>могут быть приняты в вашем приложении, но я думаю, что у вас может быть несколько видов модалов (например, информационный модал, подтверждающий модал и т. Д.) И несколько стилей для них.

Доступность и скрытие при нажатии кнопки снаружи или клавиши Escape

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

Вместо того, чтобы давать вам советы по реализации этого, я предлагаю вам просто не реализовывать это самостоятельно. Трудно получить право, учитывая доступность.

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

Вы можете даже обернуть react-modalсвой собственный, <Modal>который принимает реквизиты, специфичные для ваших приложений и генерирует дочерние кнопки или другой контент. Это все просто компоненты!

Другие подходы

Существует несколько способов сделать это.

Некоторым людям не нравится многословность этого подхода, и они предпочитают иметь <Modal>компонент, который они могут визуализировать прямо внутри своих компонентов с помощью техники, называемой «порталы». Порталы позволяют вам визуализировать компонент внутри вашего, в то время как фактически он будет отображаться в предопределенном месте в DOM, что очень удобно для модальных объектов.

На самом деле, react-modalя уже ссылался на ранее сделанное внутренне, так что технически вам даже не нужно рендерить его сверху. Мне все еще приятно отделить модал, который я хочу показать, от компонента, который его показывает, но вы также можете использовать его react-modalнепосредственно из своих компонентов и пропустить большую часть того, что я написал выше.

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

Дан Абрамов
источник
35
Одна вещь, которую я бы посоветовал, - это чтобы редуктор поддерживал список модов, которые можно нажимать и высовывать. Как бы глупо это не звучало, я постоянно сталкивался с ситуациями, когда дизайнеры / типы продуктов хотят, чтобы я открывал модальное из модального, и было бы приятно позволить пользователям «вернуться».
Кайл
9
Да, безусловно, Redux позволяет создавать такие вещи, потому что вы можете просто изменить свое состояние на массив. Лично я работал с дизайнерами, которые, напротив, хотели, чтобы модалы были эксклюзивными, поэтому подход, который я написал, решает случайное вложение. Но да, вы можете иметь оба пути.
Дан Абрамов
4
По своему опыту я бы сказал: если модал связан с локальным компонентом (например, модал подтверждения удаления связан с кнопкой удаления), то проще использовать портал, в противном случае использовать избыточные действия. Согласитесь с @Kyle, у вас должна быть возможность открыть модал из модала. Он также работает по умолчанию с порталами, потому что они добавляются для того, чтобы документировать тело, чтобы порталы красиво накладывались друг на друга (пока вы не испортили все с z-index: p)
Себастьен Лорбер
4
@DanAbramov, ваше решение отличное, но у меня небольшая проблема. Ничего серьезного. Я использую Material-ui в проекте, при закрытии модального режима он просто отключается, а не «проигрывается» затухающая анимация. Наверное, нужно сделать какую-то задержку? Или держать каждый модал там как список внутри ModalRoot? Предложения?
Герар
7
Иногда я хочу вызвать определенные функции после закрытия модального окна (например, вызвать функции со значениями поля ввода внутри модального окна). Я бы передал эти функции как modalPropsдействию. Это нарушает правило сохранения сериализуемого состояния. Как я могу преодолеть эту проблему?
chmanie
98

Обновление : Реагировать 16,0 введенные порталы через ReactDOM.createPortal ссылку

Обновление : следующие версии React (Fiber: вероятно, 16 или 17) будут включать метод для создания порталов: ReactDOM.unstable_createPortal() ссылка


Используйте порталы

Дан Абрамов ответит первая часть в порядке, но включает в себя много шаблонного. По его словам, вы также можете использовать порталы. Я немного расширю эту идею.

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

Что такое портал?

Портал позволяет вам визуализировать непосредственно внутри document.bodyэлемента, который глубоко вложен в ваше дерево React.

Идея состоит в том, что, например, вы отображаете в теле следующее дерево React:

<div className="layout">
  <div className="outside-portal">
    <Portal>
      <div className="inside-portal">
        PortalContent
      </div>
    </Portal>
  </div>
</div>

И вы получите в качестве вывода:

<body>
  <div class="layout">
    <div class="outside-portal">
    </div>
  </div>
  <div class="inside-portal">
    PortalContent
  </div>
</body>

inside-portalУзел был переведен внутри <body>, вместо его обычного, глубоко вложенного места.

Когда использовать портал

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

Зачем использовать портал

Больше никаких проблем с z-index : портал позволяет вам выполнять рендеринг <body>. Если вы хотите отобразить всплывающее окно или раскрывающийся список, это действительно хорошая идея, если вы не хотите бороться с проблемами z-index. Элементы портала добавляются document.bodyв порядке монтирования, что означает, что, если вы не поиграете z-index, по умолчанию будет складываться порталы друг над другом в порядке монтирования. На практике это означает, что вы можете безопасно открыть всплывающее окно из другого всплывающего окна и быть уверенным, что второе всплывающее окно будет отображаться поверх первого, даже не думая об этом z-index.

На практике

Самое простое: использовать локальное состояние React: если вы думаете, что для простого всплывающего окна подтверждения удаления не стоит иметь шаблон Redux, тогда вы можете использовать портал, и это значительно упрощает ваш код. Для такого случая использования, где взаимодействие является очень локальным и на самом деле довольно подробным описанием реализации, действительно ли вы заботитесь о горячей перезагрузке, перемещении во времени, регистрации действий и обо всех преимуществах, которые приносит вам Redux? Лично я не использую местное государство в этом случае. Код становится таким простым, как:

class DeleteButton extends React.Component {
  static propTypes = {
    onDelete: PropTypes.func.isRequired,
  };

  state = { confirmationPopup: false };

  open = () => {
    this.setState({ confirmationPopup: true });
  };

  close = () => {
    this.setState({ confirmationPopup: false });
  };

  render() {
    return (
      <div className="delete-button">
        <div onClick={() => this.open()}>Delete</div>
        {this.state.confirmationPopup && (
          <Portal>
            <DeleteConfirmationPopup
              onCancel={() => this.close()}
              onConfirm={() => {
                this.close();
                this.props.onDelete();
              }}
            />
          </Portal>
        )}
      </div>
    );
  }
}

Все просто: вы все еще можете использовать состояние Redux : если вы действительно хотите, вы все равно можете использовать, connectчтобы выбрать, будет ли DeleteConfirmationPopupотображаться или нет. Поскольку портал остается глубоко вложенным в ваше дерево React, очень просто настроить поведение этого портала, потому что ваш родитель может передавать реквизиты на портал. Если вы не используете порталы, вы обычно должны отображать всплывающие окна в верхней части дерева React дляz-indexпричин, и обычно приходится думать о таких вещах, как «как мне настроить общий DeleteConfirmationPopup, который я построил в соответствии с вариантом использования». И обычно вы найдете довольно хакерские решения этой проблемы, такие как отправка действия, которое содержит вложенные действия подтверждения / отмены, ключ пакета перевода, или, что еще хуже, функцию рендеринга (или что-то еще, что невозможно разобрать). Вы не должны делать это с порталами, и можете просто пропустить обычные реквизиты, так как DeleteConfirmationPopupэто просто ребенокDeleteButton

Вывод

Порталы очень полезны для упрощения вашего кода. Я не мог обойтись без них больше.

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

  • доступность
  • Espace ярлыки, чтобы закрыть портал
  • Обрабатывать внешний клик (закрыть портал или нет)
  • Обрабатывать клик по ссылке (закрыть портал или нет)
  • React Context сделанный доступным в дереве портала

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

response-tether неизвестен большинству разработчиков React, но это один из самых полезных инструментов, которые вы можете там найти. Tether позволяет вам создавать порталы, но автоматически позиционирует портал относительно заданной цели. Это идеально подходит для всплывающих подсказок, выпадающих списков, горячих точек, справочных ящиков ... Если у вас когда-либо возникали проблемы с позицией absolute/ relativeи z-indexвашим выпадающим списком, выходящим за пределы области просмотра, Tether решит все это за вас.

Например, вы можете легко реализовать горячие точки, которые открываются во всплывающую подсказку:

Бортовая точка доступа

Реальный производственный код здесь. Не может быть проще :)

<MenuHotspots.contacts>
  <ContactButton/>
</MenuHotspots.contacts>

Изменить : только что обнаруженный реагирующий шлюз, который позволяет рендерить порталы в выбранный вами узел (не обязательно тело)

Редактировать : кажется, что реакция-поппер может быть достойной альтернативой реакции-привязи. PopperJS - это библиотека, которая только вычисляет подходящую позицию для элемента, не касаясь DOM напрямую, позволяя пользователю выбирать, где и когда он хочет разместить узел DOM, в то время как Tether добавляет непосредственно к телу.

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

Себастьян Лорбер
источник
В вашем примере фрагмента всплывающее окно подтверждения не закроется, если вы подтвердите действие (в отличие от нажатия кнопки Отмена)
dKab
Было бы полезно включить импорт портала в фрагмент кода. Из какой библиотеки <Portal>? Я предполагаю, что это портал реакции, но было бы неплохо знать наверняка.
камень
1
@skypecakes, пожалуйста, рассмотрите мои реализации как псевдокод. Я не проверял это ни с какой конкретной библиотекой. Я просто пытаюсь научить концепции здесь, а не конкретную реализацию. Я привык реагировать на портале, и приведенный выше код должен хорошо работать с ним, но он должен работать почти с любой подобной библиотекой.
Себастьян Лорбер
Реакция-шлюз это круто! Поддерживает рендеринг на стороне сервера :)
cyrilluce
Я довольно новичок, поэтому буду очень рад получить объяснение этого подхода. Даже если вы действительно визуализируете модал в другом месте, при таком подходе вам придется проверять каждую кнопку удаления, если вы должны визуализировать конкретный экземпляр модала. В редукционном подходе у меня есть только один экземпляр модального типа, который показан или нет. Разве это не проблема производительности?
Амит Нейгауз
9

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

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

Я предлагаю решение, адресованное для устранения этой проблемы. Более подробное определение проблемы, src и примеры можно найти здесь: https://github.com/fckt/react-layer-stack#rationale

обоснование

react/ react-domпоставляется с 2 основными предположениями / идеями:

  • каждый пользовательский интерфейс, естественно, иерархический. Вот почему мы имеем представление о том, componentsкакие обернуть друг друга
  • react-dom монтирует (физически) дочерний компонент к родительскому узлу DOM по умолчанию

Проблема в том, что иногда второе свойство не то, что вы хотите в вашем случае. Иногда вы хотите смонтировать ваш компонент в другой физический узел DOM и одновременно удерживать логическое соединение между родителем и потомком.

Каноническим примером является Tooltip-подобный компонент: в какой-то момент процесса разработки вы можете обнаружить, что вам нужно добавить некоторое описание для вашего UI element: он будет отображаться в фиксированном слое и должен знать свои координаты (которые являются UI elementэтими координатами или координатами мыши) и в в то же время ему нужна информация о том, нужно ли его показывать прямо сейчас или нет, его содержимое и некоторый контекст из родительских компонентов. Этот пример показывает, что иногда логическая иерархия не совпадает с физической иерархией DOM.

Взгляните на https://github.com/fckt/react-layer-stack/blob/master/README.md#real-world-usage-example, чтобы увидеть конкретный пример, который является ответом на ваш вопрос:

import { Layer, LayerContext } from 'react-layer-stack'
// ... for each `object` in array of `objects`
  const modalId = 'DeleteObjectConfirmation' + objects[rowIndex].id
  return (
    <Cell {...props}>
        // the layer definition. The content will show up in the LayerStackMountPoint when `show(modalId)` be fired in LayerContext
        <Layer use={[objects[rowIndex], rowIndex]} id={modalId}> {({
            hideMe, // alias for `hide(modalId)`
            index } // useful to know to set zIndex, for example
            , e) => // access to the arguments (click event data in this example)
          <Modal onClick={ hideMe } zIndex={(index + 1) * 1000}>
            <ConfirmationDialog
              title={ 'Delete' }
              message={ "You're about to delete to " + '"' + objects[rowIndex].name + '"' }
              confirmButton={ <Button type="primary">DELETE</Button> }
              onConfirm={ this.handleDeleteObject.bind(this, objects[rowIndex].name, hideMe) } // hide after confirmation
              close={ hideMe } />
          </Modal> }
        </Layer>

        // this is the toggle for Layer with `id === modalId` can be defined everywhere in the components tree
        <LayerContext id={ modalId }> {({showMe}) => // showMe is alias for `show(modalId)`
          <div style={styles.iconOverlay} onClick={ (e) => showMe(e) }> // additional arguments can be passed (like event)
            <Icon type="trash" />
          </div> }
        </LayerContext>
    </Cell>)
// ...
fckt
источник
2

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

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

import React from 'react';
import PropTypes from 'prop-types';
import Portal from 'react-portal';

class ModalContainer extends React.Component {
  state = {
    isOpen: false,
  };

  openModal = () => {
    this.setState(() => ({ isOpen: true }));
  }

  closeModal = () => {
    this.setState(() => ({ isOpen: false }));
  }

  renderModal() {
    return (
      this.props.renderModal({
        isOpen: this.state.isOpen,
        closeModal: this.closeModal,
      })
    );
  }

  renderTrigger() {
     return (
       this.props.renderTrigger({
         openModal: this.openModal
       })
     )
  }

  render() {
    return (
      <React.Fragment>
        <Portal>
          {this.renderModal()}
        </Portal>
        {this.renderTrigger()}
      </React.Fragment>
    );
  }
}

ModalContainer.propTypes = {
  renderModal: PropTypes.func.isRequired,
  renderTrigger: PropTypes.func.isRequired,
};

export default ModalContainer;

А вот простой пример использования ...

import React from 'react';
import Modal from 'react-modal';
import Fade from 'components/Animations/Fade';
import ModalContainer from 'components/ModalContainer';

const SimpleModal = ({ isOpen, closeModal }) => (
  <Fade visible={isOpen}> // example use case with animation components
    <Modal>
      <Button onClick={closeModal}>
        close modal
      </Button>
    </Modal>
  </Fade>
);

const SimpleModalButton = ({ openModal }) => (
  <button onClick={openModal}>
    open modal
  </button>
);

const SimpleButtonWithModal = () => (
   <ModalContainer
     renderModal={props => <SimpleModal {...props} />}
     renderTrigger={props => <SimpleModalButton {...props} />}
   />
);

export default SimpleButtonWithModal;

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

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

import React from 'react'
import partialRight from 'lodash/partialRight';
import ModalContainer from 'components/ModalContainer';

class ErrorModalContainer extends React.Component {
  state = { message: '' }

  onError = (message, callback) => {
    this.setState(
      () => ({ message }),
      () => callback && callback()
    );
  }

  renderModal = (props) => (
    this.props.renderModal({
       ...props,
       message: this.state.message,
    })
  )

  renderTrigger = (props) => (
    this.props.renderTrigger({
      openModal: partialRight(this.onError, props.openModal)
    })
  )

  render() {
    return (
      <ModalContainer
        renderModal={this.renderModal}
        renderTrigger={this.renderTrigger}
      />
    )
  }
}

ErrorModalContainer.propTypes = (
  ModalContainer.propTypes
);

export default ErrorModalContainer;
kskkido
источник
0

Оберните модал в подключенный контейнер и выполните здесь асинхронную операцию. Таким образом, вы можете связаться как с диспетчером для запуска действий, так и с onClose. Чтобы добраться dispatchот реквизита, не передавайте mapDispatchToPropsфункцию connect.

class ModalContainer extends React.Component {
  handleDelete = () => {
    const { dispatch, onClose } = this.props;
    dispatch({type: 'DELETE_POST'});

    someAsyncOperation().then(() => {
      dispatch({type: 'DELETE_POST_SUCCESS'});
      onClose();
    })
  }

  render() {
    const { onClose } = this.props;
    return <Modal onClose={onClose} onSubmit={this.handleDelete} />
  }
}

export default connect(/* no map dispatch to props here! */)(ModalContainer);

Приложение, в котором отображается модал и устанавливается его состояние видимости:

class App extends React.Component {
  state = {
    isModalOpen: false
  }

  handleModalClose = () => this.setState({ isModalOpen: false });

  ...

  render(){
    return (
      ...
      <ModalContainer onClose={this.handleModalClose} />  
      ...
    )
  }

}
gazdagergo
источник