Como posso exibir uma caixa de diálogo modal no Redux que executa ações assíncronas?

240

Estou criando um aplicativo que precisa mostrar uma caixa de diálogo de confirmação em algumas situações.

Digamos que eu queira remover algo, então enviarei uma ação como essa deleteSomething(id)para que algum redutor pegue esse evento e preencha o redutor de diálogo para mostrá-lo.

Minha dúvida surge quando este diálogo é enviado.

  • Como esse componente pode despachar a ação correta de acordo com a primeira ação despachada?
  • O criador da ação deve lidar com essa lógica?
  • Podemos adicionar ações dentro do redutor?

editar:

para deixar mais claro:

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

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

Então, eu estou tentando reutilizar o componente de diálogo. Mostrar / ocultar a caixa de diálogo não é o problema, pois isso pode ser feito facilmente no redutor. O que estou tentando especificar é como despachar a ação do lado direito, de acordo com a ação que inicia o fluxo no lado esquerdo.

carlesba
fonte
1
Eu acho que no seu caso o estado do diálogo (ocultar / exibir) é local. Eu escolheria usar o estado de reação para gerenciar o diálogo mostrando / ocultando. Dessa maneira, a questão da "ação apropriada de acordo com a primeira ação" desaparecerá.
Ming

Respostas:

516

A abordagem que sugiro é um pouco detalhada, mas achei que ela pode se adaptar muito bem a aplicativos complexos. Quando você quiser mostrar um modal, inicie uma ação descrevendo qual modal você gostaria de ver:

Despachando uma ação para mostrar o modal

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

(As strings podem ser constantes, é claro; estou usando strings inline para simplificar.)

Escrevendo um redutor para gerenciar o estado modal

Em seguida, verifique se você possui um redutor que aceite apenas esses 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 */
})

Ótimo! Agora, quando você despacha uma ação,state.modal será atualizada para incluir as informações sobre a janela modal atualmente visível.

Escrevendo o componente modal raiz

Na raiz da sua hierarquia de componentes, adicione um <ModalRoot>componente conectado ao repositório Redux. Ele escutará state.modale exibirá um componente modal apropriado, encaminhando os adereços a partir do 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)

O que fizemos aqui? ModalRootlê a corrente modalTypee modalPropsda state.modalqual está conectada e renderiza um componente correspondente, como DeletePostModalouConfirmLogoutModal . Todo modal é um componente!

Escrevendo componentes modais específicos

Não há regras gerais aqui. Eles são apenas componentes do React que podem despachar ações, ler algo do estado da loja e são modais .

Por exemplo, DeletePostModalpode parecer com:

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)

O DeletePostModalestá conectado à loja para exibir o título da postagem e funciona como qualquer componente conectado: pode despachar ações, incluindohideModal quando é necessário se esconder.

Extraindo um componente de apresentação

Seria estranho copiar e colar a mesma lógica de layout para cada modal "específico". Mas você tem componentes, certo? Então você pode extrair uma apresentação <Modal> componente de que não sabe o que modais específicos fazem, mas lida com a aparência deles.

Em seguida, modais específicos, como DeletePostModalpodem ser usados ​​para renderização:

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)

Cabe a você criar um conjunto de adereços que <Modal> possam ser aceitos em seu aplicativo, mas eu imagino que você possa ter vários tipos de modais (por exemplo, informação modal, confirmação de confirmação etc.) e vários estilos para eles.

Acessibilidade e ocultação ao clicar fora ou na tecla Escape

A última parte importante sobre os modais é que geralmente queremos ocultá-los quando o usuário clica fora ou pressiona Escape.

Em vez de dar conselhos sobre como implementar isso, sugiro que você não o implemente. É difícil acertar considerando a acessibilidade.

Em vez disso, sugiro que você use um componente modal acessível disponível no mercado, como react-modal. É completamente personalizável, você pode colocar o que quiser dentro dele, mas lida com a acessibilidade corretamente para que pessoas cegas ainda possam usar o seu modal.

Você pode embrulhar react-modalvocê mesmo <Modal>que aceita objetos específicos para seus aplicativos e gera botões filhos ou outro conteúdo. É tudo apenas componentes!

