Як я можу відобразити модальний діалог у Redux, який виконує асинхронні дії?


240

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

Скажімо, я хочу щось видалити, тоді я відправлю дію на зразок, deleteSomething(id)щоб якийсь редуктор схопив цю подію і заповнить діалоговий редуктор, щоб показати його.

Мої сумніви виникають, коли це діалогове вікно подається.

  • Як цей компонент може відправити належну дію відповідно до першої відправленої дії?
  • Чи повинен творець дії керуватися цією логікою?
  • Чи можемо ми додати дії всередині редуктора?

редагувати:

щоб було зрозуміліше:

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

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

Тому я намагаюся повторно використовувати компонент діалогу. Показати / приховати діалогове вікно, це не проблема, оскільки це легко зробити в редукторі. Що я намагаюся вказати, це як відправити дію з правого боку відповідно до дії, що запускає потік у лівій частині.


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 буде оновлено, щоб включити інформацію про видиме в даний час модальне вікно.

Запис компонента Root 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.

Замість того, щоб давати вам поради щодо цього, я пропоную вам просто не виконувати це самостійно. Важко правильно попрацювати з огляду на доступність.

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

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

Інші підходи

Існує більше ніж один спосіб зробити це.

Деяким людям не подобається багатослівність цього підходу і вважають за краще мати <Modal>компонент, який вони можуть передати всередині своїх компонентів за допомогою техніки, що називається «портали». Портали дозволяють візуалізувати компонент всередині вашого, а насправді він відображатиметься заздалегідь визначеним місцем у DOM, що дуже зручно для модалів.

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

Я рекомендую вам розглянути обидва підходи, експериментувати з ними та вибрати те, що, як вам здається, найкраще підходить для вашої програми та для вашої команди.


35
Я б сказав, що редуктор веде список модалів, які можна натиснути і вискакувати. Як це не дурно звучить, я послідовно стикаюся з ситуаціями, коли дизайнери / типи виробів хочуть, щоб я відкрив модаль від модального, і це приємно дозволяти користувачам "повертатися назад".
Кайл

9
Так, безумовно, такий вид, який Redux полегшує побудову, тому що ви можете просто змінити стан, щоб стати масивом. Особисто я працював з дизайнерами, які навпаки хотіли, щоб модали були ексклюзивними, тому підхід, який я написав, вирішує випадкове вкладення. Але так, ти можеш мати це обома способами.
Дан Абрамов

4
На моєму досвіді я б сказав: якщо модальний зв’язок пов’язаний з локальним компонентом (наприклад, модаль підтвердження видалення пов'язаний з кнопкою видалення), простіше використовувати портал, в іншому випадку використовувати наддувні дії. Погодьтеся з @Kyle, ви повинні мати можливість відкрити модаль від модального. Він також працює за замовчуванням з порталами, оскільки вони додаються для того, щоб документувати тіло, так що портали чудово укладаються один на одного (поки ви не зіпсуєте все з z-index: p)
Себастьян Лорбер,

4
@DanAbramov, ваше рішення чудове, але у мене незначна проблема. Нічого серйозного. Я використовую Material-ui в проекті, при закритті модального режиму його просто вимикають, замість того, щоб "відтворювати" згасаючу анімацію. Напевно, потрібно зробити якусь затримку? Або зберігати кожен модал там як список всередині ModalRoot? Пропозиції?
gcerar

7
Іноді я хочу викликати певні функції після закриття модалу (наприклад, виклик функцій зі значеннями поля введення всередині модального). Я передав би ці функції modalPropsщодо дії. Це порушує правило про збереження держави серіалізмом. Як я можу подолати це питання?
chmanie

98

Оновлення : реагуйте на 16.0 представлені портали через ReactDOM.createPortal посилання

Оновлення : наступні версії React (Fiber: ймовірно, 16 або 17) включатимуть спосіб створення порталів: ReactDOM.unstable_createPortal() link


Використовуйте портали

Дана Абрамова відповідь перша частина - це добре, але передбачає багато котлован. За його словами, ви також можете використовувати портали. Я трохи розгорну цю ідею.

Перевага порталу полягає в тому, що спливаюче вікно та кнопка залишаються дуже близько до дерева реагування, за допомогою дуже простого спілкування батько / дитина за допомогою реквізиту: ви можете легко обробляти дії асинхронізації з порталами або дозволити батькові налаштувати портал.

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

Портал дозволяє вам відображати безпосередньо всередині document.bodyелемента, глибоко вкладеного у вашому дереві 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-індексом : портал дозволяє вам рендерувати <body>. Якщо ви хочете відобразити спливаюче або спадне меню, це дуже приємна ідея, якщо вам не потрібно боротися з проблемами z-індексу. Елементи порталу додаються document.bodyв порядку монтажу, що означає, що якщо ви не граєте z-index, поведінка за замовчуванням буде складати портали один на одного, в порядку монтажу. На практиці це означає, що ви можете сміливо відкривати спливаюче вікно всередині іншого спливаючого вікна і бути впевненим, що 2-е спливаюче вікно відображатиметься поверх першого, навіть не думаючи про це 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, налаштувати поведінку цього порталу дуже просто, оскільки ваш батько може передавати реквізити порталу. Якщо ви не використовуєте портали, вам зазвичай доведеться відображати спливаючі вікна у верхній частині вашого дерева реагуванняz-indexПричини, і зазвичай доводиться думати про такі речі, як "як я налаштую загальний DeleteConfirmationPopup, який я створив відповідно до випадку використання". І зазвичай ви знайдете досить хиткі рішення цієї проблеми, як, наприклад, диспетчеризація дії, яка містить вкладені дії підтвердження / скасування, ключ пакета перекладу або, що ще гірше, функція візуалізації (або щось інше несеріалізаційне). Вам не доведеться робити це з порталами, і ви можете просто передавати регулярні реквізити, оскільки DeleteConfirmationPopupце лише дитина дитиниDeleteButton

Висновок

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

Зауважте, що реалізація порталу також може допомогти вам з іншими корисними функціями, такими як:

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

reakct-portal або react-modal приємні для спливаючих вікон, модалів та накладок, які мають бути повноекранними, як правило, в центрі екрана.

reakct-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постачається з двома основними припущеннями / ідеями:

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

Проблема полягає в тому, що іноді друга властивість - це не те, що потрібно у вашому випадку. Іноді ви хочете встановити свій компонент в інший фізичний вузол DOM і одночасно підтримувати логічне з'єднання між батьком і дитиною.

Приклад Canonical - компонент, схожий на підказки: в якийсь момент процесу розробки ви можете виявити, що вам потрібно додати деякий опис для свого 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>)
// ...

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;

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} />  
      ...
    )
  }

}
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.