¿Cómo puedo mostrar un diálogo modal en Redux que realiza acciones asincrónicas?

240

Estoy creando una aplicación que necesita mostrar un diálogo de confirmación en algunas situaciones.

Digamos que quiero eliminar algo, luego enviaré una acción como deleteSomething(id)para que algún reductor capture ese evento y llene el reductor de diálogo para mostrarlo.

Mi duda surge cuando se presenta este diálogo.

  • ¿Cómo puede este componente despachar la acción adecuada de acuerdo con la primera acción despachada?
  • ¿Debería el creador de la acción manejar esta lógica?
  • ¿Podemos agregar acciones dentro del reductor?

editar:

para que quede más claro:

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

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

Así que estoy tratando de reutilizar el componente de diálogo. Mostrar / ocultar el diálogo no es el problema, ya que esto se puede hacer fácilmente en el reductor. Lo que intento especificar es cómo enviar la acción desde el lado derecho de acuerdo con la acción que inicia el flujo en el lado izquierdo.

carlesba
fuente
1
Creo que en su caso el estado del diálogo (ocultar / mostrar) es local. Elegiría usar el estado de reacción para administrar el diálogo que muestra / oculta. De esta manera, la cuestión de "acción adecuada de acuerdo con la primera acción" habrá desaparecido.
Ming

Respuestas:

516

El enfoque que sugiero es un poco detallado, pero encontré que escala bastante bien en aplicaciones complejas. Cuando desee mostrar un modal, active una acción que describa qué modal le gustaría ver:

Envío de una acción para mostrar el modal

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

(Las cadenas pueden ser constantes, por supuesto; estoy usando cadenas en línea para simplificar).

Escribir un reductor para administrar el estado modal

Luego, asegúrese de tener un reductor que solo acepte estos valores:

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

¡Excelente! Ahora, cuando envíe una acción, state.modalse actualizará para incluir la información sobre la ventana modal actualmente visible.

Escribir el componente modal raíz

En la raíz de su jerarquía de componentes, agregue un <ModalRoot>componente que esté conectado a la tienda Redux. Escuchará state.modaly mostrará un componente modal apropiado, reenviando los accesorios desde 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)

Que hemos hecho aqui ModalRootlee el actual modalTypey modalPropsdesde el state.modalque está conectado, y representa un componente correspondiente como DeletePostModaloConfirmLogoutModal . ¡Cada modal es un componente!

Escribir componentes modales específicos

No hay reglas generales aquí. Son solo componentes React que pueden enviar acciones, leer algo del estado de la tienda y ser modales. .

Por ejemplo, DeletePostModalpodría verse así:

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)

El DeletePostModalestá conectado a la tienda para que pueda mostrar el título de la publicación y funciona como cualquier componente conectado: puede enviar acciones, incluidashideModal cuando es necesario ocultarse.

Extraer un componente de presentación

Sería incómodo copiar y pegar la misma lógica de diseño para cada modal "específico". Pero tienes componentes, ¿verdad? Para que puedas extraer una presentación <Modal> componente de que no sabe qué hacen los modales particulares, pero maneja cómo se ven.

Luego, modales específicos como DeletePostModalpueden usarlo para renderizar:

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>Depende de usted crear un conjunto de accesorios que pueda aceptar en su aplicación, pero me imagino que podría tener varios tipos de modales (por ejemplo, información modal, modal de confirmación, etc.) y varios estilos para ellos.

Accesibilidad y ocultación al hacer clic fuera o tecla de escape

La última parte importante sobre los modales es que generalmente queremos ocultarlos cuando el usuario hace clic afuera o presiona Escape.

En lugar de darle consejos sobre cómo implementar esto, le sugiero que simplemente no lo implemente usted mismo. Es difícil acertar teniendo en cuenta la accesibilidad.

En cambio, le sugiero que utilice un componente modal accesible estándar como react-modal. Es completamente personalizable, puedes poner lo que quieras dentro de él, pero maneja la accesibilidad correctamente para que las personas ciegas puedan seguir usando tu modal.

Incluso puede envolver uno react-modalpropio <Modal>que acepte accesorios específicos para sus aplicaciones y genere botones secundarios u otro contenido. ¡Todo son solo componentes!

Otros enfoques

Hay más de una forma de hacerlo.

