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');
// 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[];
}
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的に実装できなくてとても苦しい…