【React + Recoil】ちょっとリッチな ToDo アプリ
おいしそうなタイトルになってしまいました。
今回は ToDo アプリを作成しました。
タイピング練習アプリ制作のときにはスパイス的に入れていた Material-UI を、
今回は全面的に利用しています。
また、2020年5月に発表された状態管理ライブラリ Recoil を試験的に利用しています。
この記事では、Recoil の Atom という機能の簡単な説明をした後に、
実際の実装について書いていきます。
今回は React Hooks や Material-UI や TypeScript を利用していますが、
それらの簡単な説明についてはこちらをご覧ください。
Recoil とは
Recoil は2020年5月に発表されたばかりの新しい React のための状態管理ライブラリです。
React はいくつかの state を持つことが多いのですが、
アプリが大規模になってくると、state の管理が React だけでは辛くなってきます。
また、コンポーネントが増えると、props で変数を上位のコンポーネントに渡していく「バケツリレー」が発生し、
効率が悪くなります。
そこで、Redux などの状態管理ライブラリが使われます。
管理下に置いている state はどのコンポーネントからも呼び出しが可能です。
Recoil は、React Hooks とほぼ同じ書き方で状態管理を実現でき、
Redux よりも直観的で導入しやすいと感じたので、
Recoil を選択しました。
Recoil のインストールは、コマンドでnpm install recoil
またはyarn add recoil
とすることでできます。
Atom
Atom は管理下におく state のことです。
次のように定義します。
/src/atoms/text.js1import { atom } from 'recoil';23export const textState = atom({4key: 'textState',5default: ''6});
key
には全体の中で一意的な(グローバルにユニークな)ID を指定します。
default
はデフォルト値です。
これをコンポーネントファイルの中で使うには、次のようにします。
/src/components/App.js1import { useRecoilState } from 'recoil';2import { textState } from '../atoms/text';34export default function App() {5const [text, setText] = useRecoilState(textState);6...7}
なんと React Hooks の useState とほぼ同じような形で state を呼び出すことができます。
また、読み込み専用、書き込み専用の関数も用意されています。
/src/components/App.js1import { useRecoilValue, useSetRecoilState } from 'recoil';2import { textState } from '../atoms/text';34export default function App() {5const text = useRecoilValue(textState); // 読み込み専用6const setText = useSetRecoilState(textState); // 書き込み専用7...8}
これらの関数を使うことで、より効率的な処理が行われます。
なお、Recoil を使うときは、
使用範囲に含める最上階層のコンポーネントをRecoilRoot
タグで囲む必要があります。
実装
ここからは、実際の実装について解説していきます。
ヘッダの設置
Material-UI App Bar より、Simple App Bar を使って、
ヘッダコンポーネントを作成します。
コードサンプルが提示されているので、基本的にはそれをもとにしてコーディングしていきます。
/src/components/TodoAppBar.tsx1import React from 'react';2import AppBar from '@material-ui/core/AppBar';3import Toolbar from '@material-ui/core/Toolbar';4import Typography from '@material-ui/core/Typography';56export default function TodoAppBar() {7return (8<AppBar position="static">9<Toolbar>10<Typography variant="h6">TO DO</Typography>11</Toolbar>12</AppBar>13);14}
Typography
の部分は、直接h6
と書いても大丈夫です。
/src/App.tsx1import React from 'react';23import TodoAppBar from './components/TodoAppBar';45import './styles.css';67export default function App() {8return (9<DialogContent className="App">10<TodoAppBar />11</div>12);13}
出ました!
タスク未登録のときの画面
タスクが登録されていないことを伝える文と、
タスク登録のためのボタンを配置します。
Material-UI Button から Contained Buttons を使用します。
/src/components/TodoList.tsx1import React from 'react';23import { makeStyles, createStyles, Theme } from '@material-ui/core/styles';4import Box from '@material-ui/core/Box';5import Typography from '@material-ui/core/Typography';6import Button from '@material-ui/core/Button';78const useStyles = makeStyles((theme: Theme) =>9createStyles({10button: {11'&:hover': {12backgroundColor: '#6666ff'13}14}15})16);1718export default function TodoList() {19const classes = useStyles();2021return (22<Box padding="2rem" textAlign="center">23<Typography variant="subtitle1" gutterBottom>24まだ登録されたタスクはありません。25</Typography>26<Button27className={classes.button}28variant="contained"29color="primary"30>31タスクを登録する32</Button>33</Box>34);35}
8-16行目は、Material-UI からスタイル指定ができるというものです。
makeStyles - createStyles
はおまじないのように書いてもらって大丈夫です。
createStyles
の中に、クラス名、スタイルプロパティ、スタイルを書き込んでいきます。
コンポーネント内部でこれを呼び出し、JSX のタグにclassName={classes.button}
のように指定します。
22行目のBox
は Material-UI のものですが、
スタイルを直接書き込むことができます。
出力はデフォルトではdiv
になります。
/src/App.tsx1import React from 'react';23import TodoAppBar from './components/TodoAppBar';4import TodoList from './components/TodoList';56import './styles.css';78export default function App() {9return (10<div className="App">11<TodoAppBar />12<TodoList />13</div>14);15}
タスク登録ダイアログの表示
次はボタンを押したら情報を入力するダイアログを表示させます。
Dialog の Form dialogs を参考にします。
/src/components/RegisterDialog.tsx1import React from 'react';23import Button from '@material-ui/core/Button';4import Dialog from '@material-ui/core/Dialog';5import DialogActions from '@material-ui/core/DialogActions';6import DialogTitle from '@material-ui/core/DialogTitle';7import DialogContent from '@material-ui/core/DialogContent';8import DialogContentText from '@material-ui/core/DialogContentText';910type Props = {11open: boolean;12onClose: () => void;13};1415export default function RegisterDialog({ open, onClose }: Props) {16return (17<Dialog18open={open}19onClose={onClose}20aria-labelledby="form-dialog-title"21fullWidth22>23<DialogTitle>タスク登録</DialogTitle>24<DialogContent>25<DialogContentText>26登録するタスクの情報を入力してください。27</DialogContentText>28</DialogContent>29<DialogActions>30<Button onClick={onClose} color="primary">31もどる32</Button>33<Button color="primary">34登録35</Button>36</DialogActions>37</Dialog>38);39}
open
とonClose
を props として親コンポーネントに渡しています。
このダイアログを、さきほどのボタンを押したときに出現するようにします。
/src/components/TodoList.tsx1import React, { useState } from 'react';23import { makeStyles, createStyles, Theme } from '@material-ui/core/styles';4import Box from '@material-ui/core/Box';5import Typography from '@material-ui/core/Typography';6import Button from '@material-ui/core/Button';78import RegisterDialog from './RegisterDialog';910...1112export default function TodoList() {13const classes = useStyles();1415const [open, setOpen] = useState<boolean>(false);1617const handleOpen = () => setOpen(true);1819const handleClose = () => setOpen(false);2021return (22<>23<Box padding="2rem" textAlign="center">24<Typography variant="subtitle1" gutterBottom>25まだ登録されたタスクはありません。26</Typography>27<Button28className={classes.button}29onClick={handleOpen}30variant="contained"31color="primary"32>33タスクを登録する34</Button>35</Box>36<RegisterDialog open={open} onClose={handleClose} />37</>38);39}
22行目と37行目の<></>
ですが、
React では return で返す JSX はひとつのタグで全体が囲まれていなければなりません。
そこで不定のタグで全体を囲っています。
これは別にdiv
とかでもいいのですが、
HTML に現れない<></>
を使っています。
ダイアログが表示されました!
タスク登録ダイアログの入力部分
入力させる情報は、
- 内容
- 期限
- 優先度
の3つです。
内容はテキスト、期限は日付(カレンダー)、優先度は数値とスライダーを使います。
テキスト部分は Text Field、期限は Pickers、
スライダーは Slider の Label always visible と Slider with input field を使って、
これらを Grid で並べています。
長くなったので、コンポーネントとして分けました。
入力した情報は state として管理下におきたいので、atom の設定をします。
/src/atoms/RegisterDialogContent.tsx1import { atom } from 'recoil';23export const taskContentState = atom<string>({4key: 'taskContentState',5default: ''6});78export const taskDeadlineState = atom<Date>({9key: 'taskDeadlineState',10default: new Date()11});1213export const taskPriorityState = atom<number>({14key: 'taskPriorityState',15default: 116});
これらをコンポーネントファイルで呼び出して使用します。
/src/components/RegisterDialogContent.tsx1import React from 'react';2import { useRecoilState, useSetRecoilState } from 'recoil';34import Grid from '@material-ui/core/Grid';5import TextField from '@material-ui/core/TextField';6import Slider from '@material-ui/core/Slider';7import Input from '@material-ui/core/Input';8import DialogContent from '@material-ui/core/DialogContent';9import DialogContentText from '@material-ui/core/DialogContentText';10import DateFnsUtils from '@date-io/date-fns';11import {12MuiPickersUtilsProvider,13KeyboardDatePicker14} from '@material-ui/pickers';1516import {17taskContentState,18taskDeadlineState,19taskPriorityState20} from '../atoms/RegisterDialogContent';2122export default function RegisterDialogContent() {23// atom から state を取得する24const setContent = useSetRecoilState(taskContentState);25const [deadline, setDeadline] = useRecoilState(taskDeadlineState);26const [priority, setPriority] = useRecoilState(taskPriorityState);2728// タスクの内容が変更されたとき29const handleContentChange = (30e: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>31) => {32setContent(e.target.value);33};3435// タスクの期限が変更されたとき36const handleDeadlineChange = (date: any) => {37setDeadline(date);38};3940// スライダーが動かされたとき41const handleSliderChange = (e: React.ChangeEvent<{}>, newValue: any) => {42setPriority(newValue);43};4445// スライダー横の数値入力欄が変更されたとき46const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {47setPriority(Number(e.target.value));48};4950// 数値入力欄で1~5以外の数値が指定されたとき51const handleBlur = () => {52if (priority < 1) {53setPriority(1);54} else if (priority > 5) {55setPriority(5);56}57};5859return (60{// このタグ内にある部分が pickers のカバーする範囲になる }61<MuiPickersUtilsProvider utils={DateFnsUtils}>62<DialogContent>63<DialogContentText>64登録するタスクの情報を入力してください。65</DialogContentText>66<Grid container spacing={6} direction="column">67<Grid item>68<TextField69onChange={handleContentChange}70margin="dense"71id="name"72label="内容"73fullWidth {// 横幅いっぱいにする }74/>75<KeyboardDatePicker76disableToolbar77variant="inline" {// カレンダーが出現する位置 }78format="yyyy/MM/dd" {// 表示する日付のフォーマット }79minDate={new Date()} {// 現在の日より前の日は選択不可 }80margin="normal"81id="date-picker-inline"82label="期限"83value={deadline}84onChange={date => handleDeadlineChange(date)}85invalidDateMessage="無効な形式です"86minDateMessage="昨日以前の日付を指定することはできません"87/>88</Grid>89<Grid container item spacing={2}>90<Grid item xs={2}>91<DialogContentText>優先度</DialogContentText>92</Grid>93<Grid item xs={8}>94<Slider95value={priority}96onChange={handleSliderChange}97defaultValue={1} {// デフォルト値 }98aria-valuetext=""99aria-labelledby="discrete-slider"100valueLabelDisplay="on" {// 数字の吹き出しを常に表示する }101step={1} {// 変動幅 }102marks {// 境界に印をつける }103min={1} {// 最小値 }104max={5} {// 最大値 }105/>106</Grid>107<Grid item xs={2}>108<Input109value={priority}110margin="dense"111onChange={handleInputChange}112onBlur={handleBlur}113inputProps={{114step: 1,115min: 1,116max: 5,117type: 'number',118'aria-labelledby': 'input-slider'119}}120/>121</Grid>122</Grid>123</Grid>124</DialogContent>125</MuiPickersUtilsProvider>126);127}
これを親コンポーネントRegisterDialog.tsx
で呼び出します。
/src/components/RegisterDialog.tsx1...23import RegisterDialogContent from './RegisterDialogContent';45...67export default function RegisterDialog({ open, onClose }: Props) {8return (9<Dialog10open={open}11onClose={onClose}12aria-labelledby="form-dialog-title"13fullWidth14>15<DialogTitle>タスク登録</DialogTitle>16<RegisterDialogContent />17<DialogActions>18<Button onClick={onClose} color="primary">19もどる20</Button>21<Button color="primary">登録</Button>22</DialogActions>23</Dialog>24);25}
また、Recoil を使ったので、
使用範囲に含める最上階層のコンポーネントの JSX をRecoilRoot
タグで囲む必要があります。
/src/App.tsx1import React from 'react';2import { RecoilRoot } from 'recoil';34import TodoAppBar from './components/TodoAppBar';5import TodoList from './components/TodoList';67import './styles.css';89export default function App() {10return (11<RecoilRoot>12<div className="App">13<TodoAppBar />14<TodoList />15</div>16</RecoilRoot>17);18}
できました!
タスクが登録されているときの画面
まず、タスク一覧を atom で設定します。
/src/atoms/Tasks.tsx1import { atom } from 'recoil';23export const tasksState = atom<4{ content: string; deadline: any; priority: number }[]5>({6key: 'tasksState',7default: []8});
ダイアログの登録ボタンを押したときに、atom に値を追加します。
/src/components/RegisterDialog.tsx1...2import { useRecoilValue, useRecoilState } from 'recoil';34...56import {7taskContentState,8taskDeadlineState,9taskPriorityState10} from '../atoms/RegisterDialogContent';1112import { tasksState } from '../atoms/Tasks';1314...1516export default function RegisterDialog({ open, onClose }: Props) {17const taskContent = useRecoilValue(taskContentState);18const taskDeadline = useRecoilValue(taskDeadlineState);19const taskPriority = useRecoilValue(taskPriorityState);20const [tasks, setTasks] = useRecoilState(tasksState);2122const handleRegister = () => {23setTasks([24...tasks,25{26content: taskContent,27deadline: taskDeadline,28priority: taskPriority29}30]);31onClose();32};3334return (35<Dialog36open={open}37onClose={onClose}38aria-labelledby="form-dialog-title"39fullWidth40>41<DialogTitle>タスク登録</DialogTitle>42<RegisterDialogContent />43<DialogActions>44<Button onClick={onClose} color="primary">45もどる46</Button>47<Button onClick={handleRegister} color="primary">48登録49</Button>50</DialogActions>51</Dialog>52);53}
tasks
にはオブジェクトを入れています。
ボタンを押すとダイアログを閉じるので、handleRegister
関数の中にもonClose()
を書いています。
続いてタスクの一覧を表示する表のコンポーネントを作成します。
/src/components/TodoTable.tsx1import React from 'react';2import { useRecoilState } from 'recoil';34import Table from '@material-ui/core/Table';5import TableHead from '@material-ui/core/TableHead';6import TableBody from '@material-ui/core/TableBody';7import TableCell from '@material-ui/core/TableCell';8import TableContainer from '@material-ui/core/TableContainer';9import TableRow from '@material-ui/core/TableRow';10import { format } from 'date-fns';1112import { tasksState } from '../atoms/Tasks';1314export default function TodoTable() {15const [tasks, setTasks] = useRecoilState(tasksState);1617return (18<TableContainer>19<Table>20<TableHead>21<TableRow>22<TableCell>タスク</TableCell>23<TableCell align="center">期日</TableCell>24<TableCell align="center">優先度</TableCell>25</TableRow>26</TableHead>27<TableBody>28{tasks.map((task: any) => (29<TableRow>30<TableCell>{task.content}</TableCell>31<TableCell align="center">32{// 年/月/日の形式に変換して表示する }33{format(task.deadline, 'yyyy/MM/dd')}34</TableCell>35<TableCell align="center">{task.priority}</TableCell>36</TableRow>37))}38</TableBody>39</Table>40</TableContainer>41);42}
登録されたタスクがひとつでもあれば、この表を出現させます。
タスクの一覧を Table を使って表示し、
タスク追加のアイコンボタンを Floating Action Button を使って置いています。
/src/components/TodoList.tsx1import { useRecoilValue } from 'recoil';23...45import Fab from '@material-ui/core/Fab';6import AddIcon from '@material-ui/icons/Add';78...910import TodoTable from './TodoTable';1112import { tasksState } from '../atoms/Tasks';1314const useStyles = makeStyles((theme: Theme) =>15createStyles({16button: {17'&:hover': {18backgroundColor: '#6666ff'19}20},21fab: {22position: 'absolute',23bottom: '2rem',24right: '2rem',25'&:hover': {26backgroundColor: '#6666ff'27}28}29})30);3132export default function TodoList() {33const classes = useStyles();3435const tasks = useRecoilValue(tasksState);36const [open, setOpen] = useState<boolean>(false);3738const handleOpen = () => setOpen(true);3940const handleClose = () => setOpen(false);4142return (43<>44<Box padding="2rem" textAlign="center">45{tasks.length !== 0 ? (46<>47<TodoTable />48<Fab49className={classes.fab}50onClick={handleOpen}51color="primary"52aria-label="add"53>54<AddIcon />55</Fab>56</>57) : (58<>59<Typography variant="subtitle1" gutterBottom>60まだ登録されたタスクはありません。61</Typography>62<Button63className={classes.button}64onClick={handleOpen}65variant="contained"66color="primary"67>68タスクを登録する69</Button>70</>71)}72</Box>73<RegisterDialog open={open} onClose={handleClose} />74</>75);76}
三項演算子を使用して、tasks
の要素が存在するかどうかで条件分岐をしています。
タスクの削除
選択したタスクが削除できるようにします。
選択には Checkbox を使います。
Table の Sorting & Selecting を参考にします。
/src/components/TodoTable.tsx1import React, { useState } from 'react';2...3import IconButton from '@material-ui/core/IconButton';4import DeleteIcon from '@material-ui/icons/Delete';5import Checkbox from '@material-ui/core/Checkbox';6...78export default function TodoTable() {9const [tasks, setTasks] = useRecoilState(tasksState);10const [selected, setSelected] = useState<number[]>([]);1112// すべてのタスクを選択する13const handleSelectAll = (e: React.ChangeEvent<HTMLInputElement>) => {14if (e.target.checked) {15setSelected([...Array(tasks.length).keys()]);16return;17}18setSelected([]);19};2021// 特定のタスクを選択する22const handleCheck = (e: React.ChangeEvent<HTMLInputElement>, i: number) => {23const selectedIndex = selected.indexOf(i);24let newSelected: number[] = [];2526if (selectedIndex === -1) {27newSelected = newSelected.concat(selected, i);28} else if (selectedIndex === 0) {29newSelected = newSelected.concat(selected.slice(1));30} else if (selectedIndex === selected.length - 1) {31newSelected = newSelected.concat(selected.slice(0, -1));32} else if (selectedIndex > 0) {33newSelected = newSelected.concat(34selected.slice(0, selectedIndex),35selected.slice(selectedIndex + 1)36);37}3839setSelected(newSelected);40};4142// 選択したタスクを消去する43const handleDelete = () => {44let newTasks = tasks.filter(45(e: object, i: number) => selected.indexOf(i) === -146);47setTasks(newTasks);48setSelected([]);49};5051return (52<>53<IconButton54onClick={handleDelete}55disabled={selected.length === 0}56aria-label="delete"57>58<DeleteIcon />59</IconButton>60<TableContainer>61<Table>62<TableHead>63<TableRow>64<TableCell padding="checkbox">65<Checkbox66checked={tasks.length > 0 && tasks.length === selected.length}67onChange={handleSelectAll}68/>69</TableCell>70<TableCell>タスク</TableCell>71<TableCell align="center">期日</TableCell>72<TableCell align="center">優先度</TableCell>73</TableRow>74</TableHead>75<TableBody>76{tasks.map((task: any, index: number) => (77<TableRow>78<TableCell padding="checkbox">79<Checkbox80checked={selected.indexOf(index) !== -1}81onChange={(e: any) => handleCheck(e, index)}82/>83</TableCell>84<TableCell>{task.content}</TableCell>85<TableCell align="center">86{format(task.deadline, 'yyyy/MM/dd')}87</TableCell>88<TableCell align="center">{task.priority}</TableCell>89</TableRow>90))}91</TableBody>92</Table>93</TableContainer>94</>95);96}
タスクの並び替え
期限や優先度順に並び替えられるようにします。
tasks
の要素となるオブジェクトを、そのdeadline
やpriority
によって並び替えることになります。
/src/components/TodoTable.tsx1...2import TableSortLabel from '@material-ui/core/TableSortLabel';3...45const sortTasks = (6arr: { content: string; deadline: any; priority: number }[],7sortBy: 'deadline' | 'priority',8order: 'asc' | 'desc'9) =>10arr.sort(11(12a: { content: string; deadline: any; priority: number },13b: { content: string; deadline: any; priority: number }14) => (order === 'asc' ? a[sortBy] - b[sortBy] : b[sortBy] - a[sortBy])15);1617export default function TodoTable() {18const [tasks, setTasks] = useRecoilState(tasksState);19const [selected, setSelected] = useState<number[]>([]);20const [order, setOrder] = useState<'asc' | 'desc'>('asc');21const [orderBy, setOrderBy] = useState<'deadline' | 'priority' | ''>('');2223const handleSort = (sortBy: 'deadline' | 'priority') => (24e: React.MouseEvent25) => {26let newOrder: 'asc' | 'desc' =27orderBy === sortBy ? (order === 'asc' ? 'desc' : 'asc') : 'asc';28setOrderBy(sortBy);29setOrder(newOrder);30setTasks(sortTasks(tasks.concat(), sortBy, newOrder));31};3233...3435return (36...37<TableHead>38<TableRow>39<TableCell padding="checkbox">40<Checkbox41checked={tasks.length > 0 && tasks.length === selected.length}42onChange={handleSelectAll}43/>44</TableCell>45<TableCell>タスク</TableCell>46<TableCell align="center">47<TableSortLabel48active={orderBy === 'deadline'}49direction={order === 'asc' ? 'desc' : 'asc'}50onClick={handleSort('deadline')}51>52期日53</TableSortLabel>54</TableCell>55<TableCell align="center">56<TableSortLabel57active={orderBy === 'priority'}58direction={order === 'asc' ? 'desc' : 'asc'}59onClick={handleSort('priority')}60>61優先度62</TableSortLabel>63</TableCell>64</TableRow>65</TableHead>66...67);
sort
メソッドは、配列を引数の関数に従って並び変えるです。
要素となるオブジェクトのdeadline
またはpriority
にもとづいて、
正順または逆順に並べ替えるようにしています。
30行目のtasks.concat()
は、tasks
のコピーを作っています。
sort
メソッドは破壊的処理なので、このようにしないとtasks
自体を変更しようとしてエラーが発生します。
49, 58行目は、矢印の向きを指定しています。
長くなりましたが、React + Recoil + Material-UI + TypeScript での ToDo アプリの実装について書きました。
Material-UI で本当にいろいろなことが比較的簡単にできて楽しい!というのと、
Recoil が React Hooks からシームレスに移行できて学習コストも意外と低い!という印象でした。
この記事が参考になれば幸いです。
ではまた