🎐

render hook と Promise を使ってダイアログを含むフローの記述を疎結合に実現する

2022/09/20に公開

はじめに

やめ太郎さんによる記事

https://qiita.com/Yametaro/items/b6e035fe06530a9f47bc

の末尾で紹介されている、「render hook と Promise を組み合わせた方法」について、簡潔に解説し、そのメリットも併せて述べます。

2番煎じですが、そのネタの提供者は僕なので #@!*... (循環参照エラー)

Promise を使うことのメリット

Promise を使うことによって、確認ダイアログの「確認ボタン」「キャンセルボタン」を押下したときの操作が、ダイアログ側ではなく、ページ側(ダイアログ呼び出し側) に 記述できるようになります。そのおかげで、深くなりがちな関数呼び出しの階層を浅くして、コードを追いやすくなります。

▼ こんな風に、簡潔でありながら、 階層が浅く、「モーダル操作と種々の処理」の記述が一つの階層にまとめて記述できています。

features/posts/detail/PostDetailPageContent.tsx
  const { renderDeleteDialog, confirmDelete } = useDeletePostConfirmDialog();
  const { deletePost } = useDeletePost();

  const handleDeletePost = useCallback(async () => {
    // 削除の確認ダイアログを開く。
    // acceptedは、 肯定的ボタン押下時に true, 
    //   キャンセルボタン or ダイアログの外を押して閉じたときに false
    const { accepted } = await confirmDelete({ id, title });

    if (!accepted) return; // キャンセル時は処理に進まない

    await deletePost({ id });
    // 削除処理を実行する

    // あとは、一覧ページに遷移するなり何なり
  }, [confirmDelete, deletePost, id]);

  return (
    <>
      {renderDeleteDialog()}

render hook のメリット

https://engineering.linecorp.com/ja/blog/line-securities-frontend-3/

uhyo さんを通して有名になった実装パターンですが、render hook を使うことによって、モーダルの状態と、その状態に基づいた表示切替を一つのフックの中に閉じ込めることができるのがメリットになります。

Container コンポーネント もその条件を満たしますが、「ダイアログを開く関数を返す」ということが出来ず、その差が render hooks の利点です。

余談:目的駆動パッケージングしよう

関心を複数のフック・コンポーネントにキチンと分離することは、テスト容易性や、ファイルの読みやすさ、バージョン管理の利便性などの為に必須ですが、

コードを読むときに迷子にならないためには、そういった関連ファイルを

  • 一つのディレクトリにそれらをまとめる
  • 関係ないファイルと混ぜない

ことが大事です。

なので、 hooks/ components/ という分け方をするのではなく、 /features/posts/detail/(delete) といったディレクトリにまとめて配置する 目的駆動パッケージング を使いましょう。

https://qiita.com/honey32/items/dbf3c5a5a71636374567

余談:「深く知りすぎた」命名を避ける

ユーザーが「削除ボタン」を押すとき、その目的は「削除"ダイアログを開く"」ことではなく、 単に 「削除すること」 です。

特に、 onClick に渡す関数のように、ユーザーに近くて内部実装から遠いコードにおいては、表面的に見て不必要な情報が関数名に含まれていると、このように段階を踏んだ処理の記述の可読性が下がるような気がするため、そのような命名は避けるように心がけています。

  • openDeleteDialog ではなく、 confirmDelete
    • これは「内部」に近いので賛否ありそう
  • 削除ボタンに渡す関数名は、 handleDeletePost
    • Dialog, Modal のような言葉は含めない

コードを見てみる

実装を簡便にするため、

  • next (Next.js)
    • 12.2.5
  • @mui/material (MUI / Material UI)
    • 5.10.2

を利用しています。

ディレクトリ階層

  • features/posts/detail/ ... 投稿 / 詳細画面
    • PostDetailPageContent.tsx
    • delete/ ...削除機能に関わるファイルをまとめる
      • DeletePostConfirmDialog.tsx
      • useDeletePost.ts
      • useDeletePostConfirmDialog.tsx

PostDetailPageContent — 各機能を接続して、コンテンツを表示する

コンテンツの表示部分のみを Presentational Component として切り出せば、storybook で表示の確認が可能です。

先述の通り、削除機能 (useDeletePost) と、削除確認ダイアログ (useDeletePostConfirmDialog)それぞれがこのコンポーネントから利用されることで、互いに疎な結合となり、呼び出しの階層も浅くなっています。

ソースコード(長いので折りたたんでいます)
features/posts/detail/PostDetailPageContent.tsx
import { useRouter } from "next/router";
import { useCallback } from "react";

import {
  Button,
  Container,
  Divider,
  Paper,
  Stack,
  Typography,
} from "@mui/material";

import useDeletePost from "./delete/useDeletePost";
import useDeletePostConfirmDialog from "./delete/useDeletePostConfirmDialog";

const PostDetailpageContent: React.FC = () => {
  const router = useRouter();
  const id = router.query.postId as string;
  const title = "React の全てがこれで分かる!";

  const { renderDeleteDialog, confirmDelete } = useDeletePostConfirmDialog();
  const { deletePost } = useDeletePost();

  const handleDeletePost = useCallback(async () => {
    const { accepted } = await confirmDelete({ id, title });

    if (!accepted) return; // キャンセル時は処理に進まない

    await deletePost({ id });
  }, [confirmDelete, deletePost, id]);

  return (
    <>
      {renderDeleteDialog()}

      {/* Container ... コンテンツの横幅を制限 */}
      <Container>
        <Paper sx={{ mt: 4, p: 4 }}>
          <Typography variant="h1">{title}</Typography>

          <Stack my={1} direction="row">
            <Button variant="contained" onClick={handleDeletePost}>
              削除
            </Button>
          </Stack>

          <Divider sx={{ my: 4 }} />

          <Typography variant="h2" gutterBottom>
            はじめに
          </Typography>
          <Typography gutterBottom>
            ここから、React のめちゃめちゃ分かりやすい説明が続きます。
          </Typography>
        </Paper>
      </Container>
    </>
  );
};

export default PostDetailpageContent;

useDeletePost — 削除処理本体

今回は、ダミーとして アラートを表示するだけになっています。

features/posts/detail/delete/useDeletePost.ts
import { useCallback } from "react";

const useDeletePost = () => {
  const deletePost: (options: { id: string }) => Promise<void> = useCallback(
    async ({ id }) => {
      // TODO: 実際の削除処理を挟む
      setTimeout(() => {
        window.alert("(開発用メッセージ)削除処理!");
      }, 100);
    },
    []
  );

  return { deletePost };
};

export default useDeletePost;

useDeletePostConfirmDialog — モーダルの状態管理の要

ここが最も重要なファイルになります。

  • Presentational なダイアログ本体と
  • それを開いてボタン押下を待つ async な関数

をつなぐのが、このフックの役目になります。

ソースコード(長いので折りたたんでいます)
features/posts/detail/delete/useDeletePostConfirmDialog.tsx
import { ReactNode, useCallback, useState } from "react";

import DeletePostConfirmDialog, {
  DeletePostConfirmDialogProps,
} from "./DeletePostConfirmDialog";

type State = {
  id: string;
  title: string;
  onClose: DeletePostConfirmDialogProps["onClose"];
};

type OpenModalResult = Parameters<State["onClose"]>[0];

type ReturnValues = {
  confirmDelete: (props: Omit<State, "onClose">) => Promise<OpenModalResult>;
  renderDeleteDialog: () => ReactNode;
};

const useDeletePostConfirmDialog = (): ReturnValues => {
  const [state, setState] = useState<State | undefined>(undefined);

  const confirmDelete: ReturnValues["confirmDelete"] = useCallback(
    (props) =>
      new Promise((resolve) => {
        setState({ ...props, onClose: resolve });
      }),
    []
  );

  const handleClose: State["onClose"] = useCallback(
    (options) => {
      state?.onClose(options);
      setState(undefined);
    },
    [state]
  );

  const renderDeleteDialog: ReturnValues["renderDeleteDialog"] = () => {
    return (
      <DeletePostConfirmDialog
        open={!!state}
        title={state?.title ?? ""}
        onClose={handleClose}
      />
    );
  };

  return {
    confirmDelete,
    renderDeleteDialog,
  };
};

export default useDeletePostConfirmDialog;

state

まずは、state に着目してみましょう。
ダイアログは state の値が undefined である時は閉じていて、そうでない(State型のオブジェクト)のときには開いています。

注目すべきは、 State 型の onClose プロパティです。「閉じたときに呼ばれる関数」 そのものを、状態として保持しているところがミソになります。

type State = {
  id: string;
  title: string;
  onClose: DeletePostConfirmDialogProps["onClose"];
};

// 21行目
const [state, setState] = useState<State | undefined>(undefined);

// 41行目
  <DeletePostConfirmDialog
    open={!!state}

Promise とのつなぎ込み

この「閉じたときに呼ばれる関数」と、「ダイアログを開いて、閉じるまで待つ非同期関数」を繋いでいるのが、以下のコードになります。

// 23行目
const confirmDelete: ReturnValues["confirmDelete"] = useCallback(
  (props) =>
    new Promise((resolve) => {
      setState({ ...props, onClose: resolve });
    }),
  []
);

これは、任意のタイミングで解決(完了)するPromiseを作るための一般的な方法になります。

このおかげで、呼び出し側から見ると、

await confirmDelete({ id, title }) を呼び出すと、ダイアログが閉じたときに完了し、 onClose の返り値がそのまま結果として返ってくる

という挙動が実現できます。

DeletePostConfirmDialog — ダイアログ本体

この記述は render hook の中にも書けますが、あえて Presentational Component として切り出すことによって、 storybook など単体で表示を確認できる ようになっています。

  • 「削除する」(肯定的)ボタン押下時には {accepted: true}
  • 「削除しない」(キャンセル)押下時 or ダイアログ外クリック時には {accepted: false}

を それぞれ onClick コールバックに渡すようになっています。

ソースコード(長いので折りたたんでいます)
features/posts/detail/delete/DeletePostConfirmDialog.tsx
import {
  Button,
  Dialog,
  DialogActions,
  DialogContent,
  DialogContentText,
  DialogTitle,
} from "@mui/material";
import { useCallback } from "react";

export type DeletePostConfirmDialogProps = {
  open: boolean;
  title: string;
  onClose: (options: { accepted: boolean }) => void;
};

const DeletePostConfirmDialog: React.FC<DeletePostConfirmDialogProps> = ({
  open,
  title,
  onClose,
}) => {
  const handleCancel = useCallback(() => {
    onClose({ accepted: false });
  }, [onClose]);

  const handleAccept = useCallback(() => {
    onClose({ accepted: true });
  }, [onClose]);

  return (
    <Dialog open={open} fullWidth maxWidth="xs" onClose={handleCancel}>
      <DialogTitle>
        記事の削除: <br />{title}</DialogTitle>

      <DialogContent>
        <DialogContentText>
          記事を削除すると、復元することが出来ません。
        </DialogContentText>
        <DialogContentText>本当に削除しますか?</DialogContentText>
      </DialogContent>

      <DialogActions>
        <Button color="inherit" onClick={handleCancel}>
          削除しない
        </Button>
        <Button variant="contained" color="error" onClick={handleAccept}>
          削除する
        </Button>
      </DialogActions>
    </Dialog>
  );
};

export default DeletePostConfirmDialog;

あとがき

みなさんも、Promise を有効に使って、良い疎結合ライフを!

株式会社ゆめみ

Discussion