フロントエンド側 React フォルダ構成/API通信/json-server/共通レイアウト/メニュー分割

まだまだパクリ28発目!!
いきなり寒くなって眠い。

パクリ元

内容

前回の続きを。

  • PCでのメニューは常時表示
  • メニュー内のサブメニュー表示
  • ログイン/ログアウト
  • 最適なフォルダ構成    ←←← 今回ココから。
  • 外部API呼び出し
  • 共通レイアウト・メニューの作り方をなんとかしたい
  • ダイアログ切り離し
  • import 階層
  • リンクの文字列をなんとかしたい/APIのURL
  • 一覧表示/地図表示/詳細表示
  • 検索

最適なフォルダ構成

今までずっと src 配下にベタに作っていたので実は見にくくてたまらんかった。パクリ元1つ目を参考に変更する。App.tsx も Todo.tsx に、ToolBar.tsx も Header.tsx に変更する。小さいお試し PJ でロジックもビューもごっちゃになっているので都合よく変更していく。

├── assets
│   └── react.svg
├── components
│   └── composite
│       ├── ErrorPage.tsx
│       ├── Header.tsx
│       └── MessageDialog.tsx
├── features
│   ├── login
│   │   └── login-form
│   │       └── components
│   │           └── Login.tsx
│   ├── share-application
│   │   └── qr
│   │       └── components
│   │           └── QR.tsx
│   ├── sidebar
│   │   └── sidebar-list
│   │       ├── components
│   │       │   └── SideBar.tsx
│   │       └── types
│   │           └── MenuItem.d.ts
│   └── todo
│       ├── _shared
│       │   └── logics
│       │       └── isTodos.ts
│       └── todo-list
│           ├── components
│           │   ├── ActionButton.tsx
│           │   ├── AlertDialog.tsx
│           │   ├── FormDialog.tsx
│           │   ├── Todo.tsx
│           │   └── TodoItem.tsx
│           └── types
│               ├── Filter.d.ts
│               └── Todo.d.ts
├── index.css
├── main.tsx
├── routes
│   └── Router.tsx
├── stores
│   └── account
│       └── accountState.tsx
└── vite-env.d.ts

ヘッダやサイドメニューが共通側に入れるか気になるけどだいぶ見通しが良くなった。

外部API呼び出し

とうとうAPI呼び出しまでやってきた。パクリ元2つ目、3つ目、4つ目を参考に Fetch API というのでやってみる。まずは大元のやつを4つ目パクリ。fetch てあんま使ってないワードだったので(DB のストアドで取得したデータを一件ずつナメるイメージだからこういう箇所は get じゃないの?という感じ。)不思議に思っているが useState で setState があるから set/get で間違えないようになのか、フロントエンド周辺だと当然なのか、、どうでもいいけど。

infrastructures/api/_base.ts
export const fetchWithErrorHandling = <T>(
    url: RequestInfo,
    options: RequestInit
  ): Promise<T> =>
    fetch(url, options)
      .catch((e) => {
        throw Error(e);
      })
      .then(handleErrors)
      .then((res) => res?.json());
  
  const handleErrors = async (res: void | Response) => {
    if (!res) return;
    if (res.ok) return res;
  
    let body: any | undefined = undefined;
    try {
      body = await res.json();
    } catch {
      // Non json response
    }
  
    switch (res.status) {
      case 400:
        throw new Error('INVALID_TOKEN');
      case 401:
        throw new Error('UNAUTHORIZED');
      case 403:
        throw new Error('FORBIDDEN');
      case 500:
        throw new Error('INTERNAL_SERVER_ERROR');
      case 502:
        throw new Error('BAD_GATEWAY');
      case 404:
        throw new Error('NOT_FOUND');
      default:
        throw new Error('UNHANDLED_ERROR');
    }
  };

そしてユーザー系リポジトリ作成。

infrastructures/api/userApi.ts
import { fetchWithErrorHandling } from "./_base";

export const authUserByIdAndPassword = async (_id: string, _password: string) => {
    const data = {id: _id, password: _password};
    return await fetchWithErrorHandling<User>("http://localhost:33000/auth", {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(data),
      });
};

