225
208

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【React】「困難は分割せよ」―― 複雑な画面は小さな機能に分けて実装しよう。

Last updated at Posted at 2023-01-31

「Divide and Conquer / 分割統治法」 という解法(アルゴリズムの話でよく出てきますね)は、「困難は分割せよ」 として知られる、デカルトが『方法序説』で提唱した思考法が元になっています1

第二は、わたしが検討する難問の一つ一つを、できるだけ多くの、しかも問題をよりよく解くために必要なだけの小部分に分割すること。
―― 岩波文庫 『方法序説』 デカルト著 谷川多佳子訳

同様に、React で多機能で複雑な画面を作りたい時、それを小さな機能の集まりと捉えて、それぞれをコンポーネントにすることで、開発がラクになることがあります。

画面の状態とアクションの図

フロントエンドの複雑さや、デザインのための Atomic Design という考え方の影響、または /scripts のように分ける習慣の名残なのか、フロントエンド開発者には、《再利用のためにコンポーネントを作る》という思い込みがあります。(もしくは、かつてありました。)

それが間違いというわけではありませんが、再利用だけに留まるのは勿体ないです。

「再利用のため」だけでなく「パーツに分けて考える」ためにコンポーネントを分割する》という考え方が、React 公式ドキュメントでも推奨されています。

ほとんどの React アプリでは隅から隅までコンポーネントが使われます。つまり、ボタンのような再利用可能なところでのみ使うのではなく、サイドバーやリスト、最終的にはページ本体といった大きなパーツのためにも使うのです。コンポーネントは、1 回しか使わないような UI コードやマークアップであっても、それらを整理するための有用な手段です。

https://ja.react.dev/learn/your-first-component#components-all-the-way-down
React 公式ドキュメント「初めてのコンポーネント > 隅から隅までコンポーネント」


このような方法を取ると嬉しい副産物もあります。

たとえば、 newItemTitletitle のように名前を短縮できるようになって、コードを読解するときの認知負荷が低くなります。 (直接 export するモノではそうはいかない)

しかも、再レンダリングが頻繁に起こる(入力欄の onChange )範囲を子コンポーネント内に限定できるので、結果として再描画を大きく減らすことにも繋がります。

それでは、「複雑な画面」のいちばん簡単な例を題材にして、コード分割の際の考え方を垂れ流そうと思います。

画面の大まかな仕様

以下のような仕様のページを作るとします。

▼ 完成イメージ ▼
完成イメージ

  • 「タスクの新規作成」
    • 「件名」入力欄
    • 「作成」ボタン
      • 押下時のアクション
        • タスクを新規作成。
        • title は「件名」の入力の内容とする。
        • 「件名」入力欄のリセットは行わない。
  • 「全て完了」ボタン
    • 全てのタスクの status を "done" に変更
  • タスク一覧の各アイテムについて
    • 「完了」ボタン / 「DONE」 ボタン
      • 「完了」ボタン
        • status が "todo" ならこちらを表示
        • 押下時のアクション
          • status を "done" に変更
      • 「DONE」ボタン(非活性)
        • status が "done" ならこちらを表示
    • タスク名
      • title を表示

実際に分けてみる

まず、この画面が保持するべき状態をまとめると以下のようになります。

  • タスクの一覧
    • できるアクション :
      • タスクを追加する
      • 1件のタスクを指定して、status を "done" に変更する
      • タスク全件の status を "done" に変更する
    • 入力欄の状態と比べて永続的
  • 「件名」入力欄の状態 (タスク新規作成)
    • できるアクション :
      • 入力欄への入力をステートに反映する
    • 一時的に利用される
    • 頻繁に更新される

「タスク新規作成」の箇所は、新しいタスクを作るための一時的な状態を保持する場所です。しかも、(Controlled Componentの場合、)頻繁に再レンダリングを引き起こします。

それ以外のページ内容は、タスクを新規作成するための入力欄の状態をいっさい参照していません。

以上のことから、この「タスク新規作成」部分は、真っ先にコンポーネントとして分割されるべき部品だと考えます。

―― 私が脳内でフワッと考えていることを言葉に起こしたので、長ったらしくて理解しづらいですね。簡単な図式で下に示します。このような図を起こすと思考が整理されるので良いと思います。

