"Applicational Atomic Design"について
Brad Frostが提唱したAtomic Designはすでに多くの場面で採用されていますが、もともとがUIデザイン手法であるため、フロントエンドアプリケーション開発におけるコンポーネントの分類に直接的に適用できないものも出てきます。
そこで、最近採用している"Applicational Atomic Design"について具体的なコードとともに紹介していきます。
Reactを前提として説明していきますが、VueにはもちろんAngularを用いたプロジェクトに対しても、同様の手法を適用することができるでしょう。
紹介する方法論をnext.jsに適用したアプリケーションのソースコードは、GitHubに公開しています。
"Applicational Atomic Design"とは?
Atomic Designに基づきつつ、アプリケーションの振る舞いやドメインモデルとの関わりを視点に加え、フロントエンドアプリケーション開発に適応するように整理した方法論です。
"Applicational Atomic Design"の名前は、従来のAtomic Designとの違いを明確にする目的で命名しています。
Atoms
この分類の定義は元の定義と同様に、これ以上分けられないような単位のUIです。
サンプルアプリケーションでは、プロジェクト固有のスタイルが適用されたHTMLタグのラッパーコンポーネントとして定義しています。
import { DetailedHTMLProps, InputHTMLAttributes, forwardRef } from 'react'
import clsx from 'clsx'
import { focusClasses } from '../../styles/class-values'
export type TextFieldProps = DetailedHTMLProps<
InputHTMLAttributes<HTMLInputElement>,
HTMLInputElement
> & {
block?: boolean
error?: boolean
}
const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
({ block, error, ...props }, ref) => {
return (
<input
{...props}
ref={ref}
className={clsx(
'h-9',
'px-2',
'border',
'border-gray-300',
'rounded-md',
// transition
'transition',
// hover
'hover:border-indigo-500',
// focus
focusClasses,
// disabled
'disabled:text-gray-600',
'disabled:bg-gray-50',
// block
block && 'block',
block && 'w-full',
// error
error && 'border-red-500'
)}
/>
)
}
)
TextField.displayName = 'TextField'
export default TextField
基本的なUIコンポーネントを提供するUIフレームワークを使用してる場合、このカテゴリに分類されるコンポーネントを作成することは基本的にないでしょう。
Molecules
元の定義に加えて、次の性質を持つコンポーネントをmoleculesに分類しています。
- アプリケーション固有の情報を与えられるatomコンポーネントのラッパー(例えば特定の型やインタフェースを受け入れるテキストやSelectなど)
- アプリケーションの動作としてカプセル化するべきコンポーネント(与えられたエンティティの値を変更するためのフォームなど)
この方法論では、アプリケーションとしての動作とドメインモデルとの関係性を、レンダリングされた際のUIの大きさや再利用性よりも重視します。
import { VFC, useMemo } from 'react'
import { TaskStatus, TASK_STATUSES } from '../../../interfaces'
export interface TaskStatusTextProps {
status: TaskStatus
}
const TaskStatusText: VFC<TaskStatusTextProps> = ({ status }) => {
const text = useMemo(() => {
const kv = Object.entries(TASK_STATUSES).find((kv) => kv[1] === status)
if (!kv) {
return 'Unknown'
}
return kv[0]
}, [status])
return <>{text}</>
}
export default TaskStatusText
例えば上記の例だと、スタイルの適用されないテキストを返すコンポーネントですが、TaskStatusというプロジェクト固有の型を受け付けるため、moleculesに分類されます。
また、この分類で重要なことは、コンテキストなどのグローバルな値を参照・更新しないということです。
Organisms
元のmoleculesやatomsを利用した比較的複雑なセクションを提供するコンポーネントに加え、グローバルの値を参照・更新し、それ自体がウィジェットのように動作するコンポーネントはここに分類されます。
moleculesとの大きな違いは、コンポーネントがグローバルな値を参照・更新するかどうかです。
import { VFC, useCallback } from 'react'
import { useListTask } from '../../../hooks/api/tasks'
import ButtonSecondary from '../../../atoms/ButtonSecondary'
import TaskTable from '../../../molecules/tasks/TaskTable'
const ConnectedTaskTable: VFC = () => {
const { data, error, mutate } = useListTask()
const onRetryButtonClick = useCallback(() => {
mutate()
}, [mutate])
if (error) {
return (
<div>
<p className="mb-2">Error occured</p>
<ButtonSecondary onClick={onRetryButtonClick}>Retry</ButtonSecondary>
</div>
)
}
if (!data) {
return (
<div>
<p>Loading...</p>
</div>
)
}
return <TaskTable tasks={data} />
}
export default ConnectedTaskTable
そのため、上記のようなmoleculeをラップしてコンテキストに接続するだけのコンポーネントや、SignOutButtonのように、例え小さくてもグローバルな値を変更するコンポーネントはorganismsに分類されます。
Templates
元の定義と同様に、organismsやmolecules、atomsを具体的に配置するコンポーネントが分類され、next.jsやgatsby.jsでは値が注入されることで一つのページとしてレンダリングされるコンポーネントです。
内包するorganismsに属する構成要素がグローバルの値に変更を行った後に、必要であればユーザに対するフィードバックを提供したり、画面の遷移を行う責務を負います。
対して、注入される値が存在しない場合や、ユーザにその画面にアクセスする権限が存在しない場合のハンドリングなどは、templatesコンポーネントの責務ではなくpagesコンポーネントの責務となります。
Pages
バックエンドから取得した特定の値をテンプレートに対して与え、またユーザの役割に応じて表示するテンプレートを選択するコンポーネントです。
これらのコンポーネントのコードは、使用するフレームワークや状況によって大きく異なります。
例えば、next.jsを使用している場合はpages
ディレクトリに配置されているファイルであり、gatsby.jsを使用している場合は、gatsby-node.js
で記述された手続き型のコードである可能性があります。
import { NextPage, GetServerSideProps } from 'next'
import ErrorPage from 'next/error'
import { ParsedUrlQuery } from 'node:querystring'
import { useReadTask } from '../../../hooks/api/tasks'
import PrivateRoot from '../../../components/PrivateRoot'
import ReadTemplate from '../../../templates/tasks/ReadTemplate'
import EditTemplate from '../../../templates/tasks/EditTemplate'
import LoadingTemplate from '../../../templates/LoadingTemplate'
interface PageQuery extends ParsedUrlQuery {
task_id: string
edit?: string
}
function isPageQuery(query: ParsedUrlQuery): asserts query is PageQuery {
if (typeof query['task_id'] !== 'string') {
throw new Error()
}
if (
typeof query['edit'] !== 'undefined' &&
(typeof query['edit'] !== 'string' || query['edit'] !== 'true')
) {
throw new Error()
}
}
interface PageProps {
query: PageQuery
}
export const getServerSideProps: GetServerSideProps<
PageProps,
PageQuery
> = async (context) => {
const { query } = context
isPageQuery(query)
return {
props: { query },
}
}
const ReadPage: NextPage<PageProps> = ({ query }) => {
const { task_id, edit } = query
const { data, error } = useReadTask({ id: task_id })
return (
<PrivateRoot>
{(() => {
if (error) {
return <ErrorPage statusCode={404} />
}
if (!data) {
return <LoadingTemplate />
}
if (edit === 'true') {
return <EditTemplate task={data} />
} else {
return <ReadTemplate task={data} />
}
})()}
</PrivateRoot>
)
}
export default ReadPage
上記は特定のタスクを閲覧・編集するpageコンポーネントの実装ですが、パスパラメータから該当するエンティティの取得、取得中であることをフィードバックするテンプレートのレンダリング、モードに応じたテンプレートの選択を行っています。
Components
これまでのカテゴリに当てはまらないコンポーネントが分類されます。
import { VFC, ReactNode } from 'react'
import { useRouter } from 'next/router'
import ErrorPage from 'next/error'
import { useAuthContext } from '../../contexts/AuthContext'
import { useIsBrowser } from '../../hooks/env'
import LoadingTemplate from '../../templates/LoadingTemplate'
export interface PrivateRootProps {
children: ReactNode
checkIsAccessible?: () => boolean
}
const PrivateRoot: VFC<PrivateRootProps> = ({
children,
checkIsAccessible,
}) => {
const router = useRouter()
const { isSignedIn } = useAuthContext()
const isBrowser = useIsBrowser()
if (!isBrowser) {
return <LoadingTemplate />
}
if (!isSignedIn) {
const signInUrl =
router.asPath === '/' ? '/signin' : `/signin?redirect=${router.asPath}`
router.push(signInUrl)
return <LoadingTemplate />
}
if (checkIsAccessible) {
if (!checkIsAccessible()) {
return <ErrorPage statusCode={403} />
}
}
return <>{children}</>
}
export default PrivateRoot
例えば上記のように、templateコンポーネントをラップし、ユーザのサインイン状態やロールに基づいてレンダリングを制御し、必要であればサインインページにリダイレクトするようなコンポーネントは、このカテゴリに分類されます。
以上が"Applicational Atomic Design"の紹介になりますが、それを実際のアプリケーション開発に適応する際のテクニックについて、いくつか紹介します。
"Applicational Atomic Design"適用時の実装テクニック
ディレクトリ構成と命名
atoms
、molecules
、organisms
、templates
、pages
、components
のように、分類に対応したディレクトリを作成することを推奨します。
atoms
ディレクトリに配置されるコンポーネントは汎用的なものであるため、サブディレクトリの命名が難しいのですが、headings
やforms
のように、利用が想定される場面で分類しても良いでしょう。
molecules
やorganisms
は汎用的なコンポーネントと特定のドメインモデルに依存するコンポーネントが配置されます。汎用的なコンポーネントはcommons
、ドメインモデルに依存するものはドメインモデルの名前のサブディレクトリ作成することで、分類が容易になります。
templates
はURLのパスにそったサブディレクトリやコンポーネントの命名がわかりやすいでしょう。/resources/[resource_id]/edit
の場合はresources/EditTemplate
、/resources/[resource_id]/sub-resources/create
の場合はresources/sub-resources/CreateTemplate
といった構成となります。
templatesコンポーネントを呼び出す場面は非常に局所的であるため、ListTemplate
やReadTemplate
など、その範囲内で識別可能なシンプルな命名で問題ないと考えています。
リソース操作の呼び方
templatesコンポーネントの命名にも関係しているのですが、REST APIはURLとHTTPメソッドの組み合わせでリソースに対する操作を行うので、その操作をHTTPメソッドだけで表現すると混乱が生じます。
それを避けるため、私は以下のような呼び方でその操作を識別しています。
-
[GET] /resources
- List
-
[POST] /resources
- Create
-
[GET] /resources/[resource_id]
- Read
-
[POST] /resources/[resource_id]
- Update
-
[PATCH] /resources/[resource_id]
- Patch
-
[DELETE] /resources/[resource_id]
- Delete
-
[GET] /resources/[resource_id]/versions
- VList
-
[GET] /resources/[resource_id]/versions/[version_id]
- VRead
バージョンを扱うことはあまりないのですが、一応定義しています。
これらの呼び方は、チーム全体で統一されており齟齬が生じなければ問題ないと考えるため、例えばrailsのコントローラのメソッド名を借用しても良いでしょう。
handlerの命名
moleculesやatomsのインタラクティブなコンポーネントは、イベントが発生したことを上位のコンポーネントに伝えるためのhandlerをpropsで受け取る必要があります。
これらのコンポーネントで発生するイベントは基本的に同期的であり、発生したイベントをどのように処理するかは上位のコンポーネントの責務であるため、onClick
、onChange
のようなシンプルな命名となるはずです。
例外としてあげられるのは、ダイアログやツールチップなどの閉じることを要求するイベントハンドラです。onClose
ではそのコンポーネントが閉じた際に発生するイベントを示すこととなるため、命名と挙動に乖離が生じます。onRequireClosing
といったように、閉じることを要求されたという命名にすることで、それを防ぐことができます。
organismsは基本的に単独で機能するコンポーネントであるため、イベントの発生を上位コンポーネントに伝えるhandlerを持つことは必須ではありません。
ただ、作成・編集フォームなどは、完了・失敗時のハンドリングを上位コンポーネントで行う必要がある場合があります。その場合はonSubmitSuccess
やonSubmitFailure
などの命名が適切でしょう。
react-hook-formの利用
react-hook-formを用いることで、フォームの責務をmoleclesとorganismsに適切に分類することが容易になります。
サンプルプロジェクトでは、taskエンティティの値を変更するTaskFormBody
をmoleculesとして定義し、taskを作成するCreateTaskForm
とtaskを更新するEditTaskForm
をorganismsに定義しています。
import { VFC, useMemo, useCallback } from 'react'
import { useForm, SubmitHandler } from 'react-hook-form'
import { Task } from '../../../interfaces'
import { useUpdateTask } from '../../../hooks/api/tasks'
import ButtonPrimary from '../../../atoms/ButtonPrimary'
import TaskFormBody, {
TaskFormValues,
} from '../../../molecules/tasks/TaskFormBody'
export interface EditTaskFormProps {
task: Task
formId: string
onSubmitSuccess: () => void
onSubmitFailure: (error: Error) => void
}
const EditTaskForm: VFC<EditTaskFormProps> = ({
task,
formId,
onSubmitSuccess,
onSubmitFailure,
}) => {
const useFormMethods = useForm<TaskFormValues>({
defaultValues: task,
})
const { handleSubmit, watch } = useFormMethods
const watchedValues = watch()
const { isValidating, updateTask } = useUpdateTask()
const isSubmitButtonDisabled = useMemo(() => {
if (
typeof watchedValues.name === 'undefined' ||
typeof watchedValues.description === 'undefined' ||
typeof watchedValues.deadline === 'undefined' ||
typeof watchedValues.status === 'undefined'
) {
return true
}
if (watchedValues.name.length === 0) {
return true
}
if (isValidating) {
return true
}
return false
}, [watchedValues, isValidating])
const onFormSubmit = useCallback<SubmitHandler<TaskFormValues>>(
(values) => {
;(async () => {
if (
typeof values.name === 'undefined' ||
typeof values.description === 'undefined' ||
typeof values.deadline === 'undefined' ||
typeof values.status === 'undefined'
) {
return
}
const paramsValues = {
name: values.name,
description:
values.description.length > 0 ? values.description : undefined,
deadline: values.deadline.length > 0 ? values.deadline : undefined,
status: values.status,
}
const { error } = await updateTask({
id: task.id,
values: paramsValues,
})
if (error) {
onSubmitFailure(error)
return
}
onSubmitSuccess()
})()
},
[task, onSubmitSuccess, onSubmitFailure, updateTask]
)
return (
<form id={formId} onSubmit={handleSubmit(onFormSubmit)}>
<div className="mb-5">
<TaskFormBody formId={formId} useFormMethods={useFormMethods} />
</div>
<div>
<ButtonPrimary type="submit" disabled={isSubmitButtonDisabled}>
Update
</ButtonPrimary>
</div>
</form>
)
}
export default EditTaskForm
上記はEditTaskForm
のコードですが、TaskFormBody
に対して直接値やハンドラを設定するのではなく、useForm<TaskFormValues>()
のメソッドを介して接続しています。
import { VFC, useMemo } from 'react'
import { UseFormMethods } from 'react-hook-form'
import { TaskStatus } from '../../../interfaces'
import Label from '../../../atoms/Label'
import TextFiled from '../../../atoms/TextField'
import TextArea from '../../../atoms/TextArea'
import TaskStatusSelect from '../TaskStatusSelect'
export interface TaskFormBodyProps {
formId: string
useFormMethods: UseFormMethods<TaskFormValues>
}
export interface TaskFormValues {
name?: string
description?: string
deadline?: string
status?: TaskStatus
}
const TaskFormBody: VFC<TaskFormBodyProps> = ({ formId, useFormMethods }) => {
const inputIds = useMemo(() => {
return {
name: `${formId}_name`,
description: `${formId}_description`,
deadline: `${formId}_deadline`,
status: `${formId}_status`,
}
}, [formId])
const { register, errors } = useFormMethods
return (
<div>
<div className="mb-3">
<div className="mb-1">
<Label htmlFor={inputIds.name}>Name</Label>
</div>
<div>
<TextFiled
type="text"
name="name"
placeholder="Task name"
id={inputIds.name}
form={formId}
block={true}
error={!!errors.name}
ref={register({ required: true })}
/>
</div>
</div>
<div className="mb-3">
<div className="mb-1">
<Label htmlFor={inputIds.description}>Description</Label>
</div>
<div>
<TextArea
name="description"
placeholder="Description"
rows={3}
id={inputIds.description}
form={formId}
block={true}
error={!!errors.description}
ref={register}
/>
</div>
</div>
<div className="mb-3">
<div className="mb-1">
<Label htmlFor={inputIds.deadline}>Deadline</Label>
</div>
<div>
<TextFiled
type="date"
name="deadline"
placeholder="Deadline"
id={inputIds.deadline}
form={formId}
block={true}
error={!!errors.deadline}
ref={register({ pattern: /\d{4}-\d{2}-\d{2}/ })}
/>
</div>
</div>
<div>
<div className="mb-1">
<Label htmlFor={inputIds.status}>Status</Label>
</div>
<div>
<TaskStatusSelect
name="status"
id={inputIds.status}
form={formId}
block={true}
error={!!errors.status}
ref={register({ required: true })}
/>
</div>
</div>
</div>
)
}
export default TaskFormBody
対して、TaskFormBody
は渡されたuseForm<TaskFormValues>()
のメソッドをTextFieldなどに接続し、値を変更する機能を具体的に提供しています。
入力された値の検証やエラー時のユーザへのフィードバックなどの実装はそれなりに複雑で、ともすれば作成用と更新用でほとんど同じ内容のコンポーネントを作成していることもあるフォームですが、react-hook-formを用いることで、シンプルな記述で責務の分離を行うことが可能となります。
以上が、"Applicational Atomic Design"の紹介と、それを実際にアプリケーションに適用する際の実装テクニックでした。
もちろん、この今回紹介した方法論やテクニックは他のものと同様に絶対的ではなく、アプリケーションの種類やチーム構成に応じてより良い方法が存在することも当然あるでしょう。
ただ、私がいくつかアプリケーションを開発する上でそれなりにうまく行っている方法論であり、この紹介が誰かの役に立つことがあれば幸いです。
Discussion