🐒

React(TypeScript)でSOLIDの原則を心で理解する

2024/03/10に公開3

SOLIDの原則は、5つの基本的なプログラミング・コンセプトの集合です。
これらは主にOOP(オブジェクト指向プログラミング)に適用されます。また、他の分野にも応用ができ、ソフトウェア・アーキテクチャには欠かせないものです。これらの原則を紹介するために、ReactとTypescriptを利用することで、頭ではなく心で理解することに努めました。これらの5つの原則に基づいて単純な「TODOリスト」を作成しました。

https://react-typescript-solid-principle.vercel.app/

SOLIDの原則とは?

S.O.L.I.Dという頭字語は、ロバート・C・マーティン、別名ボブおじさん(Clean Code)によって考案されたオブジェクト指向プログラミングの原則から、マイケル・フェザーズ(Michael Feathers)によって作られた造語です。

これらの原則は、コードをより読みやすく(クリーンに)、保守しやすく、変更しやすく、再利用可能で、コードの重複がないものにすることを目的としています。

「簡単」というのは、アプリケーションに変更を加えるコストよりも、その変更による直接的な利益の方が常に大きくあるべきだという意味だと理解で相違ありません。

開発者として、これらの原則に従うことで、堅牢で、柔軟で、拡張性があり、保守が容易なアプリケーションを設計することができます。

これらの原則を適用することは、コードの品質とアプリケーションのライフサイクル管理の向上に大きく貢献します。

これらの原則を使うことは、日常生活の一部がより輝くことになると信じています。

今回は、5つの原則を悪いコードと良いコードを比較して心で理解することにしましょう。

S: 単一責任の原則(Single responsibility principle)

単一責任の原則は、クラスが変更されるべき理由は1つだけであるべきです。つまり、クラス(コンポーネント、関数)が持つべき責任は1つだけであるべきである、と規定しています。

これによって、関数が1つのことだけを行うが、それがうまく行われることを保証することができます。

TodoListWidget.tsx
const TodoListWidget = () => {
  const [todos, setTodos] = useState<Todo[]>([])

  const fetchTodos = () => {
    fetch('https://api.example.com/todos')
      .then((response) => response.json())
      .then((data) => setTodos(data))
      .catch((error) => console.error(error))
  }

  useEffect(() => {
    fetchTodos() // APIからタスクを取得
  }, [])

  return (
    <div>
      {todos.map((todo) => (
        <div key={todo.id}>{todo.title}</div>
      ))}
    </div>
  )
}
@widgets/components/TodoListItems.tsx
export const TodoListItems = ({ todos }: { todos: Todo[] }) => {
  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  )
}
TodoListWidget.tsx
import { TodoListItems } from '@widgets/components/TodoListItems'

const TodoListWidget = () => {
  const [todos, setTodos] = useState<Todo[]>([])

  const fetchTodos = () => {
    fetch('https://api.example.com/todos')
      .then((response) => response.json())
      .then((data) => setTodos(data))
      .catch((error) => console.error(error))
  }

  useEffect(() => {
    fetchTodos() // APIからタスクを取得
  }, [])

  return <TodoListItems todos={todos} />
}

悪いコードでは、TodoListWidgetコンポーネントがAPIからのタスクの取得とタスクの表示の両方を管理しています。

これは単一責任の原則に違反します。コンポーネントは1つのことだけを行うべきです。

良いコードでは、TodoListWidgetコンポーネントをTodoListWidgetTodoListItemsの2つのコンポーネントに分割しています。

TodoListWidgetコンポーネントは API からタスクを取得する役割を担い、TodoListItemsコンポーネントはタスクを表示する役割を担います。

コードはより明快で保守しやすくなり、各コンポーネントが単一の責任を持つという原則が尊重されます。

TodoListItemsコンポーネントもアイテムを複数表示しているため、単一責任の原則に違反しているとも言えます。そのため、コンポーネントをさらに分割してTodoItemコンポーネントを追加することもできます。

質問