画面の状態とアクションの図

  • #から始まるのが各コンポーネントのステートです
    • 便宜上、カスタムフックで管理しているものを含んでいます。
  • 実線の矢印は、 アクション がステート更新を引き起こす向きです
    • 「アクションの発生元 → ステートが更新されるコンポーネント」
    • コールバック Prop を渡す向きと逆です
    • 「子 → 親」 または 「コンポーネント内で完結」
  • 親が再レンダーされるたびに、子も再レンダーされます
    • React.memo() で防止できます
    • 親の再レンダー時ごとに 値が Prop 経由で 「親→子」 の向きに渡されるのを、ここでは 「値の伝播」 と呼んでいます

分割したコードの概要

ここではファイル・コンポーネントの概要のみを並べます。コード本文はこの記事の末尾に載せています。

コンポーネント名は "Todo~" と、feature の名前から始まるようにしています。共通のUIパーツではないからです。(TodoPage コンポーネントも同じ feature ディレクトリに入れて、 eslint-plugin-import-access を活用してパッケージプライベート化する、という手もあります。)

ファイルを置くディレクトリも components/ などではなく、目的駆動で分けられたディレクトリに入れています。

  • src/pages/todo/
    • index.tsx (TodoPage コンポーネント)
      • 下記のコンポーネント・フックを利用して画面全体を組み立てる
  • src/features/todo/
    • TodoCreateArea.tsx
      • 「タスク新規作成」の部分
      • コールバック Props : onCreateTodoItem
      • その他の Props : なし
      • ステート : title
    • TodoItem.tsx
      • タスク一覧の各アイテム
      • コールバック Props : onMarkAsDone
      • その他の Props : itemId, status, title
      • ステート : なし
    • useTodoItems.ts
      • タスクの一覧とそれを更新する関数群を提供するフック

先程のコンポーネント依存関係図を実際のコンポーネント, Prop 名に直すとこのようになります。

画面の状態とアクションの図

余談 : ディレクトリ構造について

features/のような目的駆動のディレクトリが無いと、今回必要な「大きな画面を小さな機能に分ける」ためのコンポーネントを作るのが困難になってしまいます。

なので、目的駆動パッケージングを取り入れることをオススメします。

再レンダリングも抑制できた 👏

嬉しい副産物があります。

たびたび出てくる図ではアクションの起きる頻度を矢印の太さで表していました。 そして、細い実線矢印は親 (TodoPage) に影響を与えますが、 setTitle の太い実線矢印のアクションは TodoCreateArea に閉じていて親には影響しません。

そのため、頻繁に起こるアクションが広範囲の再レンダリングを防ぐことが出来ているのです👏👏 ページ下部のソース全文を確認すれば分かりますが、 React.memo() は使用していません。

分割前の再レンダリングのようす

todo-sample-unrefactored.mov.gif

分割後の再レンダリングのようす

todo-sample-refactored.mov.gif

おわりに

このように、 「状態やアクションに着目してコンポーネントに分ける」ことによって 「正しく動くだけでなく綺麗」なコード が書けるようになります。

この記事が皆さんの解決すべき難問を乗り越える力となることを願っています。

つづき

ふつう「作成」ボタンを押したら「件名」欄をリセットするでしょ?と思ったら続きも見てやってください。

関連記事

▼ 元ネタになった記事

▼ React の公式ドキュメントにも、同様の内容がまとめられています。

▼ 小さく分けて書くメリットについては、こちらの記事も参考になります

▼ (私の記事)状態を整理する所で「えっ?〇〇はステートじゃないの?」って思った人へ

▼ (私の記事)コンポーネントを細かく分割しやすいディレクトリ構造について

▼ コンポーネントの分割については、こちらの記事も有益

付録: 実際のコード

使用しているライブラリ:

  • next - 13.1.2
    • /pages ディレクトリを使用
  • react, @react/dom - 18.2.0
  • @mantine/core - 5.10.1
  • @emotion/react - 11.10.5
    • Mantine の依存

TodoPage コンポーネント

ページ本体のコンポーネントです。

コードを適度に短くしつつ過度な共通化・抽象化を避けているため、このページがどんな機能から成るのかコードだけでも分かりやすいのではと思います。

src/pages/todo/index.tsx
import { NextPage } from "next";
import { Box, Button, Container, Divider } from "@mantine/core";

import TodoCreateArea from "@/features/todo/TodoCreateArea";
import TodoItem from "@/features/todo/TodoItem";
import useTodoItems from "@/features/todo/useTodoItems";

