UIを作って学ぶReactHooks[Dialog] | りゅーそうブログ
りゅーそうブログのロゴ

Ryusou.dev

UIを作って学ぶReactHooks[Dialog]

react

2020/08/11

2022/01/23

どうも、りゅーそうです。
連載UIを作りながらReactHooksを学ぶ第二弾になります。
前回はuseState編ということで、ハンバーガーメニューを作成しました。
UIを作って学ぶReact.useState

当初は「今回はuseEffect、その次はuseContext...」と行ったように機能ごとにUIを作りながら連載をしていこうと思いましたが、なかなかアウトプットの効率が悪いので今回からは機能をReactHooksを使って作成するたびにブログを更新していこうと思います。

というわけで、今回はDialogをReactのUIライブラリMaterial-UIを使用して作成してみました。
UIの実装自体はライブラリに委ねてしまいましたが、useState/useEffectの良い勉強になったので作り方を紹介します。

Material-UIとは?


Material-UI
冒頭でも述べたとおり、ReactのUIライブラリです。最低限かつシンプルなマテリアルデザインのコンポーネントを提供しているのでとても使いやすく管理もしやすいお気に入りのライブラリです。
今回作成するDialogのコンポーネントも提供されています。
自前で作成するとなかなか手間がかかるUIを簡単に作成することができます。
HTMLの機能を1から使って作成してみたい方は以下のブログが参考になるかと思います。
Reactアプリのモダールをdialog要素で実装する

使用するReactHooks


以下の2つです。


useState

useStateについては前回の記事で解説したので、ぜひ合わせてご覧ください。
前回の記事ではハンバーガーメニューの実装を例に親コンポーネントからそれぞれの子コンポーネントにstateを共有して状態管理を行う方法を紹介しました(以下図)。

Layoutから状態を共有する

今回はコンポーネント間で直接stateを共有し、UIの遷移を実装します。
以下の図のようにpagesの中のButtonを押すとトリガーとなり、pagesで管理している状態がfalseからtrueに遷移します。それに合わせて,
Dialogコンポーネントで管理している状態をfalseからtrueに遷移させることによってDialogが開閉する仕組みを作成します。

pagesとdialogコンポーネントの関係図

また今回はTODOリストを作成するボタンと編集するボタンの二種類があります。Dialogコンポーネントでそのボタンに合わせて開閉するDialogを共通化し、それぞれの状態を別々に管理する実装を行います。
完成イメージは以下のようになります。

追加ボタンでTODOを追加する

アプリサンプル

追加ボタンを押すとDialogが開く。作成などを押すとDialogが閉じる

ダイアログを表示

ペンマークを押すと編集用のダイアログが開く(作成と同じコンポーネントを使用する)

編集のダイアログ

実際のデータの登録などは行いません。静的画面の遷移のみの実装を行います。

useEffect


useEffectは副作用を扱うためのHooksです
React | useEffect
詳しくは公式のドキュメントをご覧いただけたらと思います。ここでは、簡単にuseEffectの使用方法について解説します。
useEffectはアプリケーションで何かしらの処理が行われ、ページが更新されたのに合わせて(これをレンダー:renderと呼びます)、処理を行う(これを副作用と呼びます)ためのメソッドです。JavaScriptのクロージャという仕組みが内部で実行されているのでuseEffectを宣言するだけで、副作用処理が実行されます。

公式ドキュメントのカウンターアプリの例を引用します。

import React, { useState, useEffect } from 'react';


function Example() {
  const [count, setCount] = useState(0);

  // Similar to componentDidMount and componentDidUpdate:
  useEffect(() => {
    // Update the document title using the browser API
    document.title = `You clicked ${count} times`;
  });


  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}