なぜ単一責任の原則を使うのですか?

  • コンポーネントをアプリケーションから切り離すことができます
  • 長いクラスを避けることができ、コードがより明確になります
  • コードがより保守的になり、開発がより簡単で快適になります
  • それぞれのコンポーネント、クラス、ファイルにはそれぞれの役割があります

O: オープン/クローズドの原則(Open–closed principle)

オープン/クローズドの原則は、クラスやモジュールなどのソフトウェア・エンティティは、拡張に対してはオープンであるべきですが、変更に対してはクローズであるべきだと提唱しています。

クラスやモジュールは、既存のソースコードを変更することなく、新しい機能を追加するために拡張できるべきです。
これにより、既存の動作を中断させることなく、新しいメソッドを追加することが容易になります。

簡単に言えば

これは、コードの修正よりもコードの拡張を奨励することを意味します。
これは、パラメータに従ってアクションの振る舞いを変更するのではなく、上流で定義された関数を使って、このパラメータの機能を拡張することを意味します。

TodoListItems.tsx
const TodoListItems = () => {
  const [todos, setTodos] = useState<Todo[]>([])

  /* ... */

  const addTodo = (newTodo: Todo) => {
    setTodos([...todos, newTodo])
  }

  return (
    <>
      <div>
        <label htmlFor="new-todo-item">タスク追加</label>
        <div>
          <input
            type="text"
            placeholder="New Todo"
            value={newTodoTitle}
            onChange={handleTitleChange}
          />
          <button onClick={addTodo}>追加</button>
        </div>
      </div>
      <ul>
        {todos.map((todo) => (
          <li key={todo.id}>{todo.title}</li>
        ))}
      </ul>
    </>
  )
}
@widgets/components/TodoListItems.tsx
interface Props {
  todos: Todo
  addTodoHandler: React.MouseEventHandler<HTMLButtonElement>
}

const TodoListItems = ({ todos, addTodoHandler }: Props) => {
  /* 適切な動作に必要なロジック */

  return (
    <>
      <div>
        <label htmlFor="new-todo-item">タスク追加</label>
        <div>
          <input
            type="text"
            placeholder="New Todo"
            value={newTodoTitle}
            onChange={handleTitleChange}
          />
          <button onClick={addTodoHandler}>追加</button>
        </div>
      </div>
      <ul>
        {todos.map((todo) => (
          <li key={todo.id}>{todo.title}</li>
        ))}
      </ul>
    </>
  )
}
TodoListWidget.tsx
import { TodoListItems } from '@widgets/components/TodoListItems'

// 外部タスクマネージャー
const TodoListWidget = () => {
  const [todos, setTodos] = useState<Todo[]>([])

  const handleAddTodo = (newTodo: Todo) => {
    setTodos([...todos, newTodo])
  }

  return <TodoListItems todos={todos} addTodoHandler={handleAddTodo} />
}

悪いコードでは、TodoListItemsコンポーネントがaddTodo関数を使って新しいタスクを直接追加しています。

これは、オープン/クローズドの原則に違反しています。なぜなら、新しい機能を追加するためにコードが直接修正される可能性があり、望ましくない副作用を引き起こす可能性があるためです。

良いコードでは、外部のTodoListWidgetハンドラーを使ってタスクを管理しています。このマネージャーには、新しいタスクを追加するロジック(addTodoHandler)が含まれています。TodoListItemsコンポーネントは、タスクと addTodoHandle関数をpropsとして受け取ります。

TodoListItemsコンポーネントは、直接変更する場合はクローズされ、新しい機能を追加する場合はエクステンションにオープンされます。外部マネージャーを使用することで、オープン/クローズドの原則が尊重されます。

この良いコードでは、TodoListItemsコンポーネントも、上で見たように、単一責任の原則に違反していることに注意してください。つまり、100%正しいわけではありません。

L: リスコフの置換原則(Liskov substitution principle)

リスコフの置換の原則は、派生クラスのオブジェクトは、プログラムの一貫性に影響を与えることなく、基底クラスのオブジェクトと置換(置き換え)できなければならないと定めています。

言い換えれば、子クラスは親クラス以上のこともそれ以下のこともできません。
つまり、コードの実行に影響を与えることなく、子クラスを入れ替えることができるということです。