取得データ当てはめ用の型も作る。

infrastructures/types/User.ts
declare type User = {
    id: string;
    name: string;
    password: string;
};

ログイン時に呼ぶように変更する。ここではuseEffect で呼ぶようにはならず、onClick から直接。

features/login/login-form/components/Login.tsx
・・・
+import { authUserByIdAndPassword } from "../../../../infrastructures/api/userApi";
・・・
  const onClickLogin = () => {
    const auth = async () => {
      try {
        const user = await authUserByIdAndPassword(userId, password);
        setAccount({id: user.id, name: user.name});
        navigate("/todo");
      } catch {
        handleDialogSetting(
          'ログイン失敗',
          ['ログインに失敗しました。'],
          'OK',
          '',
          true
        );
        handleToggleDialog();
      }
    }
    auth();    
  };
・・・

対向サーバーが欲しいのでパクリ元5つ目、6つ目より json-server の昔のをインストールする。
はじめ全然うまくいかなくて、みんな同様に悩んでる様子がWEBにあり、それを参考に作成。

  • require 使えないとか
  • json-server が見つからないとか
  • json-server/index.js が見つからないとか
  • helmet が見つからないとか
  • cors エラーとか
~$ npm install json-server@^0.17.3 --save-dev

サーバーを立ち上げるためのファイルも用意する。

testdb/server.js
import jsonServer from "json-server";
import fs from "fs";
import bodyParser from "body-parser";
import cors from "cors";
        
// Then use it before your routes are set up:
const server = jsonServer.create();
server.use(cors());
const router = jsonServer.router("./testdb/db.json");

server.use(bodyParser.urlencoded({ extended: true }));
server.use(bodyParser.json());

const db = JSON.parse(fs.readFileSync("./testdb/db.json", "UTF-8"));

server.post("/auth", (req, resp) => {
  const { id, password } = req.body;
  const target = db.users.find(
    (user) => user.id === id && user.password === password);
    if (target === undefined) {
    resp.status(401).json("Unauthorized");
    return;
  }
  resp.status(200).json( target );
});

// JSON Serverを起動する
server.use(router);
server.listen(33000, () => {
  console.log("JSON Server Start");
});
testdb/db.json
{
    "users": [
        {
        "id": "test",
        "password": "test1",
        "name": "テストユーザー"
        }
    ]
}

別ターミナルで json-server を立ち上げる。

~/vite-project$ node testdb/server.js
JSON Server Start

やっとうまくいった。

共通レイアウト・メニューの作り方をなんとかしたい

別の一覧ページを作ろうとしたら Todo コンポーネントに全て突っ込まれてる作りになっていたので非常に作りにくい。この際共通レイアウトをコンポーネント化するように大工事開始。

パクリ元7つ目から、Layout という共通レイアウトコンポーネントを Todo コンポーネントから切り出して、Route で対象コンポーネントを挟み込むようで <Outlet /> にて置き換えることを理解した(Ruby の yield のイメージ?)。

routes/Router.js
・・・
        <Route path="/" element={<Layout />}>
          <Route path="/todo" element={<Todo />} />
        </Route>
・・・

共通レイアウトにはここで利用するメニューも定義。メニュー内容とサブメニュー開閉状態を Recoil で全体的に保持するように修正。

components/composite/Layout.tsx
import { useEffect, useState } from 'react';

import GlobalStyles from '@mui/material/GlobalStyles';
import { createTheme, ThemeProvider } from '@mui/material/styles';
import { indigo, pink } from '@mui/material/colors';

import { QR } from '../../features/share-application/qr/components/QR';
import { Header } from '../../components/composite/Header';
import { SideBar } from '../../features/sidebar/sidebar-list/components/SideBar';

import { useRecoilState, useResetRecoilState } from "recoil";
import { accountState } from "../../stores/account/accountState";
import { useNavigate, Outlet } from 'react-router-dom';
import { menuItemsState } from "../../stores/menuItem/menuItemsState";
import { operateMenuState } from "../../stores/operateMenu/operateMenuState";

