36
25

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

【React】もっとデカルトみを感じたい ―― イベントの処理を親子で適切に分担する

Last updated at Posted at 2023-02-20

上記の記事(ここでは前記事と呼びます)では、 Todo リストを例にして、コンポーネント分割の考え方を述べましたが……

  • 「作成」ボタン
    • 押下時のアクション
      • タスクを新規作成。
      • title は「件名」の入力の内容とする。
      • 「件名」入力欄のリセットは行わない。 👈

ここで

タスクを作成したときにはふつう「件名」入力欄をリセットするでしょ?
どう実装したら良いの?

という疑問が出てきたはずです。

これを題材にして、イベントが起きた時の処理を親と子でどのように分けたら良いのか考えてみます。

リセットする責務だけを子に分担させる

親から渡されたイベントハンドラを実行したあとに、 setTitle を使ってステートをリセットします。

import { useCallback, useState } from "react";
import { Button, Grid, Input, Paper, Text, Title } from "@mantine/core";

type Props = {
  onCreateTodoItem: (newItem: { title: string }) => void;
};

const TodoCreateArea: React.FC<Props> = ({ onCreateTodoItem }) => {
  const [title, setTitle] = useState<string>("");

  const handleClickCreate = useCallback(() => {
    onCreateTodoItem({ title: title });
+   setTitle("")
  }, [onCreateTodoItem, title]);

  return (
    <Paper shadow="md" p="sm">
      <Title order={2}>タスクの新規作成</Title>
      <Grid>
        <Grid.Col span="content" style={{ alignSelf: "center" }}>
          <Text>件名: </Text>
        </Grid.Col>
        <Grid.Col span="auto">
          <Input
            name="newItemTitle"
            value={title}
            onChange={(e) => setTitle(e.target.value)}
          />
        </Grid.Col>
        <Grid.Col span="content">
          <Button onClick={handleClickCreate}>作成</Button>
        </Grid.Col>
      </Grid>
    </Paper>
  );
};

export default TodoCreateArea;

「なーんだ、簡単じゃーん」と思うかもしれませんが、それは私が前記事の説明を簡単にするため敢えて「作成時にはリセットしない」という仕様からスタートしていたからです。前記事の時点ですでに関心分離をじゅうぶん意識したコードだった訳です。

親から実装を注入する部分とローカルな部分を分ける

  • Todo リストに項目追加する
  • TodoCreateArea の「件名」入力欄をリセットする

「作成ボタンを押下したときに起きる変更」と渾然一体に考えるのではなく、上記のように複数の関心事が組み合わさっていると「分かる」ことが大切です。

前者はグローバルな状態の操作(あるいは POST リクエストを投げる)なので Prop として親コンポーネントから実装を注入してもらっていますが、後者は該当コンポーネントのローカルな状態の操作なので、素朴に書けます。

コンポーネントの内と外の関係を表した図

このように線引きすると秩序が生まれます。依存関係がこちゃごちゃでイベントハンドラ関数がピタゴラ装置みたいに連鎖的に呼び出されるようなケースでも、可読性の悪さがいくらかマシになると思います。

目的駆動な命名によって主作用と副次的な作用を分ける

じつは、前記事の時点で onClickCreate ではなく onCreateTodoItem という Prop 名にしたのも、関心を分離するために意図的にしたことです。

下のようなコードを逐語的に読むだけで、「作成ボタン」には 「Todo タスクを作成する」という主作用 だけでなく、「titleステートをリセットする」という(ボタン名に現れない)副次的な作用 があることが理解できます。

物事を分離することと分かりやすい名前を付けることは表裏一体です。コード細かく分割しすぎると迷子になるかと思かもしれませんが、識別しやすい名前が付いていれば、詳細を見なくても全体像を大まかにつかむことが出来ます。

const handleClickCreate = useCallback(() => {
  onCreateTodoItem({ title: title });
  setTitle("")
}, [onCreateTodoItem, title]);