継承階層は強固であり、サブクラスを使用する際の驚きやエラーを避けることができます。

この原則に従うためには、いくつかの重要な条件があります

  • 関数のシグネチャ(パラメータと戻り値)は、子関数と親関数で同一でなければならない
  • 子関数のパラメータは、親関数のものより多くしてはならない
  • 子関数の戻り値は親関数と同じ型でなければならない
  • 返される例外やエラーは同じでなければならない
TodoListItems.tsx
const TodoListWidget = () => {
  const [todos, setTodos] = useState<Todo[]>([])

  const addTodo = (newTodo: any) => {
    if (typeof newTodo === 'string') {
      setTodos([...todos, newTodo])
    } else {
      throw new Error('Invalid todo type')
    }
  }

  return <TodoListItems todos={todos} addTodo={addTodo} />
}
TodoListItems.tsx
const TodoListWidget = () => {
  const [todos, setTodos] = useState<Todo[]>([])

  const addTodo = (newTodo: Todo) => {
    setTodos([...todos, newTodo])
  }

  return <TodoListItems todos={todos} addTodo={addTodo} />
}

悪いコードでは、TodoListWidgetタスクマネージャーは文字列タスクしかサポートしていません。他のタイプのデータが渡されると、エラーをスローします。

これはリスコフの置換原則に違反します。なぜなら、このハンドラーは他のタイプのタスクを受け付ける他のハンドラーで代用することができないからです。

良いコードでは、TodoListWidgetタスクマネージャーは有効な型タイプのタスクを受け入れます。新しいタスクをリストに追加する際にタイプ(typeof)をチェックすることはありません。

これはリスコフの置換の原則を尊重しています。なぜなら、このマネージャーは、全体的な振る舞いを変えることなく、似たようなタイプのタスクを受け入れる別のもので代替できるからです。

質問

なぜリスコフの置換原理を使うのですか?

  • 子が親よりも(たいていの場合)多くのことをするようなバグは避けるためです
  • アップデートが容易になります。親が変更する度に、変更はすべて子に反映されるので、より安全です
  • コードが読みやすくなり、再利用しやすくなります

I: インターフェイス分離の原則(Interface segregation principle)

インターフェイス分離の原則では、インターフェイスを要件に特化した機能に分割することを推奨しています。

つまり、必要のないメソッドを実装したり、情報を使ったりすべきではないということです。その代わりに、それを必要とするエンティティと契約を結びます。

インターフェイス(コンポーネント、関数など)をより小さな特定のインターフェイスに分離することで、不要なメソッドの負担を避け、インターフェイスをより特定のニーズと一致させることができます。

interface
interface Todo {
  id: string
  title: string
  isChecked: boolean
}
const TodoItemCheckbox = ({ todo }: { todo: Todo }) => {
  return <input type="checkbox" checked={todo.isChecked} />
}

const TodoItem = ({ todo }: { todo: Todo }) => {
  return (
    <li>
      <TodoItemCheckbox todo={todo} />
      <span>{todo.title}</span>
    </li>
  )
}

const TodoListItems = ({ todos }: { todos: Todo[] }) => {
  return (
    <ul>
      {todos.map((todo) => (
        <TodoItem key={todo.id} todo={todo} />
      ))}
    </ul>
  )
}
const TodoItemCheckbox = ({ isChecked }: { isChecked: boolean }) => {
  return <input type="checkbox" checked={isChecked} />
}

const TodoItem = ({ todo }: { todo: Todo }) => {
  return (
    <li>
      <TodoItemCheckbox isChecked={todo.isChecked} />
      <span>{todo.title}</span>
    </li>
  )
}

const TodoListItems = ({ todos }: { todos: Todo[] }) => {
  return (
    <ul>
      {todos.map((todo) => (
        <TodoItem key={todo.id} todo={todo} />
      ))}
    </ul>
  )
}

悪いコードでは、タスクのすべての詳細が、必要がないのにTodoItemCheckboxコンポーネントに渡されています。これにより、コンポーネントに不必要なエラーのリスクと複雑さが加わります。
これは、コンポーネントが不必要な機能や情報を持つことになり、問題になる可能性があります。

