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 を内部で使用しない
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
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 などを引き渡す。
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 の提供を行う
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 では広域コンポーネントへのイベントハンドラや値の注入を行う。
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
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 リストのサンプルコードを書いたので今回省略した部分のコードについてはこちらを参照。
まとめ
- 特定のページに依存するコンポーネント(局所ドメインコンポーネント)と依存しないコンポーネント(広域ドメインコンポーネント)に分割する
- 広域ドメインコンポーネントはコンポーネントがもつ表示やフォームなどとのイベントのやり取りに責任を持つ
- 局所ドメインコンポーネントはページが持つドメインの取得・操作・加工に責任を持ち、ページを組み立てる