const theme = createTheme({
  palette: {
    primary: {
      main: indigo[500],
      light: '#757de8',
      dark: '#002984',
    },
    secondary: {
      main: pink[500],
      light: '#ff6090',
      dark: '#b0003a',
    },
  },
});

export const Layout = () => {
  const [qrOpen, setQrOpen] = useState(false);
  const [drawerOpen, setDrawerOpen] = useState(false);
  const [menuItems, setMenuItems] = useRecoilState<MenuItem>(menuItemsState)
  const [operateMenu, setOperateMenu] = useRecoilState<OperateMenu>(operateMenuState)
  const resetAccountState = useResetRecoilState(accountState);
  const navigate = useNavigate();

  const handleToggleQR = () => {
    setQrOpen((qrOpen) => !qrOpen);
  };

  const handleToggleDrawer = () => {
    setDrawerOpen((drawerOpen) => !drawerOpen);
  };

  useEffect(() => {
    let items: MenuItem = {};
    items['share'] = { text: 'このアプリを共有', icon: 'share', iconsx: null, onClick: handleToggleQR, subitems: {} };
    items['logout'] = { text: 'ログアウト', icon: 'logout', iconsx: null, onClick: () => { resetAccountState(); navigate("/login");}, subitems: {} };

    setMenuItems((menuItems) => ({...menuItems, share: items['share'], logout: items['logout']}));
  },[]) 

  return (
    <ThemeProvider theme={theme}>
      <GlobalStyles styles={{ body: { margin: 0, padding: 0 } }} />
      <Header filter={'all'} onToggleDrawer={handleToggleDrawer} />
      <SideBar
        drawerOpen={drawerOpen}
        onToggleDrawer={handleToggleDrawer}
        menuItems={menuItems}
        operateMenu={operateMenu}
      />
      <Outlet />
      <QR open={qrOpen} onClose={handleToggleQR} />
    </ThemeProvider>
  );
};
stores/menuItem/menuItemsState.ts
import { atom } from "recoil";

export const menuItemsState = atom({
 key: 'menuItemsState', 
 default: {} as MenuItem,
});
features/sidebar/sidebar-list/types/MenuItem.d.ts
declare type MenuItem = {
    [id: string]:             // メニューのID
    {
        text: string;           // テキスト
        icon: string;           // アイコン名
        iconsx: SxProps<Theme> | undefined; // アイコンのスタイル
        onClick: Function | MouseEventHandler<HTMLDivElement>;  // メニュー押下時の処理
        subitems: MenuItem;   // サブアイテム
    }
};
stores/operateMenu/operateMenuState.ts
import { atom } from "recoil";

export const operateMenuState = atom({
 key: 'operateMenuState', 
 default: {openMenuId: '',  openMenu: false, selectMenuId: ''} as OperateMenu,
});
components/types/OperateMenu.d.ts
declare type OperateMenu = {
    openMenuId: string;          // クリックした子がある親メニューID
    openMenu: boolean;           // クリックした子がある親メニュー開閉状態
    selectMenuId: string;        // 選択しているメニューID(親も子も)
};

渡す変数を変更したり、メニューを ID をキーにしたオブジェクトに変更したので SideBar も大幅に修正。

features/sidebar/sidebar-list/components/SideBar.tsx
import Icon from '@mui/material/Icon';
import List from '@mui/material/List';
import Avatar from '@mui/material/Avatar';
import Drawer from '@mui/material/Drawer';
import Divider from '@mui/material/Divider';
import ListItem from '@mui/material/ListItem';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import ListItemButton from '@mui/material/ListItemButton';

import { styled } from '@mui/material/styles';
import { indigo, lightBlue, pink } from '@mui/material/colors';

import * as pjson from '../../../../../package.json';

import { headerHeight } from '../../../../components/composite/Header';

import Collapse from '@mui/material/Collapse';
import ExpandLess from '@mui/icons-material/ExpandLess';
import ExpandMore from '@mui/icons-material/ExpandMore';

import { useRecoilState } from "recoil";
import { accountState } from "../../../../stores/account/accountState";

type Props = {
  drawerOpen: boolean;
  onToggleDrawer: () => void;
  menuItems: MenuItem;
  operateMenu: OperateMenu;
};