A algunas personas no les gusta la verbosidad de este enfoque y prefieren tener un <Modal>componente que puedan representar dentro de sus componentes con una técnica llamada "portales". Los portales le permiten representar un componente dentro del suyo, mientras que en realidad se representará en un lugar predeterminado en el DOM, lo cual es muy conveniente para los modales.

De hecho react-modal, el enlace anterior ya lo hace internamente, por lo que técnicamente ni siquiera necesita renderizarlo desde la parte superior. Todavía me parece agradable desacoplar el modal que quiero mostrar del componente que lo muestra, pero también puede usarlo react-modaldirectamente desde sus componentes y omitir la mayoría de lo que escribí anteriormente.

Te animo a que consideres ambos enfoques, experimentes con ellos y elijas lo que creas que funciona mejor para tu aplicación y para tu equipo.

Dan Abramov
fuente
35
Una cosa que sugeriría es que el reductor mantenga una lista de modales que se pueden empujar y reventar. Por tonto que parezca, me he encontrado constantemente con situaciones en las que los diseñadores / tipos de productos quieren que abra un modal de un modal, y es bueno permitir que los usuarios "regresen".
Kyle
99
Sí, definitivamente, este es el tipo de cosas que Redux hace fácil de construir porque puedes cambiar tu estado para que sea una matriz. Personalmente, he trabajado con diseñadores que, por el contrario, querían que los modales fueran exclusivos, por lo que el enfoque que redacté resuelve el anidamiento accidental. Pero sí, puedes tenerlo en ambos sentidos.
Dan Abramov
44
En mi experiencia, diría: si modal está relacionado con un componente local (como un modal de confirmación de eliminación está relacionado con el botón Eliminar), es más simple usar un portal, de lo contrario, use acciones redux. De acuerdo con @Kyle, uno debería poder abrir un modal desde un modal. También funciona de forma predeterminada con los portales porque se agregan para documentar el cuerpo, de modo que los portales se apilan bien entre sí (hasta que lo estropees todo con el índice z: p)
Sebastien Lorber
44
@DanAbramov, su solución es excelente, pero tengo un problema menor. Nada serio. Utilizo Material-ui en el proyecto, cuando cierro modal simplemente lo apago, en lugar de "reproducir" animación desvanecida. ¿Probablemente necesites hacer algún tipo de retraso? ¿O mantener cada modal allí como una lista dentro de ModalRoot? Sugerencias?
gcerar
77
A veces quiero llamar a ciertas funciones después de que se cierra el modal (por ejemplo, llamar a las funciones con los valores del campo de entrada dentro del modal). Pasaría estas funciones en cuanto modalPropsa la acción. Sin embargo, esto viola la regla de mantener el estado serializable. ¿Cómo puedo superar este problema?
chmanie
98

Actualización : React 16.0 introdujo portales a través del ReactDOM.createPortal enlace

Actualización : las próximas versiones de React (Fiber: probablemente 16 o 17) incluirán un método para crear portales: ReactDOM.unstable_createPortal() enlace


Usa portales

La primera parte de la respuesta de Dan Abramov está bien, pero involucra muchas repeticiones. Como él dijo, también puedes usar portales. Ampliaré un poco esa idea.

La ventaja de un portal es que la ventana emergente y el botón permanecen muy cerca del árbol React, con una comunicación padre / hijo muy simple usando accesorios: puede manejar fácilmente acciones asíncronas con portales, o dejar que el padre personalice el portal.

¿Qué es un portal?

Un portal le permite renderizar directamente dentro de document.bodyun elemento que está profundamente anidado en su árbol React.

La idea es que, por ejemplo, conviertas en cuerpo el siguiente árbol React:

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

Y obtienes como salida:

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

El inside-portalnodo ha sido traducido dentro<body> , en lugar de su lugar normal, profundamente anidado.

Cuando usar un portal

Un portal es particularmente útil para mostrar elementos que deben ir por encima de sus componentes React existentes: ventanas emergentes, menús desplegables, sugerencias, puntos de acceso

¿Por qué usar un portal?