Outras abordagens

Há mais de uma maneira de fazer isso.

Algumas pessoas não gostam da verbosidade dessa abordagem e preferem ter um <Modal>componente que possa renderizar diretamente dentro de seus componentes com uma técnica chamada "portais". Os portais permitem renderizar um componente dentro do seu enquanto na verdade ele será renderizado em um local predeterminado no DOM, o que é muito conveniente para os modais.

De fato, ao qual react-modaleu vinculei anteriormente, já faz isso internamente, tecnicamente, você nem precisa renderizá-lo do topo. Ainda acho bom desacoplar o modal que quero mostrar do componente que o mostra, mas você também pode usar react-modaldiretamente de seus componentes e pular a maior parte do que escrevi acima.

Convido você a considerar as duas abordagens, experimentar com elas e escolher o que achar melhor para seu aplicativo e sua equipe.

Dan Abramov
fonte
35
Uma coisa que eu sugiro é que o redutor mantenha uma lista de modais que podem ser pressionados e acionados. Por mais bobo que pareça, eu sempre enfrentei situações em que designers / tipos de produtos querem que eu abra um modal a partir de um modal, e é bom permitir que os usuários "voltem".
26616 Kyle
9
Sim, definitivamente, esse é o tipo de coisa que o Redux facilita a criação, porque você pode simplesmente mudar seu estado para ser uma matriz. Pessoalmente, trabalhei com designers que, pelo contrário, queriam que os modais fossem exclusivos, de modo que a abordagem que escrevi resolve o aninhamento acidental. Mas sim, você pode ter os dois lados.
Dan Abramov
4
Na minha experiência, eu diria: se modal está relacionado a um componente local (como um modal de confirmação de exclusão está relacionado ao botão excluir), é mais simples usar um portal, além de usar ações de redux. De acordo com o @Kyle, um deve poder abrir um modal a partir de um modal. Ele também funciona por padrão com os portais, porque eles são adicionados a fim de corpo documento para portais de pilha em cada outro bem (até que você estragar tudo com z-index: p)
Sebastien Lorber
4
@ DanAbramov, sua solução é ótima, mas tenho um pequeno problema. Nada sério. Eu uso o Material-ui no projeto, ao fechar o modal, apenas o desligo, em vez de "reproduzir" a animação de desaparecer. Provavelmente precisa fazer algum tipo de atraso? Ou manter todos os modais lá como uma lista dentro do ModalRoot? Sugestões?
gcerar
7
Às vezes, desejo chamar determinadas funções após o fechamento do modal (por exemplo, chamar as funções com os valores do campo de entrada dentro do modal). Eu passaria essas funções quanto modalPropsà ação. Isso viola a regra de manter o estado serializável. Como posso superar esse problema?
Chmanie
98

Atualização : React 16.0 portais introduzidos através do ReactDOM.createPortal link

Atualização : as próximas versões do React (Fiber: provavelmente 16 ou 17) incluirão um método para criar portais: ReactDOM.unstable_createPortal() link


Use portais

Dan Abramov responder a primeira parte é bom, mas envolve muito clichê. Como ele disse, você também pode usar portais. Vou expandir um pouco essa ideia.

A vantagem de um portal é que o pop-up e o botão permaneçam muito próximos da árvore React, com uma comunicação pai / filho muito simples usando adereços: você pode lidar facilmente com ações assíncronas com portais ou permitir que o pai personalize o portal.

O que é um portal?

Um portal permite que você renderize diretamente dentro de document.bodyum elemento profundamente aninhado na sua árvore do React.

A idéia é que, por exemplo, você renderize no corpo a seguinte árvore React:

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

E você obtém como saída:

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

O inside-portalnó foi traduzido para dentro <body>, em vez de seu lugar normal e profundamente aninhado.

Quando usar um portal

Um portal é particularmente útil para exibir elementos que devem estar no topo de seus componentes existentes do React: pop-ups, listas suspensas, sugestões, pontos de acesso

Por que usar um portal