const DrawerList = styled('div')(() => ({
  width: 250,
}));

const DrawerHeader = styled('div')(() => ({
  height: 150,
  display: 'flex',
  flexDirection: 'column',
  justifyContent: 'center',
  alignItems: 'center',
  padding: '1em',
  backgroundColor: indigo[500],
  color: '#ffffff',
  fontFamily: '-apple-system, BlinkMacSystemFont, Roboto, sans-serif',
}));

const DrawerAvatar = styled(Avatar)(({ theme }) => ({
  backgroundColor: pink[500],
  width: theme.spacing(6),
  height: theme.spacing(6),
}));

const drawer = (props: Props) => {
  const [account, setAccount] = useRecoilState(accountState)

  let ret: JSX.Element[] = [];
  Object.keys(props.menuItems).forEach((itemId, idx) => {
    if (idx > 0) {
      ret.push(<Divider />);
    }
    ret.push(
      <ListItemButton selected={itemId === props.operateMenu.selectMenuId} onClick={()=>{(Object.keys(props.menuItems[itemId].subitems).length===0)?props.onToggleDrawer():()=>{};props.menuItems[itemId].onClick()}}>
        <ListItemIcon>
          <Icon sx={props.menuItems[itemId].iconsx}>{props.menuItems[itemId].icon}</Icon>
        </ListItemIcon>
        <ListItemText primary={props.menuItems[itemId].text} />
        {(Object.keys(props.menuItems[itemId].subitems).length === 0) ? null:
          ( props.operateMenu.openMenuId === itemId ?
            (props.operateMenu.openMenu ? <ExpandLess /> : <ExpandMore />) :
          <ExpandMore />)}
      </ListItemButton>
    );

    let sub: JSX.Element[] = [];
    Object.keys(props.menuItems[itemId].subitems).forEach((subItemId: string, subIdx) => {
      sub.push(
        <ListItem disablePadding onClick={props.onToggleDrawer}>
          <ListItemButton
            aria-label={subItemId}
            selected={subItemId === props.operateMenu.selectMenuId}
            onClick={props.menuItems[itemId].subitems[subItemId].onClick}
          >
            <ListItemIcon>
              <Icon sx={props.menuItems[itemId].subitems[subItemId].iconsx}>{props.menuItems[itemId].subitems[subItemId].icon}</Icon>
            </ListItemIcon>
            <ListItemText secondary={props.menuItems[itemId].subitems[subItemId].text} />
          </ListItemButton>
        </ListItem>
      );
    });
    ret.push(
      <Collapse in={props.operateMenu.openMenuId === itemId && props.operateMenu.openMenu} timeout="auto" unmountOnExit>
        <List sx={{paddingLeft: 2}}>
          {sub}
        </List>
      </Collapse>
    );
  });

  return (
  <>
    <DrawerList role="presentation">
      <DrawerHeader>
        <DrawerAvatar>
          <Icon>create</Icon>
        </DrawerAvatar>
        <p>TODO v{pjson.version}</p>
        <p>{account.name}様</p>
      </DrawerHeader>
        {ret}
    </DrawerList>
  </>)
};

export const SideBar = (props: Props) => (
  <>
  <Drawer
    variant="temporary"
    open={props.drawerOpen}
    onClose={props.onToggleDrawer}
    ModalProps={{
      keepMounted: true,
    }}
    sx={{
      display: { xs: 'block', sm: 'none' }, // 変更:xsまで(スマホ)はハンバーガーメニューアイコンクリックからの一時表示
      '& .MuiDrawer-paper': { 
        boxSizing: 'border-box',
        top: headerHeight,
        height: `calc(100% - ${headerHeight})`,
      },
    }}
  >
    {drawer(props)}
  </Drawer>
  <Drawer
    variant="permanent"
    PaperProps={{ elevation: 4 }}
    sx={{
      display: { xs: 'none', sm: 'block' }, // 変更:smから(PC等)は常時表示
      '& .MuiDrawer-paper': {
        boxSizing: 'border-box',
        top: headerHeight,
        height: `calc(100% - ${headerHeight})`,
      },
    }}
  >
    {drawer(props)}
  </Drawer>
  </>
);