これは、「インターフェース」を機能ごとに明確に分けることを推奨するインターフェース分離の原則に違反します。

良いコードでは、TodoItemCheckboxコンポーネントは、タスクのすべての詳細を受け取るのではなく、必要なisChecked情報だけを受け取ります。

これにより、不要な機能/情報を持つことがなくなります。これは、インターフェース分離の原則を尊重しています。

質問

なぜインターフェース分離の原則を使うのですか?

  • コードの品質が向上します
  • コードがよりモジュール化され、保守しやすくなります
  • コードが読みやすく理解しやすくなります
  • 単一責任とオープン/クローズドの原則が尊重されます

D: 依存性逆転の原則(Dependency inversion principle)

依存性注入とも呼ばれます。

依存関係逆転の原則は、単一責任の原則と並んで最も重要なもののひとつとも言えます。高レベルのクラスやモジュールは、抽象化されたものに依存し、実装には依存しないというルールです。

抽象化は細部に依存すべきではなく、細部は抽象化に依存すべきです。

より簡単に

オブジェクトをパラメータとして渡すのを避け、インターフェースが利用可能な場合にはそれを用います。そうすることで、扱うオブジェクトの型が何であれ、それが適切なメソッドや型を持っていることを確実にできます。

これには、オブジェクト自体ではなく、抽象をパラメータとして渡すことを求められます。具体的には、インターフェース(オブジェクト指向言語において)を通じた契約を使い、そうすることです。

TodoListWidget.tsx
const TodoListWidget = () => {
  const [todos, setTodos] = useState<Todo[]>([])

  const fetchTodos = () => {
    fetch('https://api.example.com/todos')
      .then((response) => response.json())
      .then((data) => setTodos(data))
      .catch((error) => console.error(error))
  }

  useEffect(() => {
    fetchTodos() // APIからタスクを取得
  }, [])

  // ...取得したタスクの利用
}
TodoListWidget.tsx
const TodoListWidget = ({ fetchCallback }) => {
  const [todos, setTodos] = useState<Todo[]>([])

  const fetchTodos = () => {
    // APIなどからタスクを取得するコード
    const todoItems = fetchCallback() // 注入されたコールバック関数を呼び出し
    setTodos(todoItems) // 取得したToDo項目で状態を更新
  }

  useEffect(() => {
    fetchTodos() // コンポーネントのマウント時にTodo項目を取得
  }, [])

  // その他の処理
}

悪いコードでは、TodoListWidgetコンポーネントがタスクをAPIから取得するためのfetchTodos関数に直接依存しています。コンポーネントはタスク取得のロジックと密接に結びついており、このロジックの置き換えや単体テストの実施が困難になります。

良いコードでは、fetchTodosでコールバック関数を使用することで、TodoListWidgetとタスクの取得との依存関係を逆転させています。

TodoListWidgetコンポーネントは、タスクがどのように取得されるかについては気にせず、初期データの取得時に呼び出されるコールバック関数を提供するだけです。

これにより、コンポーネントはより柔軟で、データソースから独立したものとなります。テストの実装が容易になり、必要に応じてデータ取得のロジックを容易に置き換えることができます。これは依存性反転の原則を尊重しています。

質問

なぜ依存関係の逆転原理を使うのですか?

  • コードがより柔軟で修正しやすくなり、バグを恐れずに機能を追加できます
  • コードは再利用しやすくなります
  • 依存関係を分離することで、単体テストの記述が容易になります
  • コードの品質と可読性が向上します

あらら...良いコードに少し問題があるようです

もしかしたらお気づきの方がいるかもしれません。「良いコード」において、TodoListWidgetTodoListItemsコンポーネント(これは意図的なもので、誤りではありません)は、リスコフの置換原則や、オープン/クローズドの原則、依存性逆転の原則を守っていません。

実際には、TodoListItemsコンポーネント内でTodoItemコンポーネントを置き換えることができません。また、TodoListWidget内でTodoListItemsコンポーネントを置き換えることもできません。

では、Reactを使ってTodoListWidgetコンポーネントを拡張可能でカスタマイズ可能にするにはどうすれば良いのでしょうか?

