ドメインによるコンポーネント分割

2021/03/26

Atomic Design でコンポーネントの分割をした時に pages に依存する Organisms の置き場に困ったり、汎用的に作った Molecules が特定のドメイン操作に依存しすぎているせいで似たような物を再利用できなかったり抽象化に失敗しているケースがあった。サンプルに TODO リストを作ることでコンポーネントの責務分離の流れを掴めたのでここに記録する。アイディアを教えてくださった前職同僚氏に深く感謝する。

目次

コンポーネント分割

ドメインによる分割

動機

Atomic Design をやっているとよくある現象として Pages が肥大化して分割するケースがあるが、これを Organisms として分割するとしばしば置き場に困ったりする。 解決方法としては私が見たものは organisms/ にフラットに配置するケースと pages/ にディレクトリを作るケースがあった。 前者では切り出した Organisms がどの Pages 所属するかわからない。後者では Organisms が pages/ に含まれることになり Atomic Design が崩れてしまう。これを特定の pages に依存するコンポーネントと依存しないコンポーネントで分割することで解決する。

広域ドメインと局所ドメイン

pages に依存するコンポーネントを局所ドメインコンポーネント、依存しないコンポーネントを広域ドメインコンポーネントと呼ぶことにする。 広域ドメインコンポーネントには Atomic Design でいうところの Atoms や Molecules などが含まれる。複数の pages で共有されるコンポーネントなどもここに含まれる。局所ドメインコンポーネントは pages のコンポーネントやそれに依存するコンポーネントを配置する。Atomic Design でいうところの Pages や Organisms などが含まれる。

ディレクトリ構造

ディレクトリ構成は以下のようになる

components
  - Button
    - Button.tsx
    - index.tsx
  - Checkbox
    - Checkbox.tsx
    - index.tsx
  - TodoForm
    - index.tsx
layouts
  - default.tsx
pages
  - todo
    - TodoList
      - index.tsx
      - hooks.ts
    - index.tsx
    - hooks.ts

components は前述の広域ドメインコンポーネントに該当する。配下のディレクトリはコンポーネントを横並びで配置し Atoms や Molecules を区別しない。

pages は前述の局所ドメインコンポーネントに該当する。配下のディレクトリは Atomic Design と同様にページのコンポーネント(index.tsx など)と、ページに依存する各コンポーネントを配置する。

layouts は特定のページに依存しないヘッダーやサイドメニューなどの全体のレイアウトを提供するコンポーネントを格納する。

もし、今後広域ドメインコンポーネントになりそうだが現状 1 つのページからしか参照されないようなコンポーネントがあれば、一旦局所ドメインコンポーネントとして配置した後に再利用される時ディレクトリを移動すると良い。

局所ドメインと広域ドメイン

責務の分離

広域ドメインコンポーネントと局所ドメインコンポーネントの責務を分割することで、両コンポーネントの線引きを明らかにして再利用性の向上を図る。

広域ドメインコンポーネントはコンポーネントがもつ表示やフォームなどとの直接的なイベントのやり取りに責任を持つ。表示する値の加工やフォーム操作後の処理は呼び出し元が責任を持つため、広域ドメインコンポーネントは行わない。

局所ドメインコンポーネントはページが持つドメインの取得・操作・加工に責任を持ち、広域ドメインコンポーネントなどに加工した値やイベントハンドラを渡すことでページを組み立てを行う。

広域ドメインコンポーネント

広域ドメインコンポーネントでは Pages に依存しないコンポーネントを提供する。特定のドメイン操作等に依存しないようにコンポーネントが持つフォームなどのイベントハンドラなどを要求し、その処理を呼び出し元のコンポーネントに移譲する。

  • データの取得(fetch)や操作(useRedux)を行わない
  • onClick や onChange などのイベントハンドラの処理を参照元に移譲する
  • context を内部で使用しない
components/Button/index.tsx
import React, { FC } from "react"
import { Button as StyledButton } from "./Button"
import { ButtonText as StyledButtonText } from "./ButtonText"

interface ButtonProps {
  children?: string
  onClick: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void
}

const Button: FC<ButtonProps> = ({ children, onClick }) => {
  return (
    <StyledButton onClick={onClick}>
      <StyledButtonText>{children}</StyledButtonText>
    </StyledButton>
  )
}

export default Button
components/TodoForm/index.tsx
import React, { FC } from "react"
import Button from "components/Button"
import Checkbox from "components/Checkbox"
import Input from "components/Input"

interface TodoItem {
  id: string
  title: string
  done: boolean
  onChangeDone: (e: React.ChangeEvent<HTMLInputElement>) => void
  onChangeTitle: (e: React.ChangeEvent<HTMLInputElement>) => void
  onClickDelete: () => void
}

interface TodoFormProps {
  todo: TodoItem
}

const TodoForm: FC<TodoFormProps> = ({ todo }) => {
  const { title, done, onChangeDone, onChangeTitle, onClickDelete } = todo

  return (
    <div>
      <Checkbox onChange={onChangeDone} checked={done} />
      <Input onChange={onChangeTitle} value={title} />
      <Button onClick={onClickDelete}>削除</Button>
    </div>
  )
}

局所ドメインコンポーネント