このようにbuttonがクリックされ、画面がレンダーされるのに合わせて処理を行うことが出来ます。この仕組みを使ってpagesのbuttonが押されレンダーした際に、Dialogコンポーネントの状態を切り替えることでDialogの実装を行います。
また、useEffectは基本的にレンダーごとに処理が実行されますが、その処理を制御することが出来ます。
その方法はuseEffectの第二引数にトリガーとなる値を設定することです。
先ほどのカウンターアプリの例を引用します。

useEffect(() => {
  document.title = `You clicked ${count} times`;
}, [count]); // Only re-run the effect if count changes


このように第二引数にcountというuseStateで宣言したstateを設定することによって、このcountという状態が変化した場合にのみ、イベントを発生させます。
他にもクリーンアップを伴う副作用の例や、複数のuseEffectをコンポーネントで扱う例などが紹介されているので、詳しくは公式ドキュメントをご覧ください。

以上の知識をもとに実装を行いたいと思います。

環境

"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router-dom": "^5.2.0",
"typescript": "~3.7.2"
"@material-ui/core": "^4.11.0",
サンプルはTypeScriptを使用します。


環境構築

プロジェクトの雛形を用意します。今回はcreate-react-appを使用します。

create-react-app --typescript


ページを構成する要素はpages配下に、Dialogを作成するコンポーネントはcomponents/molecules配下に実装していきます。
ディレクトリ構成
src
App.tsx
-pages
--Home.tsx
-components
--molecules
----FormDialog.tsx

//App.tsx pages/Home.tsxを読み込む
import React from 'react'
import { BrowserRouter as Router, Switch, Route } from 'react-router-dom'
import { ThemeProvider } from '@material-ui/core'

import { theme } from './theme'
import { Home } from './pages/Home'

function App() {
    return (
        <ThemeProvider theme={theme}>
            <Router>
                <Switch>
                    <Route exact path="/">
                        <Home />
                    </Route>
                </Switch>
            </Router>
        </ThemeProvider>
    )
}

export default App


Dialogコンポーネントを作成する


componentsにDialogのコンポーネントを作成していきます。
FormDialog.tsxを作成します。
コードは以下のようになります。(コードは関連する部分以外は省略してあります。)

//components/molecules/FormDialog.tsx
import React, { useState, useEffect } from 'react';
import {
  Button,
  Dialog,
  DialogContent,
  DialogActions,
  DialogTitle,
} from '@material-ui/core';

type Props = {
  title: string;
  isOpen: boolean;
  ButtonText: string;
  doClose: () => void;
};

export const FormDialog: React.FC<Props> = ({
  title,
  isOpen,
  ButtonText,
  doClose,
}) => {
  const [open, setOpen] = useState(false);

  useEffect(() => {
    setOpen(isOpen);
  }, [isOpen]);

  const handleClose = () => {
    setOpen(false);
    doClose();
  };

  return (
    <>
      <Dialog
        open={open}
        onClose={handleClose}
        aria-labelledby="form-dialog-title"
      >
        <DialogTitle id="form-dialog-title">{title}</DialogTitle>
        <DialogContent>
         //ここにDialogに表示させたい要素を記述する(ex TextFieldなど
        </DialogContent>
        <DialogActions>
          <Button onClick={handleClose} color="primary">
            キャンセル
          </Button>
          <Button onClick={handleClose} color="primary">
            {ButtonText}
          </Button>
        </DialogActions>
      </Dialog>
    </>
  );
};


Material-UIでDialogを実装

ドキュメントをご参照ください。簡単に実装出来ます。
https://material-ui.com/components/dialogs/

React/TypeScriptとProps

前回の記事でも述べましたが、ReactとTypeScriptの相性は抜群です。Propsに型を与えることが出来ます。
今回のFormDialogコンポーネントでは4つのPropsを渡しています。

type Props = {
  title: string;
  isOpen: boolean;
  ButtonText: string;
  doClose: () => void;
};


Propsを明示的に渡せるのがとても良いですね。React/TypeScript最高です。
titleはDialogのタイトルに当たるPropsです。コンポーネントを使う側で値を与えてあげることが出来ます。