Ya no hay problemas con el índice z : un portal le permite renderizar <body>. Si desea mostrar una ventana emergente o desplegable, esta es una muy buena idea si no quiere tener que luchar contra los problemas del índice z. Los elementos del portal se agregan document.bodyen orden de montaje, lo que significa que, a menos que juegue con ellos z-index, el comportamiento predeterminado será apilar los portales uno encima del otro, en orden de montaje. En la práctica, significa que puede abrir de manera segura una ventana emergente desde el interior de otra ventana emergente, y asegúrese de que la segunda ventana emergente se mostrará encima de la primera, sin tener que pensar siquiera enz-index .

En la práctica

Lo más simple: use el estado de Reacción local: si cree que, para una simple ventana emergente de confirmación de eliminación, no vale la pena tener el repetitivo de Redux, entonces puede usar un portal y simplifica enormemente su código. Para tal caso de uso, donde la interacción es muy local y en realidad es un detalle de implementación, ¿realmente le importa la recarga en caliente, el viaje en el tiempo, el registro de acciones y todos los beneficios que Redux le brinda? Personalmente, no lo hago y uso el estado local en este caso. El código se vuelve tan simple como:

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>
    );
  }
}

Simple: aún puede usar el estado de Redux : si realmente lo desea, puede usar connectpara elegir si DeleteConfirmationPopupse muestra o no. Como el portal permanece profundamente anidado en su árbol React, es muy sencillo personalizar el comportamiento de este portal porque su padre puede pasar accesorios al portal. Si no usa portales, generalmente debe mostrar sus ventanas emergentes en la parte superior de su árbol React paraz-index motivos son , y generalmente tiene que pensar en cosas como "¿cómo personalizo el DeleteConfirmationPopup genérico que construí de acuerdo con el caso de uso? ". Y, por lo general, encontrará soluciones bastante hacky para este problema, como enviar una acción que contiene acciones de confirmación / cancelación anidadas, una clave de paquete de traducción o, lo que es peor, una función de representación (o algo más no serializable). No tiene que hacer eso con los portales, y solo puede pasar accesorios regulares, ya que DeleteConfirmationPopupes solo un hijo delDeleteButton

Conclusión

Los portales son muy útiles para simplificar su código. No podría prescindir de ellos nunca más.

Tenga en cuenta que las implementaciones de portal también pueden ayudarlo con otras características útiles como:

  • Accesibilidad
  • Atajos de espacio para cerrar el portal
  • Manejar el clic externo (cerrar el portal o no)
  • Manejar clic en el enlace (cerrar portal o no)
  • Contexto de reacción disponible en el árbol del portal

react-portal o react-modal son buenos para ventanas emergentes, modales y superposiciones que deberían estar en pantalla completa, generalmente centradas en el medio de la pantalla.

react-tether es desconocido para la mayoría de los desarrolladores de React, sin embargo, es una de las herramientas más útiles que puedes encontrar. Tether le permite crear portales, pero posicionará automáticamente el portal, en relación con un objetivo determinado. Esto es perfecto para información sobre herramientas, menús desplegables, zonas interactivas, cajas de ayuda ... Si alguna vez ha tenido algún problema con la posición absolute/ relativey z-index, o si su menú desplegable sale de su ventana gráfica, Tether resolverá todo eso por usted.

Puede, por ejemplo, implementar fácilmente zonas activas de incorporación, que se expanden a una información sobre herramientas una vez que se hace clic en ellas:

Punto de acceso a bordo

Código de producción real aquí. No puede ser más simple :)

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

Editar : acaba de descubrir react-gateway que permite representar portales en el nodo de su elección (no necesariamente el cuerpo)

Editar : parece que react-popper puede ser una alternativa decente a react-tether. PopperJS es una biblioteca que solo calcula una posición apropiada para un elemento, sin tocar el DOM directamente, lo que permite al usuario elegir dónde y cuándo quiere colocar el nodo DOM, mientras que Tether se agrega directamente al cuerpo.

Editar : también hay react-slot-fill, que es interesante y puede ayudar a resolver problemas similares al permitir representar un elemento en un espacio de elemento reservado que coloque en cualquier lugar que desee en su árbol

