React(TypeScript)でSOLIDの原則を心で理解する
SOLIDの原則は、5つの基本的なプログラミング・コンセプトの集合です。
これらは主にOOP(オブジェクト指向プログラミング)に適用されます。また、他の分野にも応用ができ、ソフトウェア・アーキテクチャには欠かせないものです。これらの原則を紹介するために、ReactとTypescriptを利用することで、頭ではなく心で理解することに努めました。これらの5つの原則に基づいて単純な「TODOリスト」を作成しました。
SOLIDの原則とは?
S.O.L.I.Dという頭字語は、ロバート・C・マーティン、別名ボブおじさん(Clean Code)によって考案されたオブジェクト指向プログラミングの原則から、マイケル・フェザーズ(Michael Feathers)によって作られた造語です。
これらの原則は、コードをより読みやすく(クリーンに)、保守しやすく、変更しやすく、再利用可能で、コードの重複がないものにすることを目的としています。
「簡単」というのは、アプリケーションに変更を加えるコストよりも、その変更による直接的な利益の方が常に大きくあるべきだという意味だと理解で相違ありません。
開発者として、これらの原則に従うことで、堅牢で、柔軟で、拡張性があり、保守が容易なアプリケーションを設計することができます。
これらの原則を適用することは、コードの品質とアプリケーションのライフサイクル管理の向上に大きく貢献します。
これらの原則を使うことは、日常生活の一部がより輝くことになると信じています。
今回は、5つの原則を悪いコードと良いコードを比較して心で理解することにしましょう。
S: 単一責任の原則(Single responsibility principle)
単一責任の原則は、クラスが変更されるべき理由は1つだけであるべきです。つまり、クラス(コンポーネント、関数)が持つべき責任は1つだけであるべきである、と規定しています。
これによって、関数が1つのことだけを行うが、それがうまく行われることを保証することができます。
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>
)
}
export const TodoListItems = ({ todos }: { todos: Todo[] }) => {
return (
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
)
}
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
コンポーネントをTodoListWidget
とTodoListItems
の2つのコンポーネントに分割しています。
TodoListWidget
コンポーネントは API からタスクを取得する役割を担い、TodoListItems
コンポーネントはタスクを表示する役割を担います。
コードはより明快で保守しやすくなり、各コンポーネントが単一の責任を持つという原則が尊重されます。
TodoListItems
コンポーネントもアイテムを複数表示しているため、単一責任の原則に違反しているとも言えます。そのため、コンポーネントをさらに分割してTodoItem
コンポーネントを追加することもできます。
質問
なぜ単一責任の原則を使うのですか?
- コンポーネントをアプリケーションから切り離すことができます
- 長いクラスを避けることができ、コードがより明確になります
- コードがより保守的になり、開発がより簡単で快適になります
- それぞれのコンポーネント、クラス、ファイルにはそれぞれの役割があります
O: オープン/クローズドの原則(Open–closed principle)
オープン/クローズドの原則は、クラスやモジュールなどのソフトウェア・エンティティは、拡張に対してはオープンであるべきですが、変更に対してはクローズであるべきだと提唱しています。
クラスやモジュールは、既存のソースコードを変更することなく、新しい機能を追加するために拡張できるべきです。
これにより、既存の動作を中断させることなく、新しいメソッドを追加することが容易になります。
簡単に言えば
これは、コードの修正よりもコードの拡張を奨励することを意味します。
これは、パラメータに従ってアクションの振る舞いを変更するのではなく、上流で定義された関数を使って、このパラメータの機能を拡張することを意味します。
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>
</>
)
}
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>
</>
)
}
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)
リスコフの置換の原則は、派生クラスのオブジェクトは、プログラムの一貫性に影響を与えることなく、基底クラスのオブジェクトと置換(置き換え)できなければならないと定めています。
言い換えれば、子クラスは親クラス以上のこともそれ以下のこともできません。
つまり、コードの実行に影響を与えることなく、子クラスを入れ替えることができるということです。
継承階層は強固であり、サブクラスを使用する際の驚きやエラーを避けることができます。
この原則に従うためには、いくつかの重要な条件があります
- 関数のシグネチャ(パラメータと戻り値)は、子関数と親関数で同一でなければならない
- 子関数のパラメータは、親関数のものより多くしてはならない
- 子関数の戻り値は親関数と同じ型でなければならない
- 返される例外やエラーは同じでなければならない
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} />
}
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 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)
依存性注入とも呼ばれます。
依存関係逆転の原則は、単一責任の原則と並んで最も重要なもののひとつとも言えます。高レベルのクラスやモジュールは、抽象化されたものに依存し、実装には依存しないというルールです。
抽象化は細部に依存すべきではなく、細部は抽象化に依存すべきです。
より簡単に
オブジェクトをパラメータとして渡すのを避け、インターフェースが利用可能な場合にはそれを用います。そうすることで、扱うオブジェクトの型が何であれ、それが適切なメソッドや型を持っていることを確実にできます。
これには、オブジェクト自体ではなく、抽象をパラメータとして渡すことを求められます。具体的には、インターフェース(オブジェクト指向言語において)を通じた契約を使い、そうすることです。
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からタスクを取得
}, [])
// ...取得したタスクの利用
}
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
コンポーネントは、タスクがどのように取得されるかについては気にせず、初期データの取得時に呼び出されるコールバック関数を提供するだけです。
これにより、コンポーネントはより柔軟で、データソースから独立したものとなります。テストの実装が容易になり、必要に応じてデータ取得のロジックを容易に置き換えることができます。これは依存性反転の原則を尊重しています。
質問
なぜ依存関係の逆転原理を使うのですか?
- コードがより柔軟で修正しやすくなり、バグを恐れずに機能を追加できます
- コードは再利用しやすくなります
- 依存関係を分離することで、単体テストの記述が容易になります
- コードの品質と可読性が向上します
あらら...良いコードに少し問題があるようです
もしかしたらお気づきの方がいるかもしれません。「良いコード」において、TodoListWidget
と TodoListItems
コンポーネント(これは意図的なもので、誤りではありません)は、リスコフの置換原則や、オープン/クローズドの原則、依存性逆転の原則を守っていません。
実際には、TodoListItems
コンポーネント内でTodoItem
コンポーネントを置き換えることができません。また、TodoListWidget
内でTodoListItems
コンポーネントを置き換えることもできません。
では、React
を使ってTodoListWidget
コンポーネントを拡張可能でカスタマイズ可能にするにはどうすれば良いのでしょうか?
そんなに複雑なことではありません。主に依存性逆転の原則を利用して、TodoListItems
と TodoItem
のコンポーネントを交換することで、リスコフの置換原則やオープン/クローズド原則を遵守することができます。
この小技を実現するために、TodoListWidget
コンポーネントに「providers
」というパラメーターを追加します。これらの「providers
」は関数の形をしており、カスタマイズされた「サブコンポーネント」のレンダリングを返す責務を持ちます。
ここでは、Todoリストのアイテムに対してこれを実現する方法を紹介します。
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
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>
)
}
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>
)
}
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 側
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
失礼します。
データ取得・CRUD のロジックの共通化には、TodoListWidget のようなコンポーネントを使わずに、以下のようなジェネリックなフックを作るほうが良いと思います。
そうすると、具体的な各種リストは、〇〇Provider を使わず、直接的に各種LisstItemを描画することになるので、Item 系のコンポーネントについては、リスコフの置換原則、インターフェイス分離の原則については、共通化されてないのでそもそも無関係になります。
また、「UI の見た目上の構造を共通化したい」というニーズに対しては、TodoListTemplate, TodoListLoadingIndicater のように《todoアイテムのデータの受け渡しはせず、ReactNode 型の Props を使って詳細を利用側に注入させる、見た目の共通化のみを目的とする》ようなコンポーネントにする
composition パターン をオススメします。
を両立し、かなり高いレベルで SOLID 原則を守ることができるようになります。(言わずもがな、これは 開放/閉鎖原則 にのっとっています)
詳しい説明は省略しますが、
on〇〇
の形式のコールバック Props や、formElm
のような ReactNode 型の Props は、依存性逆転原則 にも従っています。(「《タスクを削除する》とはどういう処理か」「独自の機能を備えたフォームは、どのようなものか」が、親から注入する情報になっていて、子は一切関知しないからです。)ありがとうございます。ご教授いただいたアプローチについて、データ取得やCRUDロジックを共通化するためのジェネリックなフックは学びになりました。これによりデータ操作のロジックはコンポーネントから分離することができ、再利用性を高めれることができそうです。
確かにcompositionパターンを利用して、コンポーネントの見た目を柔軟に再利用できるようにした方が優れた方法だと思いました。
特に特定のデータ構造を依存しない形でUIの見た目を共通化できるので、様々な状況で用意にカスタマイズ可能なUIコンポーネントを作成できそうです。
また、コールバックPropsやReactNode型のPropsを通じて、処理の詳細を子コンポーネントから分離し、それを親コンポーネントから注入する方法は、コンポーネント間の結合度を低下させ、高度な柔軟性と再利用性を可能したと認識しました。
いただいたアプローチの内容は、今後の開発で大いに役立つと確信しており、実際に適用してみるのが楽しみになりました。ありがとうございました。
いきなり長文で失礼しました🙇♀ お助けになれたのなら幸いです!