🧊

【第1回】SOLID原則を使ったReact設計 ~SRP編~

2023/02/09に公開

概要

この記事はReactを使ってSOLID原則を理解する記事です。そしてSOLID原則を適用した時、どんな課題が解決できるのかを説明します。

今回は第1回となります。第1回はSOLID原則についての説明と原則の1つである単一責任の原則について説明します。

SOLID原則ってなに?SOLID原則を使うとどんな良いことがあるの?使ってみたいけど記事の内容が難しい。普段React使っているからReactを使った説明があったらな。

と勉強しはじめたときに思っている方のお力になれたら幸いです。

※できるだけ平易に説明しようと思うのですが難しい言葉があれば質問いただければコメント欄でお答えします。

参考記事

この記事では以下の素晴らしい記事達を参考にして作成しました。
https://konstantinlebedev.com/solid-in-react/
https://zenn.dev/chida/articles/e46a66cd9d89d1
https://zenn.dev/koki_tech/articles/361bb8f2278764

この記事で伝えたいこと

  • そもそもSOLID原則とは
  • SOLID原則を使う理由(メリット)
  • ReactでSOLID原則は使えるのか
  • SOLID原則の使い方 in React

そもそもSOLID原則とは?

SOLID原則はそれぞれの文字が5つの原則を表しています。

  • 単一責任の原則(SRP)
  • オープンクローズドの原則(OCP)
  • リスコフの置換原則(LSP)
  • インターフェイス分離の原則(ISP)
  • 依存関係が逆転の原則(DIP)

SOLID原則を使う理由(メリット)

5つの原則を適用することによって以下が実現できます。

  • 変更に強いアプリケーション(修正や機能追加が容易)
  • 読みやすいコード

補足:変更の辛さ

実際ソフトウェアは常に変化にさらされています。

例えば

  • 設計で考慮漏れがあった
  • 実装に誤りがあった
  • テストのときにバグが見つかった
  • 設計→実装→テストうまくいったけどユーザの要望と異なっていた、または要望が変化した
  • なんか実装がイケてない(リファクタリングしたい)
  • 機能を追加したい
  • 本番運用でバグが見つかった

このような変更は変更の大小にかかわらず容易ではありません。プロジェクトの特性や進捗、チームメンバーのレベルなどの要素も絡んできて変更の方法や手順は大変複雑になっていきます。また一方の変更が一方に影響して思い通りに変更できないことがあります。そうなると妥協による変更やリファクタリングが起こります。その変更が各地に広がっていきアプリケーションがどんどん複雑になっていきます。

コードは1回書き上げれば終わりではありません。これは時間が流れる限り絶対です。なぜならその時々のニーズや情勢、流行り、チームメンバーによって生み出されたさまざまな課題を解決するのがソフトウェアだからです。つまりソフトウェアは変化に強く作る必要があり、それを実現してくれる原則がSOLID原則だと考えられます。

ReactでSOLID原則は使えるのか

使えます。

しかし注意点があります。SOLID原則はもともとオブジェクト指向(OOP)を念頭に考え出されています。したがって、説明にクラスやインターフェースを使っています。Reactではクラスを使わないですし、インターフェイスもありません。(ただしTypeScriptが一般的になっているのでここはあまり問題ではないかもしれません。)またReactは関数型プログラミングで書かれるのでここもOOPと異なる部分です。

しかしSOLID原則は抽象度が高いのでどのやってどの程度従うかは開発者に委ねられます。ですので少し自由な解釈すればReactに適用することができます。

ただ以上の理由からReactでは5つ全てを完璧に使うことはできないと思っています。
この記事では5つではなく以下の4つの原則を説明しようと思います。

  • 単一責任の原則(SRP)
  • オープンクローズドの原則(OCP)
  • インターフェイス分離の原則(ISP)
  • 依存関係が逆転の原則(DIP)

なんでリスコフの置換原則(LSP)はやらないのかは最後に説明します。

全部使わなくても良いの?と思ったかもしれませんがそもそもSOLID原則を全て使う必要はなく、一部を使っても「変更に強い」アプリケーションは作ることができます。重要なのはSOLID原則を使うことではなく「変更に強い」アプリケーションを作ることです。

単一責任の原則(SRP)

本来の定義:すべてのクラスはただひとつの責任を持つべきである
Reactのための解釈:たったひとつのことを行うべきである(単一の目的だけを処理する)

「ひとつのこと」とは何でしょうか。実は「1つのこと」は以下の2つの面がありそれぞれの面を見てそれぞれが持つ「ひとつのこと」を考える必要があります。

  1. コンポーネントの内部から見た「ひとつのこと」
  2. コンポーネントの外部から見た「ひとつのこと」

【SRPのメリット】

  • 可読性が上がる
    一つ一つのファイルが小さくなるため
  • ある変更の及ぼす範囲が狭くなる
    例えば通信関係の変更があったからといってコンポーネントを直すのは変。なぜならコンポーネントは見た目担当だから。SRPを適用して責任の境界を作ることで通信関係のバグは通信関係のファイルだけ修正すれば良くなる。
  • バグが出たときに当たりがつきやすい
    例えばユーザの一覧を表示する機能で、ユーザIDの昇順でユーザが表示される想定⇒けれど想定通りに表示されていない⇒並び替えのロジックが書いてあるファイルを直せばよいか?と当たりがつきやすい。