Sebastien Lorber
fuente
En su fragmento de ejemplo, la ventana emergente de confirmación no se cerrará si confirma la acción (a diferencia de cuando hace clic en Cancelar)
dKab
Sería útil incluir la importación de su Portal en el fragmento de código. ¿De qué biblioteca <Portal>viene? Supongo que es el portal de reacción, pero sería bueno saberlo con certeza.
piedra
1
@skypecakes, considere mis implementaciones como pseudocódigo. No lo probé contra ninguna biblioteca concreta. Solo trato de enseñar el concepto aquí, no una implementación concreta. Estoy acostumbrado a reaccionar-portal y el código anterior debería funcionar bien con él, pero debería funcionar bien con casi cualquier lib similar.
Sebastien Lorber
¡react-gateway es increíble! Es compatible con la representación del lado del servidor :)
cyrilluce
Soy bastante principiante, así que estaré muy feliz por alguna explicación sobre este enfoque. Incluso si realmente representa el modal en otro lugar, en este enfoque tendrá que verificar cada botón de eliminación si debe representar la instancia específica del modal. En el enfoque redux tengo solo una instancia del modal que se muestra o no. ¿No es una preocupación de rendimiento?
Amit Neuhaus
9

Aquí se pueden encontrar muchas buenas soluciones y valiosos comentarios de expertos conocidos de la comunidad de JS sobre el tema. Podría ser un indicador de que no es un problema tan trivial como puede parecer. Creo que es por eso que podría ser la fuente de dudas e incertidumbre sobre el tema.

El problema fundamental aquí es que en React solo se le permite montar el componente en su padre, que no siempre es el comportamiento deseado. Pero, ¿cómo abordar este problema?

Propongo la solución, dirigida a solucionar este problema. Una definición más detallada del problema, src y ejemplos se pueden encontrar aquí: https://github.com/fckt/react-layer-stack#rationale

Razón fundamental

react/ react-domviene viene con 2 supuestos básicos / ideas:

  • cada interfaz de usuario es jerárquica naturalmente. Por eso tenemos la idea de componentsqué envolvernos
  • react-dom monta el componente hijo (físicamente) en su nodo DOM padre de forma predeterminada

El problema es que a veces la segunda propiedad no es lo que desea en su caso. A veces, desea montar su componente en un nodo DOM físico diferente y mantener una conexión lógica entre padre e hijo al mismo tiempo.

El ejemplo canónico es un componente similar a la información sobre herramientas: en algún punto del proceso de desarrollo, puede encontrar que necesita agregar alguna descripción para su UI element: se representará en una capa fija y debe conocer sus coordenadas (que son esas coordenadas o coordenadas del UI elementmouse) y en Al mismo tiempo, necesita información sobre si debe mostrarse en este momento o no, su contenido y algún contexto de los componentes principales. Este ejemplo muestra que a veces la jerarquía lógica no coincide con la jerarquía física del DOM.

Eche un vistazo a https://github.com/fckt/react-layer-stack/blob/master/README.md#real-world-usage-example para ver el ejemplo concreto que es la respuesta a su pregunta:

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
fuente
2

En mi opinión, la implementación mínima básica tiene dos requisitos. Un estado que realiza un seguimiento de si el modal está abierto o no, y un portal para representar el modal fuera del árbol de reacción estándar.

El componente ModalContainer a continuación implementa esos requisitos junto con las funciones de representación correspondientes para el modal y el desencadenante, que es responsable de ejecutar la devolución de llamada para abrir el modal.

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;

Y aquí hay un caso de uso simple ...

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;

Utilizo las funciones de representación, porque quiero aislar la administración de estado y la lógica repetitiva de la implementación del componente modal y del componente de activación. Esto permite que los componentes renderizados sean lo que usted quiera que sean. En su caso, supongo que el componente modal podría ser un componente conectado que recibe una función de devolución de llamada que despacha una acción asincrónica.

Si necesita enviar accesorios dinámicos al componente modal desde el componente de activación, lo que con suerte no sucede con demasiada frecuencia, le recomiendo envolver el Contenedor Modal con un componente contenedor que gestione los accesorios dinámicos en su propio estado y mejore los métodos de renderizado originales como entonces.

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
fuente
0

Envuelva el modal en un contenedor conectado y realice la operación asíncrona aquí. De esta manera, puede llegar al despacho para activar acciones y al accesorio onClose también. Para llegar dispatchdesde los accesorios, no pase la mapDispatchToPropsfunción a 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);

La aplicación donde se representa el modal y se establece su estado de visibilidad:

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

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

  ...

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

}
gazdagergo
fuente