そんなに複雑なことではありません。主に依存性逆転の原則を利用して、TodoListItemsTodoItemのコンポーネントを交換することで、リスコフの置換原則やオープン/クローズド原則を遵守することができます。

この小技を実現するために、TodoListWidgetコンポーネントに「providers」というパラメーターを追加します。これらの「providers」は関数の形をしており、カスタマイズされた「サブコンポーネント」のレンダリングを返す責務を持ちます。

ここでは、Todoリストのアイテムに対してこれを実現する方法を紹介します。

type.d.ts
type.d.ts
export interface Todo {
  id: string
  title: string
  completed: boolean
}

export interface TodoExt extends Todo {
  dueDate: string
}

export interface TodoItemProps<T> {
  item: T
  deleteTodoHandler: DeleteTodoHandler
  toggleTodoCompletedHandler: toggleTodoCompletedHandler
}

export interface CustomListItemProvider<T> {
  (
    todo: T,
    deleteTodoHandler: DeleteTodoHandler,
    toggleTodoCompletedHandler: ToggleTodoCompletedHandler
  ): React.JSX.Element
}

export interface TodoListItemsProps {
  todos: (Todo | TodoExt)[]
  deleteTodoHandler: DeleteTodoHandler
  toggleTodoCompletedHandler: toggleTodoCompletedHandler
  customListItemProvider: CustomListItemProvider
}

export interface TodoListWidgetProps<T> {
  fetchCallback: FetchCallback
  customListItemProvider: CustomListItemProvider<T>
}

export type FetchCallback = (signal: AbortSignal) => Todo[]

export type DeleteTodoHandler = (id: string) => void

export type toggleTodoCompletedHandler = (id: string) => void
TodoListWidget.tsx
import React from 'react'
import { useEffect, useState } from 'react'
import { TodoListItems } from '@widgets/components/TodoListItems'
import { Todo, TodoExt, TodoListWidgetProps } from '@widgets/type'

export const TodoListWidget = <T extends Todo | TodoExt>({
  fetchCallback,
  customListItemProvider,
}: TodoListWidgetProps<T>) => {
  const [todos, setTodos] = useState<(Todo | TodoExt)[]>([])

  const fetchTodos = (signal: AbortSignal) => {
    // APIまたはその他の手段からタスクを取得する
    const todoItems = fetchCallback(signal)
    setTodos(todoItems)
  }

  const handleDeleteTodo = (id: string) => {
    setTodos(todos.filter((todo) => todo.id !== id))
  }

  const handleToggleCompleted = (id: string) => {
    setTodos(
      todos.map((todo) => {
        if (todo.id === id) {
          return { ...todo, completed: !todo.completed }
        }
        return todo
      })
    )
  }

  useEffect(() => {
    const abortController = new AbortController()
    fetchTodos(abortController.signal)
  }, [])

  return (
    <div>
      <h1>Todo リスト Widget</h1>
      <hr />
      <TodoListItems
        todos={todos}
        deleteTodoHandler={handleDeleteTodo}
        toggleTodoCompletedHandler={handleToggleCompleted}
        customListItemProvider={customListItemProvider}
      />
    </div>
  )
}
@widgets/components/TodoListItems.tsx
import React from 'react'
import { TodoListItemsProps } from '@widgets/type'

export const TodoListItems = ({
  todos,
  deleteTodoHandler,
  toggleTodoCompletedHandler,
  customListItemProvider,
}: TodoListItemsProps) => {
  return (
    <div>
      {todos.length === 0 && (
        <p className={'text-center'}>表示するタスクがありません</p>
      )}
      {todos.length > 0 && (
        <ul>
          {todos.map((todo) => (
            <li key={todo.id}>
              {customListItemProvider(
                todo,
                deleteTodoHandler,
                toggleTodoCompletedHandler
              )}
            </li>
          ))}
        </ul>
      )}
    </div>
  )
}
@widgets/components/TodoItem.tsx
import React from 'react'
import { Todo, TodoItemProps } from '@widgets/type'