pages/Home.tsx 後ほど解説します
 <FormDialog title="調整を作成する"  isOpen={open}  ButtonText="作成"  doClose={() => handleClose()} />


こうした仕組みに寄ってコンポーネントを使い回すことが出来ます。
重要なのはisOpenというboolean型のPropsです。このPropsを使ってtrueとfalseの切り替えを実装します。

const [open, setOpen] = useState(false);

  useEffect(() => {
    setOpen(isOpen);
  }, [isOpen]);


まずはuseStateを使ってコンポーネントにStateを実装していきます。初期値にfalseを設定し、falseとtrueの二つの状態をコンポーネントに持たせることが出来ます。
この状態をきり変える役割をここで果たしているのがuseEffectです。useEffectの説明でも述べた通り、useEffectは第二引数にトリガーとなる値を設定できるので、先ほどのProps(isOpen)を設定します。
このisOpenの値が切り替わるたびにこのDialogが開閉する仕組みをここでは実装しています。

isOpenの値を切り替える実装はHome.tsxで実装します。(
後ほど解説します。)
最後に、FormDialogではボタンを押すとDialogが閉じる仕組みを実装します。

          <Button onClick={handleClose} color="primary">
            {ButtonText}
          </Button>

ButtonTextはstring型を持つPropsです。先ほどのtitleと使い方は同じです。ButtonにonClickイベントを実装します。ボタンが押された時にイベントを発火させます。

  const handleClose = () => {
    setOpen(false);
    doClose();
  };


そのイベントがhandleCloseです。まずはsetOpenにfalseという値を渡すことによって、Dialogを閉じます。そしてdoCloseというvoid型のPropsを実行します。(このPropsの使用方法についても後ほど解説します。)
これでDialogの準備が整いました!


HomeページでDialogコンポーネントを扱う

仕上げです。
作成したコンポーネントFormDialog.tsxをHome.tsxで扱います。先にコードをご覧ください。

//pages/Home.tsx
import React, { useState } from 'react';
import {
  Grid,
  Button,
  Typography,
} from '@material-ui/core';

import { Layout } from '../components/layout';
import { FormDialog } from '../components/molecules/FormDialog';

export const Home: React.FC = () => {
  const [open, setOpen] = useState(false);
  const [Edit, setEdit] = useState(false);

  const handleOpen = () => {
    setOpen(true);
  };
  const handleClose = () => {
    setOpen(false);
  };
  const EditOpen = () => {
    setEdit(true);
  };
  const EditClose = () => {
    setEdit(false);
  };

  return (
    <>
      <Layout>
        <Grid
          container
          alignItems="center"
          justify="flex-end"
        >
          <Button onClick={handleOpen}>
            <AddCircleIcon fontSize="large" />
            <Typography>追加する</Typography>
          </Button>
          <FormDialog
            title={'調整を作成する'}
            isOpen={open}
            ButtonText="作成"
            doClose={() => handleClose()}
          />
        </Grid>
        <Button onClick={EditOpen}>
          Button
        </Button>
        <FormDialog
          title="調整を編集する"
          isOpen={Edit}
          ButtonText="編集"
          doClose={() => EditClose()}
        />
      </Layout>
    </>
  );
};


先ほど作成したFormDialogコンポーネントを使って作成するためのDialogと編集するためのDialogの2つをpegesに実装していきます。

作成するDialogを実装

Home.tsxでもuseStateを使ってStateをコンポーネントに持たせていきます。

  const [open, setOpen] = useState(false);


初期値はfalseで、trueとfalseの2つの状態を持たせることが出来ます。
そして2つのイベントを作成します。

  const handleOpen = () => {
    setOpen(true);
  };
  const handleClose = () => {
    setOpen(false);
  };