副次的な作用には、ほかにも以下のようなものが考えられます。

  • トースト・スナックバーを表示する
  • 用の済んだダイアログを閉じる
  • Google Analitics のイベントを飛ばす

関数を受け取る関数、高階関数

この考え方を更に徹底しようとすると、いつかは高階関数(関数を受け取る関数)の考え方でカスタムフックやコンポーネントを設計する必要にぶち当たることがあります。はっきり言って慣れが必要ですが、私の過去の記事から一例を挙げておきます。

上記の記事では、state として関数を保持していて、しかも Promise を使っているので必要以上に難解かもしれませんが、

以下のカスタムフックを利用している親コンポーネントの記述の抜粋を見れば、 useDeletePostConfirmDialog が具体的な削除処理に依存しておらず、親コンポーネント側でそれらを組み合わせていることが分かります。

// ダイアログの開閉を管理するためのカスタムフック
const { renderDeleteDialog, confirmDelete } = useDeletePostConfirmDialog();
// 削除処理を呼び出すためのカスタムフック
const { deletePost } = useDeletePost();

const handleDeletePost = useCallback(async () => {
  // ダイアログを開く。
  // ダイアログが閉じられたときに Promise が resolve されて結果が返ってくる。
  const { accepted } = await confirmDelete({ id, title });

  // キャンセル時には accepeted が false として返ってくる。
  // その場合は早期リターンする
  if (!accepted) return;

  // 実際の削除処理を呼び出す
  await deletePost({ id });
}, [confirmDelete, deletePost, id]);

Promise を使うなんて気持ち悪い!って方の場合は

confirmDelete({
  onClose: ({ accepted }) => {
    if (!accepted) return;

    deletePost({ id });
  }
})

みたいにすれば良いですかね...?

依存の方向を整える

コードの依存関係を常に「ライフサイクルを利用したコード→利用しない純粋な処理」となるようにするのもコードを整理するのに重要です。

これだけで大長編になりそうなのでいずれ記事にします。

🚫 DON'T: 取得する関数の詳細 / 取得した値をセットする処理をまとめる

// get って名前なのに副次的に set しちゃったら責務が明確にならない!
const getHogehogeData = () => {
  fetch("path/to/hogehoge/" + hogeId, /* ... */)
    .then(async (res) => {
      if(!res.ok) return;

      setHogehoge(await data.json())
    }) 
}

const handleClickHogeButton = useCallback(() => {
  getHogehogeData();
}, [getHogehogeData]);

✅ DO: 取得する関数の詳細 / 取得した値をセットする処理で分離

const repository = useRepository();

const handleClickHogeButton = useCallback(() => {
  repository
    .fetchHogehogeData(hogeId)
    .then((data) => {
      setHogehoge(data);
    });
}, [repository, hogeId]);

// useRepository.ts (詳細は省略)
export const useRepository = () => {
  const fetchHogehogeData = useCallback(async (hogeId: number) => {
    return await fetch("path/to/hogehoge/" + hogeId, /* ... */)
      .then(/* ... */)
  }, [/* .. */])
// ...

しかも Promise を返してくるだけの関数であれば、各種ライブラリとの相性もいいんですよ!!!

const { data } = useSWR(
  ["hogehoge", hogeId], 
  ([, hogeId]) => repository.setHogehoge(hogeId),
);

おわりに

コンポーネントを分割するだけでなく、今回はイベントハンドラの分割について考え方を書き出してみました。

イベント発生時の処理は得てして複雑なピタゴラ装置になるものですが、関心事によって切り分けて然るべきコンポーネント・フックに分担させると、少しでもマシになるのではないかと思います。

思いつくのは少しばかり大変ですが、「関数を受け取る関数・フック」を駆使することによっても、複雑な依存関係を解消できます。

補足

ちなみに、件名以外にも色々な項目が追加されて、それらを全てリセットしないといけない、というケースだと、 key を使ったほうが良いです。

36
25
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
36
25

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?