Não há mais problemas com o índice z : um portal permite que você renderize <body>. Se você deseja exibir um pop-up ou uma lista suspensa, é uma ótima idéia se não quiser lutar contra problemas do índice z. Os elementos do portal são adicionados document.bodyem ordem de montagem, o que significa que, a menos que você brinque z-index, o comportamento padrão será empilhar portais uns sobre os outros, em ordem de montagem. Na prática, isso significa que você pode abrir com segurança um pop-up de dentro de outro pop-up e certifique-se de que o segundo pop-up seja exibido na parte superior da primeira, sem ter que pensar z-index.

Na prática

Mais simples: use o estado local React: se você acha que, para um pop-up simples de confirmação de exclusão, não vale a pena ter o Redux, então você pode usar um portal e isso simplifica bastante o seu código. Para esse caso de uso, em que a interação é muito local e é realmente um detalhe de implementação, você realmente se preocupa com o carregamento quente, viagens no tempo, registro de ações e todos os benefícios que o Redux traz para você? Pessoalmente, eu não uso e estado local neste caso. O código se torna tão simples quanto:

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

Simples: você ainda pode usar o estado Redux : se realmente quiser, ainda pode connectescolher se deseja ou não DeleteConfirmationPopupser exibido. Como o portal permanece profundamente aninhado em sua árvore do React, é muito simples personalizar o comportamento desse portal, porque seu pai pode transmitir acessórios para o portal. Se você não usa portais, geralmente precisa exibir seus pop-ups no topo da árvore do React paraz-index motivos e, geralmente, precisa pensar em coisas como "como personalizar o DeleteConfirmationPopup genérico que criei de acordo com o caso de uso " E geralmente você encontrará soluções bastante invasivas para esse problema, como despachar uma ação que contém ações de confirmação / cancelamento aninhadas, uma chave de pacote de conversão ou, pior ainda, uma função de renderização (ou outra coisa não serializável). Você não precisa fazer isso com portais e pode apenas passar adereços regulares, já que DeleteConfirmationPopupé apenas um filho doDeleteButton

Conclusão

Portais são muito úteis para simplificar seu código. Eu não poderia mais ficar sem eles.

Observe que as implementações do portal também podem ajudá-lo com outros recursos úteis, como:

  • Acessibilidade
  • Atalhos de espaço para fechar o portal
  • Manipular clique externo (fechar portal ou não)
  • Manipular clique no link (fechar portal ou não)
  • Contexto do React disponibilizado na árvore do portal

react-portal ou react-modal são ótimos para pop-ups, modais e sobreposições que devem ser em tela cheia, geralmente centralizadas no meio da tela.

O react-tether é desconhecido para a maioria dos desenvolvedores do React, mas é uma das ferramentas mais úteis que você pode encontrar por aí. O Tether permite criar portais, mas posicionará automaticamente o portal, em relação a um determinado destino. Isso é perfeito para dicas de ferramentas, listas suspensas, pontos ativos, caixas de ajuda ... Se você já teve algum problema com a posição absolute/ relativee / z-indexou sua lista suspensa fora da janela de visualização, o Tether resolverá tudo isso para você.

Você pode, por exemplo, implementar facilmente pontos de acesso integrados, que se expandem para uma dica de ferramenta uma vez clicados:

Ponto de acesso integrado

Código de produção real aqui. Não pode ser mais simples :)

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

Edit : recém-descoberto react-gateway que permite renderizar portais no nó de sua escolha (não necessariamente o corpo)

Edit : parece que o react-popper pode ser uma alternativa decente ao reat-tether. PopperJS é uma biblioteca que calcula apenas uma posição apropriada para um elemento, sem tocar diretamente no DOM, permitindo que o usuário escolha onde e quando deseja colocar o nó DOM, enquanto o Tether anexa diretamente ao corpo.

Edit : também há react-slot-fill, o que é interessante e pode ajudar a resolver problemas semelhantes, permitindo renderizar um elemento em um slot de elemento reservado que você coloca em qualquer lugar que desejar na sua árvore