const TodoPage: NextPage = () => {
  const todoItems = useTodoItems();

  return (
    <Container>
      <Box my="lg">
        <TodoCreateArea onCreateTodoItem={todoItems.create} />
      </Box>

      <Button variant="outline" onClick={todoItems.markAllAsDone}>
        全て完了する
      </Button>

      <Box mt="md">
        {todoItems.items.map((item) => (
          <TodoItem
            key={item.id}
            itemId={item.id}
            title={item.title}
            status={item.status}
            onMarkAsDone={todoItems.markSingleItemAsDone}
          />
        ))}
        <Divider />
      </Box>
    </Container>
  );
};

export default TodoPage;

useTodoItems フック

ここでは簡単のため、 useState + useCallback でタスクたちのデータを保持し、更新するための関数を提供していますが、 実際のアプリケーションでは @vercel/swr や TanStack Query を使ってバックエンドと通信することが多いと思います。

今回は「一つの画面」だから一つのカスタムフックにまとめた訳ではありません。そのような分割はカスタムフックをブラックボックス化するので私は推奨しません

あくまで「TodoリストのCRUD操作」というひとまとまりの関心事だからカスタムフックにまとめています。

「export する関数には型定義が必要」といった規約を eslint で設定しているケースに配慮して、 ReturnValue で先に返り値の型を宣言してから、フック内でその型を引用して書いています。

src/features/todo/useTodoItems.ts のソースコード
src/features/todo/useTodoItems.ts
import { useCallback, useState } from "react";

type TodoItem = {
  title: string,
  id: number,
  status: "todo" | "done",
};

type ReturnValue = {
  items: TodoItem[],
  create: (newItem: Pick<TodoItem, "title">) => void,
  markSingleItemAsDone: (itemId: TodoItem["id"]) => void,
  markAllAsDone: () => void,
};

const useTodoItems = (): ReturnValue => {
  const [items, setItems] = useState<ReturnValue["items"]>([]);

  const create: ReturnValue["create"] = useCallback((newItem) => {
    setItems((prev) => [
      ...prev,
      {
        title: newItem.title,
        status: "todo",
        id: Date.now(),
      },
    ]);
  }, []);

  const markSingleItemAsDone: ReturnValue["markSingleItemAsDone"] = useCallback(
    (itemId) => {
      setItems((prev) =>
        prev.map((i) => (i.id === itemId ? { ...i, status: "done" } : i))
      );
    },
    []
  );

  const markAllAsDone: ReturnValue["markAllAsDone"] = useCallback(() => {
    setItems((prev) => prev.map((item) => ({ ...item, status: "done" })));
  }, []);

  return { items, create, markSingleItemAsDone, markAllAsDone };
};

export default useTodoItems;

TodoCreateArea コンポーネント

文法的には 名詞+動詞 でおかしいですが、検索・補完のしやすさを考慮して Todo をプレフィックスにしています。

src/features/todo/TodoCreateArea.tsx のソースコード
src/features/todo/TodoCreateArea.tsx
import { 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>("");

  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={() => onCreateTodoItem({ title: title })}>
            作成
          </Button>
        </Grid.Col>
      </Grid>
    </Paper>
  );
};

export default TodoCreateArea;

TodoItem コンポーネント

src/features/todo/TodoItems.tsx のソースコード
src/features/todo/TodoItem.tsx
import { Button, Divider, Grid, Text } from "@mantine/core";

type Props = {
  itemId: number,
  status: "todo" | "done",
  title: string,
  onMarkAsDone: (id: number) => void,
};

const TodoItem: React.FC<Props> = ({ itemId, status, title, onMarkAsDone }) => {
  return (
    <>
      <Divider />
      <Grid p="sm">
        <Grid.Col span="content">
          {status === "todo" ? (
            <Button variant="outline" onClick={() => onMarkAsDone(itemId)}>
              完了
            </Button>
          ) : (
            <Button disabled>DONE</Button>
          )}
        </Grid.Col>

        <Grid.Col span="auto">
          <Text size={18} weight="bold">
            {title}
          </Text>
        </Grid.Col>
      </Grid>
    </>
  );
};

export default TodoItem;

  1. 「ローマ帝国を参考にダイクストラが分割統治法を提唱したんじゃないの?」と思った方へ -- 分割統治(Divide and Conquer)について | Ouobpo

225
208
0

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
225
208

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?