handleOpemはStateをtrueに切り替えるためのメソッド、handleCloseはfalseを与えるメソッドです。
これらのStateとメソッドは以下のようにして使用します。

    <Button onClick={handleOpen}>
            <AddCircleIcon fontSize="large" />
            <Typography>追加する</Typography>
          </Button>
          <FormDialog
            title={'調整を作成する'}
            isOpen={open}
            ButtonText="作成"
            doClose={() => handleClose()}
          />


FormDialogコンポーネントで渡したisOpenというPropsを利用します。
ButtonをクリックするとhandleOpenメソッドが発火、それに合わせてopenの値が変化しisOpenの値が切り替わります。
よって、FormDialogコンポーネントが持っているStateも切り替わりDialogが開く仕組みです。
またdoCloseというPropsにhandleCloseメソッドを渡すことによって、HomeコンポーネントのStateが切り替わるのと同時にFormDialogコンポーネントでもdoClose()が実行され、Stateが切り替わりDialogが閉じます。
このようにしてHomeページとFormDialogコンポーネント間でStateを連動させることによってUIの状態遷移を実装します。


編集するDialogを実装

あとは簡単です。編集する際のDialogにも別のStateを実装して、別々に状態管理を行えば良いだけです。

  <Button onClick={EditOpen}>
          Button
        </Button>
        <FormDialog
          title="調整を編集する"
          isOpen={Edit}
          ButtonText="編集"
          doClose={() => EditClose()}
        />
      </Layout


仕組みは先ほどと同じです。このようにコンポーネントを簡単に使いまわせるのは良いですね。

最後に

蛇足ですが、関連コードの全体を載せておきます。

//pages/Home.tsx
import React, { useState } from 'react';
import dayjs from 'dayjs';
import 'dayjs/locale/ja';

import { makeStyles, createStyles, Theme } from '@material-ui/core/styles';
import {
  Table,
  TableBody,
  TableCell,
  TableContainer,
  TableHead,
  TableRow,
  Paper,
  Grid,
  Button,
  Typography,
} from '@material-ui/core';
import DeleteIcon from '@material-ui/icons/Delete';
import AddCircleIcon from '@material-ui/icons/AddCircle';
import CreateIcon from '@material-ui/icons/Create';

import { Layout } from '../components/layout';
import { FormDialog } from '../components/molecules/FormDialog';

const useStyles = makeStyles((theme: Theme) =>
  createStyles({
    addButton: {
      padding: '24px',
      '& button': {
        color: theme.palette.secondary.main,
        '& p': {
          fontWeight: 'bold',
        },
      },
    },
    tableContainer: {
      paddding: '12px',
    },
    table: {
      padding: '12px',
    },
  })
);

// mockデータを作成する関数
function createData(id: number, day: string, overTime: string) {
  return { id, day, overTime };
}
// mockデータ
const rows = [
  createData(1, '2020-07-21', '01:00'),
  createData(2, '2020-08-15', '00:30'),
];

export const Home: React.FC = () => {
  const classes = useStyles();
  const [open, setOpen] = useState(false);
  const [Edit, setEdit] = useState(false);

  const handleOpen = () => {
    setOpen(true);
  };
  const handleClose = () => {
    setOpen(false);
  };
  const EditOpen = () => {
    setEdit(true);
  };
  const EditClose = () => {
    setEdit(false);
  };

  return (
    <>
      <Layout>
        <Grid
          container
          alignItems="center"
          justify="flex-end"
          className={classes.addButton}
        >
          <Button onClick={handleOpen}>
            <AddCircleIcon fontSize="large" />
            <Typography>追加する</Typography>
          </Button>
          <FormDialog
            title={'調整を作成する'}
            isOpen={open}
            ButtonText="作成"
            doClose={() => handleClose()}
          />
        </Grid>
        <TableContainer component={Paper} className={classes.tableContainer}>
          <Table className={classes.table} aria-label="simple table">
            <TableHead>
              <TableRow>
                <TableCell>日付</TableCell>
                <TableCell>調整取得時間</TableCell>
                <TableCell>失効日</TableCell>
                <TableCell>使用/編集</TableCell>
              </TableRow>
            </TableHead>
            <TableBody>
              {rows.map((row) => (
                <TableRow key={row.id}>
                  <TableCell component="th" scope="row">
                    {dayjs(row.day).format('YYYY年MM月DD日')}
                  </TableCell>
                  <TableCell>{row.overTime}</TableCell>
                  <TableCell>
                    {dayjs(row.day).add(16, 'week').format('YYYY年MM月DD日')}
                  </TableCell>
                  <TableCell>
                    <Button>
                      <DeleteIcon />
                    </Button>
                    <Button onClick={EditOpen}>
                      <CreateIcon />
                    </Button>
                  </TableCell>
                </TableRow>
              ))}
            </TableBody>
          </Table>
        </TableContainer>
        <FormDialog
          title="調整を編集する"
          isOpen={Edit}
          ButtonText="編集"
          doClose={() => EditClose()}
        />
      </Layout>
    </>
  );
};