【SRPのデメリット】

  • ファイルが多くなる

1. コンポーネントの内部から見た「ひとつのこと」

次のコンポーネントを使ってSRPを適用していきます。
TODOリストを取得して表示するコンポーネントです。

import React, { useEffect, useState } from 'react'

const URL = 'https://jsonplaceholder.typicode.com/todos'

export const App = () => {
  const [todos, setTodos] = useState([])

  useEffect(() => {
    const fetchAllTodos = async () => {
      const response = await fetch(URL)
      const allTodos = await response.json()
      setTodos(allTodos)
    }
    fetchAllTodos()
  }, [])

  return (
    <ul>
      {[...todos]
        .filter((todo) => todo.completed)
        .map((completedTodo) => (
          <li key={completedTodo.id}>
            <p>{completedTodo.id}</p>
            <a href="#">{completedTodo.title}</a>
          </li>
        ))}
    </ul>
  )
}

このコンポーネントは大きく以下のことを行っています。

  • データの取得
  • 取得したデータの編集
  • レンダリング
    これを分解していきます。

まずデータ取得です。useEffectとuseStateが登場しているときはカスタムフックを作る必要があるかもしれないとアンテナを張っておくのが良いと思います。

import React, { useEffect, useState } from 'react'

// カスタムフック
export const useAllTodos = () => {
  const [todos, setTodos] = useState([])

  useEffect(() => {
    const fetchAllTodos = async () => {
      const response = await fetch(URL)
      const allTodos = await response.json()
      setTodos(allTodos)
    }
    fetchAllTodos()
  }, [])

  return { todos }
}

// コンポーネント
const URL = 'https://jsonplaceholder.typicode.com/todos'
export const App = () => {
  const { todos } = useAllTodos()

  return (
    <ul>
      {[...todos]
        .filter((todo) => todo.completed)
        .map((completedTodo) => (
          <li key={completedTodo.id}>
            <p>{completedTodo.id}</p>
            <a href="#">{completedTodo.title}</a>
          </li>
        ))}
    </ul>
  )
}

useAllTodosフックはAPIを叩いて「データを取得する」というただひとつだけを行う関数になりました。

同時にコンポーネントに対しては以下の効果があります。

  • データの取得が処理がなくなったことでAppコンポーネントのコードが短くなる
  • カスタムフック名から処理の内容が読み取りやすくなった

それでは改めて「1つのこと」に注目してみてみると以下のように変化しました。

  • データの取得(コンポーネント)
  • 取得したデータの編集(コンポーネント)
  • レンダリング(コンポーネント)
  • データの取得(カスタムフック)
  • 取得したデータの編集(コンポーネント)
  • レンダリング(コンポーネント)

残りは取得したデータの編集をコンポーネントから剥がすことができればSRPが達成できそうです。
データの編集は状態(state)には関わっていないかつ、再利用できそうな汎用的な関数なためutilとして切り出すことができます。

// util関数
export const getCompletedTodos = (todos) => todos.filter((todo) => todo.completed)