Todo では Layout に抜き出した部分を削除し、ここで使うメニュー用のロジックを入れたりこちらも大工事。

features/todo/todo-list/components/Todo.tsx
import { FC, useEffect, useState } from 'react';
import * as localforage from 'localforage';

import GlobalStyles from '@mui/material/GlobalStyles';
import { createTheme, ThemeProvider } from '@mui/material/styles';
import { indigo, pink } from '@mui/material/colors';

import { TodoItem } from './TodoItem';
import { FormDialog } from './FormDialog';
import { AlertDialog } from './AlertDialog';
import { ActionButton } from './ActionButton';

import { isTodos } from '../../_shared/logics/isTodos';
import { useRecoilState } from "recoil";
import { menuItemsState } from "../../../../stores/menuItem/menuItemsState";
import { lightBlue } from '@mui/material/colors';
import { operateMenuState } from "../../../../stores/operateMenu/operateMenuState";

const theme = createTheme({
  palette: {
    primary: {
      main: indigo[500],
      light: '#757de8',
      dark: '#002984',
    },
    secondary: {
      main: pink[500],
      light: '#ff6090',
      dark: '#b0003a',
    },
  },
});

export const Todo = () => {
  const [text, setText] = useState('');
  const [todos, setTodos] = useState<Todo[]>([]);
  const [filter, setFilter] = useState<Filter>('all');

  const [alertOpen, setAlertOpen] = useState(false);
  const [dialogOpen, setDialogOpen] = useState(false);

  const [operateMenu, setOperateMenu] = useRecoilState<OperateMenu>(operateMenuState)
  
  const handleToggleDialog = () => {
    setDialogOpen((dialogOpen) => !dialogOpen);
    setText('');
  };

  const handleToggleAlert = () => {
    setAlertOpen((alertOpen) => !alertOpen);
  };

  const handleChange = (
    e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
  ) => {
    setText(e.target.value);
  };

  const handleSubmit = () => {
    if (!text) {
      setDialogOpen((dialogOpen) => !dialogOpen);
      return;
    }

    const newTodo: Todo = {
      value: text,
      id: new Date().getTime(),
      checked: false,
      removed: false,
    };

    setTodos((todos) => [newTodo, ...todos]);
    setText('');
    setDialogOpen((dialogOpen) => !dialogOpen);
  };

  const handleTodo = <K extends keyof Todo, V extends Todo[K]>(
    id: number,
    key: K,
    value: V,
  ) => {
    setTodos((todos) => {
      const newTodos = todos.map((todo) => {
        if (todo.id === id) {
          return { ...todo, [key]: value };
        } else {
          return todo;
        }
      });

      return newTodos;
    });
  };

  const handleEmpty = () => {
    setTodos((todos) => todos.filter((todo) => !todo.removed));
  };

  useEffect(() => {
    localforage
      .getItem('todo-20200101')
      .then((values) => isTodos(values) && setTodos(values));
  }, []);

  useEffect(() => {
    localforage.setItem('todo-20200101', todos);
  }, [todos]);

  const [menuItems, setMenuItems] = useRecoilState<MenuItem>(menuItemsState)

  const handleSort = (filter: Filter, selectMenuId: string) => {
    setOperateMenu((operateMenu) => ({...operateMenu, selectMenuId: selectMenuId}));
    setFilter(filter);
  };
  const handleToggleMenu = (openMenuId: string) => {
    setOperateMenu((operateMenu) => ({...operateMenu, openMenuId: openMenuId, openMenu: !operateMenu.openMenu}));
  };

  useEffect(() => {
    let subitems: MenuItem = {};
    subitems['list-all'] = { text: 'すべてのタスク', icon: 'subject', iconsx: null, onClick: () => handleSort('all', 'list-all'), subitems: {} };
    subitems['list-unchecked'] = { text: '現在のタスク', icon: 'radio_button_unchecked', iconsx: { color: lightBlue[500] }, onClick: () => handleSort('unchecked', 'list-unchecked'), subitems: {} };
    subitems['list-checked'] = { text: '完了したタスク', icon: 'check_circle_outline', iconsx: { color: pink.A200 }, onClick: () => handleSort('checked', 'list-checked'), subitems: {} };
    subitems['list-removed'] = { text: 'ごみ箱', icon: 'delete', iconsx: null, onClick: () => handleSort('removed', 'list-removed'), subitems: {} };
    
    let items: MenuItem = {};
    items['task'] = { text: 'タスク', icon: 'task', iconsx: null, onClick:  () => handleToggleMenu('task'), subitems: subitems };

    setMenuItems((menuItems) => ({task: items['task'], ...menuItems}));
  }, [todos]);

  return (
    <>
      <FormDialog
        text={text}
        dialogOpen={dialogOpen}
        onChange={handleChange}
        onSubmit={handleSubmit}
        onToggleDialog={handleToggleDialog}
      />
      <AlertDialog
        alertOpen={alertOpen}
        onEmpty={handleEmpty}
        onToggleAlert={handleToggleAlert}
      />
      <TodoItem todos={todos} filter={filter} onTodo={handleTodo} />
      <ActionButton
        todos={todos}
        filter={filter}
        alertOpen={alertOpen}
        dialogOpen={dialogOpen}
        onToggleAlert={handleToggleAlert}
        onToggleDialog={handleToggleDialog}
      />
    </>
  );
};

