Panda Noir

JavaScript の限界を究めるブログでした。最近はいろんな分野を幅広めに書いてます。

Reactで<dialog>いじってみたけどつらい件

みなさん<dialog>要素を知っていますか?HTML5.2で追加された要素で、モーダルウィンドウをカンタンに実装できるステキ要素です。これをReactから使おうとしてみたのですが、Flux的なやり方ができなくて辛かったです。

…というだけの記事です。

この記事の環境

この記事は以下のバージョンのパッケージを使用しています。

  • React v16.7.0
  • Redux v4.0.1
  • TypeScript v3.2.2
  • TypeScript-FSA v3.0.0-beta-2

まず完成したコード

// actionCreator.ts
import {Action} from 'redux';
import actionCreatorFactory from 'typescript-fsa';

const actionCreator = actionCreatorFactory();
export const closeDialog = actionCreator('CLOSE_DIALOG');
import * as React from 'react';
import { connect } from 'react-redux';
import ClassNames from 'classnames';
import * as styles from '../style/dialog.css';
import { closeDialog } from '../actionCreator';

const mapStateToProps = ({ opensDialog }) => ({ opensDialog });
const mapStateToDispatch = (dispatch) => ({
  closeDialog() {
    dispatch(closeDialog());
  },
});

interface Props {
  opensDialog: boolean;
  closeDialog(): void;
  className?: string[];
}
interface State {}

class Dialog extends React.Component<Props, State> {
  refDialog = React.createRef<HTMLDialogElement>();
  constructor(props) {
    super(props);
  }
  componentDidUpdate(prevProps, prevState, snapshot) {
    // opensDialogが変更されていたらダイアログを操作する
    if (prevProps.opensDialog === this.props.opensDialog) return;
    if (this.props.opensDialog) this.refDialog.current.showModal();
    else this.refDialog.current.close();
  }
  render() {
    return (
      <dialog
        ref={this.refDialog}
        className={ClassNames(styles.dialog, ...this.props.className)}
      >
        <form method="dialog">
          name: <input type="text" />
          <br />
          age: <input type="number" />
          <br />
          <button onClick={this.props.closeDialog}>Cancel</button>
          <button>Confirm</button>
        </form>
      </dialog>
    );
  }
}
export default connect(mapStateToProps, mapStateToDispatch)(Dialog);

よく分かる解説

このコンポーネントは、storeのopensDialogがtrueのときにダイアログを開き、falseのときは表示しないコンポーネントです。CSSを適切に設定すれば::backdrop疑似要素も使えます。

componentDidUpdateはReactがDOMを変更したあとに呼び出されるフックです。これは変更の前後のpropsを取得できます。ここで比較を行い、変更されていた場合にshowModal()やclose()を呼び出します。

そもそも: なんでこんなややこしい実装しているの?

実はここまでカンタンに書くこともできます。

// component/dialog.tsx
import * as React from 'react';
import { connect } from 'react-redux';
import ClassNames from 'classnames';
import * as styles from '../style/dialog.css';
import { closeDialog } from '../actionCreator';

const mapStateToProps = ({ opensDialog }) => ({ opensDialog });
const mapStateToDispatch = (dispatch) => ({
  closeDialog() {
    dispatch(closeDialog());
  },
});

interface Props {
  opensDialog: boolean;
  closeDialog(): void;
  className?: string[];
}

const Dialog: React.SFC<Props> = (props) => (
  <dialog
    open={props.opensDialog}
    className={ClassNames(styles.dialog, ...props.className)}
  >
    <form method="dialog">
      name: <input type="text" />
      <br />
      age: <input type="number" />
      <br />
      <button onClick={props.closeDialog}>Cancel</button>
      <button>Confirm</button>
    </form>
  </dialog>
);
export default connect(mapStateToProps, mapStateToDispatch)(Dialog);

この実装では、componentDidUpdateによる監視もなく、さらにDialogがReactComponentではなくSFCです。いいことづくめなようですが、これはうまく動作しません。

なぜかというと、open={props.opensDialog}だと::backdrop疑似要素を使えないからです。(逆に言えば、::backdropを使わないならコレもアリです)

::backdrop疑似要素はどうやらHTMLDialogElement.showModal()を実行しないと付与されないらしく、open属性によるトグルではうまく動作してくれません。そのため、いちいちややこしいやり方しかできないようです。Flux的に実装できなくてとても苦しい…