// コンポーネント
export const App = () => {
  const { todos } = useAllTodos()

  return (
    <ul>
      {getCompletedTodos(todos).map((completedTodo) => (
        <li key={completedTodo.id}>
          <p>{completedTodo.id}</p>
          <a href="#">{completedTodo.title}</a>
        </li>
      ))}
    </ul>
  )

これで単一責任の原則を達成することができました。

  • データの取得(カスタムフック)
  • 取得したデータの編集(util)
  • レンダリング(コンポーネント)
// データの取得(通信)
const useAllTodos = () => {
  const [todos, setTodos] = useState([])

  useEffect(() => {
    const fetchAllTodos = async () => {
      const response = await fetch(URL)
      const allTodos = await response.json()
      setTodos(allTodos)
    }
    fetchAllTodos()
  }, [])

  return { todos }
}
// 取得したデータの編集
export const getCeompletedTodos = (todos: Todo[]) => todos.filter((todo) => todo.completed)
// レンダリング(見た目)
import React, { useEffect, useState } from 'react'

const URL = 'https://jsonplaceholder.typicode.com/todos'

export const App = () => {
  const { todos } = useAllTodos()

  return (
    <ul>
      {getCompletedTodos(todos).map((completedTodo) => (
        <li key={completedTodo.id}>
          <p>{completedTodo.id}</p>
          <a href="#">{completedTodo.title}</a>
        </li>
      ))}
    </ul>
  )
}

SRPの適用だけでもSOLID原則の効果を十分享受できそうです。

SRPをしていくとやりすぎと思うことがあるかもしれません。もしSRPを適用したけれど逆効果になったと感じた場合は、どのくらい分解すべきかを考え直すと良いと思います。あまり原則に執着すると悪い方向に行くことがあるので注意が必要です。

SRP以外のリファクタリング

SRP化は完了しましたが、実はまだこのコンポーネントには変えられる場所が2つあります。JSXに注目するとマークアップが少し複雑に見えます。以下のようにしてワンラインで書けるようにすると可読性が上がりますし、再利用ができるようになります。

// TodoItemコンポーネントを新たに作成
export const TodoItem = ({ todo }) => (
  <li>
    <p>{todo.id}</p>
    <a href="#">{todo.title}</a>
  </li>
)

// Appコンポーネントがより短くなった
export const App: React.FC = () => {
  const { todos } = useAllTodos()

  return (
    <ul>
      {getCompletedTodos(todos).map((completedTodo) => (
        <TodoItem key={completedTodo.id} todo={completedTodo} />
      ))}
    </ul>
  )
}

もう一つはデータ取得とフィルターに注目します。Appコンポーネントは取得したTodoリストをただ表示するだけにすればよりシンプルになります。具体的な方法としてはデータ取得とフィルターを一つの関数に閉じ込めます。これでコンポーネントはカスタムフックを呼び出すだけになりました。

const useCompletedTodos = () => {
  const { todos } = useAllTodos()
  const completedTodo = useMemo(() => {
    return getCompletedTodos(todos)
  }, [todos])
  return { completedTodo }
}

export const App: React.FC = () => {
  const { completedTodo } = useCompletedTodos()

  return (
    <ul>
      {completedTodo.map((completedTodo) => (
        <TodoItem key={completedTodo.id} todo={completedTodo} />
      ))}
    </ul>
  )
}

2. コンポーネントの外部から見た「ひとつのこと」

コンポーネントは作成するだけではありません。どこかでimportされます。

したがって、外部から見たとき「1つのコンポーネントがどれだけ多くのことに使えるか」に関係しています。多くのことに使えるコンポーネントは汎用的なコンポーネントと呼ばれたりしますが「ひとつのこと」に違反している可能性があります。

実際のコードから汎用的なコンポーネントがどうやって作られていくのかを見ていきます。
Twitterのようなアプリを作ります。ツブヤキを表示するコンポーネントを作りました。

type Props = {
  text: string;
}

const tweet = ({ text }: Props) => <p>{text}</p>

しばらくして、ツブヤキに画像を表示したくなりました。

type Props = {
  text: string;
  imgUrl: string;
}
const tweet = ({ text, imgUrl }: Props) => (
  <div>
    {imgUrl && <img src={imgUrl} />}
    {text && <p>{text}</p>}
  </div>
)

さらにボイスメッセージを使ったツブヤキにも対応しました。

type Props = {
  text: string
  imgUrl: string
  audioUrl: string
}
const tweet = ({ text, imgUrl, audioUrl }: Props) => {
  if (audioUrl)
    return (
      <audio controls>
        <source src={audioUrl} />
      </audio>
    )

  return (
    <div>
      {imgUrl && <img src={imgUrl} />}
      {text && <p>{text}</p>}
    </div>
  )
}

このコンポーネントは「ツブヤキ」という「ひとつのこと」をおこなっています。今後は動画やGIFなどいろいろな機能が追加されそうです。
ここまでくると外側からみたときの「ツブヤキ」という定義が広いことがわかると思います。
この問題を解決するにはツブヤキをさらに分解して「ひとつのこと」を行うように修正します。

const TextTweet = ({ text }: Props) => (
  <div>
    <p>{text}</p>
  </div>
)

const ImageTweet = ({ text, imgUrl }: Props) => (
  <div>
    <img src={imgUrl} />
    {text && <p>{text}</p>}
  </div>
)
const AudioTweet = ({ audioUrl }: Props) => (
  <audio controls>
    <source src={audioUrl} />
  </audio>
)

これでそれぞれのコンポーネントの見通しがよくなり、それぞれの拡張や修正を行いやすくなりました。
このようなコンポーネントの成長は徐々に行われていくものなので常に気を付けていないといけません。分解を考えるタイミングとしては以下があります。

  • ifが出てくる
  • propsが多い
    これらの兆候がある場合は1つのコンポーネントが複数のコンポーネントの役割を持っている可能性があるので注意が必要です。

補足:SRPの別解釈

この記事で説明した例以外にもSRPの解釈があります。
・コンポーネントや関数を変更する理由は一つだけであるべきだ
こちらのほうが理解しやすいという方がいるかもしれません。私はこの考え方も素晴らしいと考えています。以下の記事でその例を見ることができますので是非読んでみてください。
https://zenn.dev/koki_tech/articles/361bb8f2278764#✅-srp-(simple-responsibility-principle)

「ひとつのこと」や「変更する理由」については人それぞれ解釈があり、もっと細かく見るべきだ、細かすぎるなど意見が対立する可能性があります。プロジェクトの特性やチームメンバーの理解を考慮して最終的には全員の認識をしっかりと合わせる必要がありそうです。

最後に

次回は以下のいずれかの記事を作成します。

  • オープンクローズドの原則(OCP)
  • インターフェイス分離の原則(ISP)
  • 依存関係が逆転の原則(DIP)

Discussion