ダイアログ切り離し

上記レイアウト整理に入っていなかったメッセージダイアログが Login にくっついている箇所の修正。型を作成し、どこからでも読み込めるように atom ファイル作成。

components/types/MessageDialog.d.ts
declare type MessageDialog = {
    dialogOpen: boolean;        // 開閉
    dialogTitle: string;        // タイトル
    dialogMessages: string[];   // メッセージ
    okCaption: string;          // OK時のボタンタイトル
    cancelCaption: string;      // CANCEL時のボタンタイトル
    isOkAutoFocus: boolean;     // true:OKボタンにフォーカス
    onOkFunc: () => void;       // OK時処理
    onCancelFunc: () => void;   // CANCEL時処理
};
stores/messageDialog/messageDialogState.ts
import { atom } from "recoil";

export const messageDialogState = atom({
 key: 'messageDialogState', 
 default: {
    dialogOpen: false,
    dialogTitle: '',
    dialogMessages: [],
    okCaption:  '',
    cancelCaption:  '',
    isOkAutoFocus: true,
    onOkFunc: () => {},
    onCancelFunc: () => {},
 } as MessageDialog,
});

共通レイアウトに入れて、Todo で利用している AlertDialog を削除して一本化する、と思ったが Login は共通レイアウト使ってなかった!もう一枚の共通レイアウトのごとく Router にぶち込む。

route/Router.tsx
・・・
import { MessageDialog } from "../components/composite/MessageDialog";
import { messageDialogState } from "../stores/messageDialog/messageDialogState";
・・・
    <Routes>
      <Route element={<MessageDialog messageDialog={messageDialog} />}>
        <Route path="/" element={<Login />} />
        <Route path="/login" element={<Login />} />
        <Route element={<PrivateRoutes />}>
          <Route path="/" element={<Layout />}>
            <Route path="/todo" element={<Todo />} />
          </Route>
        </Route>
        <Route path="*" element={<ErrorPage404 />} />
      </Route>
    </Routes>
・・・

MessageDialog も引数やら <Outlet /> やら変更。

components/composite/MessageDialog.tsx
import Button from '@mui/material/Button';
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@mui/material/DialogContentText';

import { styled } from '@mui/material/styles';
import { Outlet } from "react-router-dom";

type Props = {
  messageDialog: MessageDialog;
};

const Alert = styled(Dialog)(() => ({
  fontFamily: '-apple-system, BlinkMacSystemFont, Roboto, sans-serif',
}));