export const TodoItem: React.FC<TodoItemProps<Todo>> = ({
  item,
  deleteTodoHandler,
  toggleTodoCompletedHandler,
}) => {
  return (
    <div>
      <input
        id={`check-${item.id}`}
        type="checkbox"
        defaultChecked={item.completed}
        onClick={() => toggleTodoCompletedHandler(item.id)}
      />
      <label htmlFor={`check-${item.id}`}>{item.title}</label>
      <button onClick={() => deleteTodoHandler(item.id)}>X</button>
    </div>
  )
}

App.tsx 側

App.tsx
import React from 'react'
import { TodoListWidget } from '@widgets/TodoListWidget'
import { getData } from '@widgets/data/todoListData'
import { getData as getDataExt } from '@widgets/data/todoListDataExt'
import { TodoItemExt } from '@widgets/components/TodoItemExt'
import { TodoItem } from '@widgets/components/TodoItem'
import { Todo, TodoExt, CustomListItemProvider } from '@widgets/type'

const App = () => {
  const todoItemProvider: CustomListItemProvider<Todo> = (
    todo,
    deleteTodoHandler,
    toggleTodoCompletedHandler
  ) => {
    return (
      <TodoItem
        item={todo}
        deleteTodoHandler={deleteTodoHandler}
        toggleTodoCompletedHandler={toggleTodoCompletedHandler}
      />
    )
  }

  const todoItemExtProvider: CustomListItemProvider<TodoExt> = (
    todo,
    deleteTodoHandler,
    toggleTodoCompletedHandler
  ) => {
    return (
      <TodoItemExt
        item={todo}
        deleteTodoHandler={deleteTodoHandler}
        toggleTodoCompletedHandler={toggleTodoCompletedHandler}
      />
    )
  }

  return (
    <div>
      <TodoListWidget<Todo>
        fetchCallback={getData}
        customListItemProvider={todoItemProvider}
      />
      <TodoListWidget<TodoExt>
        fetchCallback={getDataExt}
        customListItemProvider={todoItemExtProvider}
      />
    </div>
  )
}

export default App

TodoListItemsコンポーネントからaddTodoHandlerへのpropsを削除しました。前述したように、単一責任の原則に違反していました。タスクをデータに追加するのはTodoListItemsコンポーネントの役割ではありません。その唯一の役割は、タスクのリストを表示することです。

したがって、TodoAddFormコンポーネントを新たに作成し、これをTodoListWidgetコンポーネントで使用することで、SOLIDの原則を尊重し続けることができました。

みなさんも心で理解できましたか。

完成品コード

SOLIDの原則を尊重したTodoListWidgetの完成品コードは、githubに残してあります。少しでも参考になれば幸いです。

参考文献

Discussion

Honey32Honey32

失礼します。

データ取得・CRUD のロジックの共通化には、TodoListWidget のようなコンポーネントを使わずに、以下のようなジェネリックなフックを作るほうが良いと思います。

import { useCallback, useEffect, useState } from "react";

/**
 * useTodoList で管理可能であるために、最低限この型の部分型である必要がある。
 */
export interface AnyTodoItem {
  id: string;
  completed: boolean;
}

export type FetcherFn<T> = (options: { signal: AbortSignal }) => Promise<T>;

/**
 * fetcher 関数は無限ループを引き起こす可能性あり。
 * メモ化したものか、コンポーネントの外側で宣言して参照が変わらないものを用いること。
 */
export const useTodoList = <T extends AnyTodoItem>(fetcher: FetcherFn<T[]>) => {
  const [todos, setTodos] = useState<T[]>([]);

  useEffect(() => {
    const ac = new AbortController();
    fetcher({ signal: ac.signal }).then((data) => {
      setTodos(data);
    });

    return () => {
      ac.abort();
    };
  }, [fetcher]);

  const addTodo = useCallback((newTodo: T) => {
    setTodos((prev) => [...prev, newTodo]);
  }, []);

  const deleteTodo = useCallback((id: string) => {
    setTodos((prev) => prev.filter((todo) => todo.id !== id));
  }, []);

  const toggleCompletedTodo = useCallback((id: string, newValue: boolean) => {
    setTodos((prev) =>
      prev.map((todo) => {
        if (todo.id === id) {
          return { ...todo, completed: newValue };
        }
        return todo;
      })
    );
  }, []);

  return {
    todos,
    addTodo,
    deleteTodo,
    toggleCompletedTodo,
  };
};

