🗺️

"Applicational Atomic Design"について

2021/03/28に公開

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"適用時の実装テクニック

ディレクトリ構成と命名

atomsmoleculesorganismstemplatespagescomponentsのように、分類に対応したディレクトリを作成することを推奨します。
atomsディレクトリに配置されるコンポーネントは汎用的なものであるため、サブディレクトリの命名が難しいのですが、headingsformsのように、利用が想定される場面で分類しても良いでしょう。
moleculesorganismsは汎用的なコンポーネントと特定のドメインモデルに依存するコンポーネントが配置されます。汎用的なコンポーネントはcommons、ドメインモデルに依存するものはドメインモデルの名前のサブディレクトリ作成することで、分類が容易になります。
templatesはURLのパスにそったサブディレクトリやコンポーネントの命名がわかりやすいでしょう。/resources/[resource_id]/editの場合はresources/EditTemplate/resources/[resource_id]/sub-resources/createの場合はresources/sub-resources/CreateTemplateといった構成となります。
templatesコンポーネントを呼び出す場面は非常に局所的であるため、ListTemplateReadTemplateなど、その範囲内で識別可能なシンプルな命名で問題ないと考えています。

リソース操作の呼び方

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で受け取る必要があります。
これらのコンポーネントで発生するイベントは基本的に同期的であり、発生したイベントをどのように処理するかは上位のコンポーネントの責務であるため、onClickonChangeのようなシンプルな命名となるはずです。
例外としてあげられるのは、ダイアログやツールチップなどの閉じることを要求するイベントハンドラです。onCloseではそのコンポーネントが閉じた際に発生するイベントを示すこととなるため、命名と挙動に乖離が生じます。onRequireClosingといったように、閉じることを要求されたという命名にすることで、それを防ぐことができます。
organismsは基本的に単独で機能するコンポーネントであるため、イベントの発生を上位コンポーネントに伝えるhandlerを持つことは必須ではありません。
ただ、作成・編集フォームなどは、完了・失敗時のハンドリングを上位コンポーネントで行う必要がある場合があります。その場合はonSubmitSuccessonSubmitFailureなどの命名が適切でしょう。

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