Sebastien Lorber
fonte
No exemplo de trecho, o pop-up de confirmação não será fechado se você confirmar a ação (ao contrário de quando você clicar em Cancelar)
dKab
Seria útil incluir sua importação do Portal no snippet de código. De que biblioteca <Portal>vem? Acho que é o portal de reação, mas seria bom saber com certeza.
stone
1
@skypecakes, considere minhas implementações como pseudo-código. Não testei em nenhuma biblioteca concreta. Apenas tento ensinar o conceito aqui, não uma implementação concreta. Estou acostumado a reagir-portal e o código acima deve funcionar bem com ele, mas deve funcionar bem com praticamente qualquer lib semelhante.
Sebastien Lorber
O react-gateway é incrível! Ele suporta renderização no lado do servidor :)
cyrilluce
Eu sou muito iniciante, então ficarei muito feliz com algumas explicações sobre essa abordagem. Mesmo se você realmente renderizar o modal em outro local, nessa abordagem, você deverá verificar todos os botões de exclusão para renderizar a instância específica do modal. Na abordagem redux, tenho apenas uma instância do modal que é mostrada ou não. Não é uma preocupação de desempenho?
Amit Neuhaus
9

Muitas soluções boas e comentários valiosos de especialistas conhecidos da comunidade JS sobre o tópico podem ser encontrados aqui. Pode ser um indicador de que não é esse problema trivial que possa parecer. Penso que é por isso que poderia ser a fonte de dúvidas e incertezas sobre o assunto.

O problema fundamental aqui é que no React você só pode montar o componente no pai, o que nem sempre é o comportamento desejado. Mas como resolver esse problema?

Proponho a solução, abordada para corrigir esse problema. Definição de problema mais detalhada, src e exemplos podem ser encontrados aqui: https://github.com/fckt/react-layer-stack#rationale

Fundamentação

react/ react-domvem com 2 suposições / idéias básicas:

  • toda interface do usuário é hierárquica naturalmente. É por isso que temos a ideia de componentsque se envolvem
  • react-dom monta (fisicamente) o componente filho em seu nó DOM pai por padrão

O problema é que, às vezes, a segunda propriedade não é o que você deseja no seu caso. Às vezes, você deseja montar seu componente em um nó DOM físico diferente e manter a conexão lógica entre pai e filho ao mesmo tempo.

O exemplo canônico é o componente tipo dica de ferramenta: em algum ponto do processo de desenvolvimento, você pode achar que precisa adicionar uma descrição para o seu UI element: ele será renderizado em camada fixa e deve conhecer suas coordenadas (que sãoUI element ou mouse) e em ao mesmo tempo, ele precisa de informações para mostrar agora ou não, seu conteúdo e algum contexto dos componentes pai. Este exemplo mostra que às vezes a hierarquia lógica não corresponde à hierarquia DOM física.

Dê uma olhada em https://github.com/fckt/react-layer-stack/blob/master/README.md#real-world-usage-example para ver o exemplo concreto que é a resposta à sua pergunta:

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

Na minha opinião, a implementação mínima básica tem dois requisitos. Um estado que controla se o modal está aberto ou não, e um portal para processar o modal fora da árvore de reação padrão.

O componente ModalContainer abaixo implementa esses requisitos, juntamente com as funções de renderização correspondentes para o modal e o acionador, responsáveis ​​pela execução do retorno de chamada para abrir o 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;

E aqui está um caso de uso simples ...

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;

Uso funções de renderização, porque quero isolar o gerenciamento de estado e a lógica do clichê da implementação do componente modal e do componente renderizado. Isso permite que os componentes renderizados sejam o que você deseja. No seu caso, suponho que o componente modal possa ser um componente conectado que recebe uma função de retorno de chamada que despacha uma ação assíncrona.

Se você precisar enviar adereços dinâmicos para o componente modal a partir do componente acionador, o que, esperamos, não aconteça com muita frequência, recomendo envolver o ModalContainer com um componente de contêiner que gerencia os adereços dinâmicos em seu próprio estado e aprimora os métodos de renderização originais, como tão.

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

Enrole o modal em um contêiner conectado e execute a operação assíncrona aqui. Dessa forma, você pode alcançar o envio para acionar ações e o suporte onClose também. Para chegar dispatchdos adereços, não passe a mapDispatchToPropsfunção para 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);

O aplicativo em que o modal é renderizado e seu estado de visibilidade é definido:

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

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

  ...

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

}
gazdagergo
fonte