export const MessageDialog = (props: Props) => {
  let dialogMessages: JSX.Element[] = [];
  props.messageDialog.dialogMessages.forEach((message, idx) => {
    dialogMessages.push(<DialogContentText>{message}</DialogContentText>);
  });

  return (
    <>
      <Alert open={props.messageDialog.dialogOpen} onClose={() => ({})}>
        <DialogTitle>{props.messageDialog.dialogTitle}</DialogTitle>
        <DialogContent>
          {dialogMessages}
        </DialogContent>
        <DialogActions>
          {props.messageDialog.cancelCaption !== '' && <Button
            aria-label="alert-cancel"
            onClick={() => {
              props.messageDialog.onCancelFunc();
            }}
            color="primary"
            autoFocus={!props.messageDialog.isOkAutoFocus}
          >
            {props.messageDialog.cancelCaption}
          </Button>}
          <Button
            aria-label="alert-ok"
            onClick={() => {
              props.messageDialog.onOkFunc();
            }}
            color="secondary"
            autoFocus={props.messageDialog.isOkAutoFocus}
          >
            {props.messageDialog.okCaption}
          </Button>
        </DialogActions>
      </Alert>
      <Outlet />
    </>
  )
};

ログイン失敗時のメッセージ表示部分を修正する。MessageDialog 部分を一旦全部削除し、新たな呼び出しを記述する。

features/login/login-form/components/Login.tsx
・・・
import { messageDialogState } from "../../../../stores/messageDialog/messageDialogState";
・・・
  const [messageDialog, setMessageDialog] = useRecoilState<MessageDialog>(messageDialogState);
・・・
      } catch {
        setMessageDialog({
          dialogOpen: true,
          dialogTitle: 'ログイン失敗',
          dialogMessages: ['ログインに失敗しました。'],
          okCaption: 'OK',
          cancelCaption: '',
          isOkAutoFocus: true,
          onOkFunc: () => {setMessageDialog({...messageDialog, dialogOpen: false})},
          onCancelFunc: () => {setMessageDialog({...messageDialog, dialogOpen: false})},
        });
      }
・・・

Todo 削除時のメッセージ部分(AlertDialog部分)を書き換える。

features/todo/todo-list/components/Todo.tsx
・・・
-import { AlertDialog } from './AlertDialog';"../../../../stores/messageDialog/messageDialogState";
・・・
-  const [alertOpen, setAlertOpen] = useState(false);
・・・
-  const handleToggleAlert = () => {
-    setAlertOpen((alertOpen) => !alertOpen);
-  };
・・・
-      <AlertDialog
-        alertOpen={alertOpen}
-        onEmpty={handleEmpty}
-        onToggleAlert={handleToggleAlert}
-      />
・・・
       <ActionButton
         todos={todos}
         filter={filter}
         dialogOpen={dialogOpen}
         onEmpty={handleEmpty}
         onToggleDialog={handleToggleDialog}
       />
・・・
features/todo/todo-list/components/ActionButton.tsx
・・・
import { useRecoilState } from "recoil";
import { messageDialogState } from "../../../../stores/messageDialog/messageDialogState";
・・・
 type Props = {
   todos: Todo[];
   filter: Filter;
   dialogOpen: boolean;
   onEmpty: () => void;
   onToggleDialog: () => void;
 };
・・・
   const [messageDialog, setMessageDialog] = useRecoilState<MessageDialog>(messageDialogState);
・・・
      {props.filter === 'removed' ? (
        <FabButton
          aria-label="fab-delete-button"
          color="secondary"
          onClick={() =>
            setMessageDialog({
              dialogOpen: true,
              dialogTitle: 'ログイン失敗',
              dialogMessages: ['本当にごみ箱を完全に空にしますか?', 'この操作は取り消しできません。'],
              okCaption: 'OK',
              cancelCaption: 'キャンセル',
              isOkAutoFocus: true,
              onOkFunc: () => { setMessageDialog({...messageDialog, dialogOpen: false});props.onEmpty(); },
              onCancelFunc: () => { setMessageDialog({...messageDialog, dialogOpen: false}) },
            })
          }
          disabled={!removed}
        >
          <Icon>delete</Icon>
        </FabButton>
・・・

雑感

結局根本的なところが分かっていなかったりして state の変更が反映しないとか、他のコンポーネントでどう更新させるかとかいろいろ躓いてしまった。まだまだ理解が浅い。