//components/molecules/FormDialog/tsx
import React, { useState, useEffect } from 'react';
import {
  Button,
  Dialog,
  DialogContent,
  DialogActions,
  DialogTitle,
  TextField,
  MenuItem,
  Grid,
} from '@material-ui/core';
import {
  MuiPickersUtilsProvider,
  KeyboardDatePicker,
} from '@material-ui/pickers';
import jaLocale from 'date-fns/locale/ja';
import DateFnsUtils from '@date-io/date-fns';

const Times = ['00:30', '01:00', '01:30', '02:00'];

type Props = {
  title: string;
  isOpen: boolean;
  ButtonText: string;
  doClose: () => void;
};

export const FormDialog: React.FC<Props> = ({
  title,
  isOpen,
  ButtonText,
  doClose,
}) => {
  const [open, setOpen] = useState(false);
  const [selectedDate, setSelectedDate] = useState<Date | null>(new Date());
  const [time, setTime] = useState<string>('30');

  useEffect(() => {
    setOpen(isOpen);
  }, [isOpen]);

  const handleClose = () => {
    setOpen(false);
    doClose();
  };

  const handleDateChange = (date: Date | null) => {
    setSelectedDate(date);
  };

  const handleTimeChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    setTime(event.target.value);
  };

  return (
    <>
      <Dialog
        open={open}
        onClose={handleClose}
        aria-labelledby="form-dialog-title"
      >
        <DialogTitle id="form-dialog-title">{title}</DialogTitle>
        <DialogContent>
          <Grid container direction="column">
            <MuiPickersUtilsProvider utils={DateFnsUtils} locale={jaLocale}>
              <KeyboardDatePicker
                margin="normal"
                id="date-picker-dialog"
                label="Date picker dialog"
                format="yyyy年MM月dd日"
                value={selectedDate}
                onChange={handleDateChange}
                KeyboardButtonProps={{
                  'aria-label': 'change date',
                }}
              />
            </MuiPickersUtilsProvider>
            <TextField
              id="standard-select-currency"
              select
              label="Select"
              value={time}
              onChange={handleTimeChange}
              helperText="調整取得時間を選択してください"
            >
              {Times.map((time) => (
                <MenuItem key={time} value={time}>
                  {time}
                </MenuItem>
              ))}
            </TextField>
          </Grid>
        </DialogContent>
        <DialogActions>
          <Button onClick={handleClose} color="primary">
            キャンセル
          </Button>
          {/* TODO:処理を加える関数に変更する */}
          <Button onClick={handleClose} color="primary">
            {ButtonText}
          </Button>
        </DialogActions>
      </Dialog>
    </>
  );
};


作りかけなので参考までにどうぞ。

まとめ

コンポーネント側に必要なPropsを持たせて、状態を管理するのがReact開発の肝。
State管理が増えたときにどのようにみやすいコードにしていくかが今後の課題です。