そうすると、具体的な各種リストは、〇〇Provider を使わず、直接的に各種LisstItemを描画することになるので、Item 系のコンポーネントについては、リスコフの置換原則インターフェイス分離の原則については、共通化されてないのでそもそも無関係になります。

また、「UI の見た目上の構造を共通化したい」というニーズに対しては、TodoListTemplate, TodoListLoadingIndicater のように《todoアイテムのデータの受け渡しはせず、ReactNode 型の Props を使って詳細を利用側に注入させる、見た目の共通化のみを目的とする》ようなコンポーネントにする
composition パターン をオススメします。

  • データの流れは共通化せず、個々(通常・拡張・API ) を独立させる
  • 見た目の一部を共通化する

を両立し、かなり高いレベルで SOLID 原則を守ることができるようになります。(言わずもがな、これは 開放/閉鎖原則 にのっとっています)

詳しい説明は省略しますが、on〇〇の形式のコールバック Props や、formElm のような ReactNode 型の Props は、依存性逆転原則 にも従っています。(「《タスクを削除する》とはどういう処理か」「独自の機能を備えたフォームは、どのようなものか」が、親から注入する情報になっていて、子は一切関知しないからです。)

"use client";

import { FC } from "react";
import { AnyTodoItem, FetcherFn, useTodoList } from "./use-todo-list";
import {
  TodoListTemplate,
  TodoListLoadingIndicator,
} from "./template-components";

interface TodoSimpleItem extends AnyTodoItem {
  title: string;
}

const fetchSimpleItems: FetcherFn<TodoSimpleItem[]> = async ({ signal }) => {
  return [];
};

export const TodoListSimple: FC = () => {
  const { todos, toggleCompletedTodo, addTodo } = useTodoList(fetchSimpleItems);

  return (
    <TodoListTemplate
      title="通常"
      // 追加フォームを ReactNode 型の Props で注入する (composition)
      formElm={<TodoSimpleAddTaskForm onSubmit={addTodo} />}
    >
      {todos.length === 0 ? (
        <TodoListLoadingIndicator />
      ) : (
        <ul>
          {todos.map((item) => (
            <li key={item.id}>
              <TodoListItemSimple
                title={item.title}
                completed={item.completed}
                onChangeCompleted={toggleCompletedTodo}
              />
            </li>
          ))}
        </ul>
      )}
    </TodoListTemplate>
  );
};

// 詳細は省略しています
export const TodoListItemSimple: FC<{
  title: string;
  completed: boolean;
  onChangeCompleted: (id: string, newValue: boolean) => void;
}> = () => {
  return <></>;
};

// 詳細は省略しています
export const TodoSimpleAddTaskForm: FC<{
  onSubmit: (item: TodoSimpleItem) => void;
}> = () => {
  return <></>;
};

andmorefineandmorefine

ありがとうございます。ご教授いただいたアプローチについて、データ取得やCRUDロジックを共通化するためのジェネリックなフックは学びになりました。これによりデータ操作のロジックはコンポーネントから分離することができ、再利用性を高めれることができそうです。
確かにcompositionパターンを利用して、コンポーネントの見た目を柔軟に再利用できるようにした方が優れた方法だと思いました。
特に特定のデータ構造を依存しない形でUIの見た目を共通化できるので、様々な状況で用意にカスタマイズ可能なUIコンポーネントを作成できそうです。
また、コールバックPropsやReactNode型のPropsを通じて、処理の詳細を子コンポーネントから分離し、それを親コンポーネントから注入する方法は、コンポーネント間の結合度を低下させ、高度な柔軟性と再利用性を可能したと認識しました。

いただいたアプローチの内容は、今後の開発で大いに役立つと確信しており、実際に適用してみるのが楽しみになりました。ありがとうございました。

Honey32Honey32

いきなり長文で失礼しました🙇‍♀ お助けになれたのなら幸いです!