局所ドメインコンポーネントでは Pages に依存するコンポーネントを提供する。ページが持つドメインの取得・操作・加工等はここで行い、広域ドメインコンポーネントなどに加工した値やイベントハンドラを渡すことでページを組み立てを行う。

肥大化した Pages コンポーネントは Organisms に分割してページのディレクトリ配下に配置する。Pages はページを構成するパーツ(Organisms)のレイアウトのみを提供する。各パーツへの props の受け渡しが複数階層にまたがる場合などは context を使用する。

Pages

Pages のコードはページのディレクトリ配下に index.tsx や hooks.ts を配置する。Pages ではできる限り Organisms の配置のみに留める。必要があれば Context を使用して配下のコンポーネントに state や dispatch などを引き渡す。

todo/index.tsx
import React, { FC } from "react"
import Buttons from "./Buttons"
import { TodoPageContext, useTodoPageContextValue } from "./hooks"
import TodoList from "./TodoList"

const TodoPage: FC<{}> = () => {
  const contextValues = useTodoPageContextValue()

  return (
    <TodoPageContext.Provider value={contextValues}>
      <div>
        <div>
          <Buttons />
        </div>
        <div>
          <TodoList />
        </div>
      </div>
    </TodoPageContext.Provider>
  )
}

export default TodoPage

Pages の hooks では useReducer の組み立てや Context の提供を行う

todo/hooks.tsx
import { createContext, useReducer, Dispatch } from "react"
import { request } from "requests"

interface TodoItem {
  id: string
  title: string
  done: boolean
}

interface State {
  todos: TodoItem[]
}

type Actions =
  | { type: "SET_TODOS"; payload: { todos: TodoItem[] } }
  | { type: "ADD_TODO"; payload: { todo: TodoItem } }
  | { type: "UPDATE_TODO"; payload: { todo: TodoItem } }
  | { type: "REMOVE_TODO"; payload: { id: string } }

const reducer = (state: State, action: Actions) => {...}

export const fetchTodoAsync = () => {...}
export const addTodoAsync = () => {...}
export const updateTodoAsync = () => {...}
export const deleteTodoAsync = () => {...}

const initialState: State = {
  todos: [],
}

export const useTodoPageContextValue = (values: State = initialState) => {
  const [state, dispatch] = useReducer(reducer, values)
  return { state, dispatch }
}

export const TodoPageContext = createContext<{
  state: State
  dispatch: (action: Actions) => void
}>({
  state: initialState,
  dispatch: () => {},
})

Organisms

Organisms のコードはページのディレクトリ配下にコンポーネントディレクトリを配置する。Organisms では広域コンポーネントへのイベントハンドラや値の注入を行う。

todo/TodoList/index.tsx
import React, { FC } from "react"
import TodoForm from "../TodoForm"
import { useTodoList } from "./hooks"

const TodoList: FC = () => {
  const { todos } = useTodoList()

  return (
    <>
      {todos.map(todo => (
        <TodoForm key={todo.id} todo={todo} />
      ))}
    </>
  )
}

export default TodoList
todo/TodoList/hooks.tsx
import { useContext, useEffect } from "react"
import {
  deleteTodoAsync,
  fetchTodoAsync,
  TodoPageContext,
  updateTodoAsync,
} from "../hooks"

export const useTodoList = () => {
  const { state, dispatch } = useContext(TodoPageContext)

  useEffect(() => {
    dispatch(fetchTodoAsync())
  }, [])

  return {
    todos: state.todos.map(todo => {
      const onChangeDone = (e: React.ChangeEvent<HTMLInputElement>) => {
        dispatch(updateTodoAsync({ todo: { ...todo, done: e.target.checked } }))
      }

      const onChangeTitle = (e: React.ChangeEvent<HTMLInputElement>) => {
        dispatch(updateTodoAsync({ todo: { ...todo, title: e.target.value } }))
      }

      const onClickDelete = () => {
        dispatch(deleteTodoAsync({ id: todo.id }))
      }

      return { ...todo, onChangeDone, onChangeTitle, onClickDelete }
    }),
  }
}

サンプルコード

wn-seko/react-sandbox に今回の TODO リストのサンプルコードを書いたので今回省略した部分のコードについてはこちらを参照。

まとめ

  • 特定のページに依存するコンポーネント(局所ドメインコンポーネント)と依存しないコンポーネント(広域ドメインコンポーネント)に分割する
  • 広域ドメインコンポーネントはコンポーネントがもつ表示やフォームなどとのイベントのやり取りに責任を持つ
  • 局所ドメインコンポーネントはページが持つドメインの取得・操作・加工に責任を持ち、ページを組み立てる

wn-seko

フロントエンドエンジニア

当サイトでは、Googleによるアクセス解析ツール「Googleアナリティクス」を使用しています。このGoogleアナリティクスはデータの収集のためにCookieを使用しています。このデータは匿名で収集されており、個人を特定するものではありません。この機能はCookieを無効にすることで収集を拒否することが出来ますので、お使いのブラウザの設定をご確認ください。この規約に関しての詳細はGoogleアナリティクスサービス利用規約のページやGoogleポリシーと規約ページをご覧ください。

© 2022 wn-seko, Built with Gatsby