はじめに
案件でNuxt3を使って開発をしているのだが、当案件でUIを作る時にアトミックデザインを使用している。
今まで何となく既存のデザインから傾向を読み取って開発を行なっていたが、よく考えればちゃんと公式ドキュメントを参照しながら座学で学んだ記憶があまりない…
”アトミックデザイン”とは何か。 分かったつもりの状態から脱却するために、改めてアウトプットを踏まえて復習してみる。
今回のゴール
- Atomic Design(アトミックデザイン)を”分かったつもり”から”ちゃんと理解する”へステップアップする
- Atomic Design(アトミックデザイン)でUIを作る時にやってはいけないタブーを知る
- 各コンポーネントの必要性について考察を行う
アトミックデザインとは
Udemyメディアに記載がありました。
アトミックデザインは、UIの構成要素を5つのコンポーネントに分解し、順番に設計していく手法です。小さな要素から順番に組み合わせて、より大きなコンポーネントを作ることで、WebサイトのUIが完成します。
アトミックデザインを考えたBrat Frostさんはアトミックデザインを下記のように記載しています。(日本語訳は割愛)
Atomic design is a methodology composed of five distinct stages working together to create interface design systems in a more deliberate and hierarchical manner.
まとめると、5つの要素であるAtoms, Molecules, Organisms, Templates, Pagesのコンポーネントに分割をして、順番に設計を行なっていく手法とのこと。
コンポーネントについて
Atoms
https://atomicdesign.bradfrost.com/images/content/atomic-design-atoms.png
まずは、アトミックデザインで開発を行う場合の、最小単位となるAtomsについて。
これ以上分解できない基本的な要素で、UIを構成するための基礎となる。
https://atomicdesign.bradfrost.com/images/content/atoms-form-elements.png
具体的には、以下のようなものがAtomsに該当する。
- ボタン(Button): クリック可能な要素
- 入力フィールド(Input Field): ユーザーがテキストを入力するためのフィールド
- ラベル(Label): テキストを表示するための要素
- カラー(Color): 色の定義
- フォント(Font): テキストのスタイルやサイズ
Atomsを作成することによって、非常にシンプルで汎用性が高く、基本的な要素を統一することによるデザイン全体に一貫性を持たせることができる。
また、Atomsは小さな部品なので、要件ごとに修正や更新が容易にできるというメリットがある。
Molecules
https://atomicdesign.bradfrost.com/images/content/atomic-design-molecules.png
次に、Atomsを組み合わせたUI群のMoleculesについて。
Moleculesは、単一の機能を提供するために設計されており、Atomsよりも少し複雑だが、まだ比較的シンプルな構造を持っているのが特徴。
Atomsはあくまでも最小のUIコンポーネントで、Moleculesはそれらを組み合わせて単一の機能を提供するという役割がある。
https://atomicdesign.bradfrost.com/images/content/molecule-search-form.png
具体的には、以下のようなものがMoleculesに該当する。
- 検索フォーム(Search Form): 入力フィールド(Input Field)、ラベル(Label)、ボタン(Button)を組み合わせたもの
- カード(Card): 画像(Image)、タイトル(Title)、テキスト(Text)を組み合わせたもの
- ナビゲーションメニュー(Navigation Menu): リンク(Link)やアイコン(Icon)を組み合わせたもの
MoleculesはAtomsを組み合わせて作られるため、より具体的な機能を持つコンポーネントとなる。
これにより、UIの一部として再利用しやすくなり、複数の場所で同じMoleculeを使用することで、一貫性のあるデザインを実現させることができる。
また、Moleculesは特定の機能を持つため、様々な場所で再利用させることができるというメリットが存在する。
Organisms
https://atomicdesign.bradfrost.com/images/content/atomic-design-organisms.png
次は、AtomsやMoleculesを組み合わせて作られるOrganismsについて。
Organismsは、独立して機能することができ、他のページやコンポーネントでも同じ意図で再利用することが可能という特徴がある。
https://atomicdesign.bradfrost.com/images/content/organism-header.png
具体的には、以下のようなものがOrganismsに該当する。
- ヘッダー(Header): ロゴ(Atom)、ナビゲーションメニュー(Molecule)、検索ボックス(Molecule)を組み合わせたもの
- カードリスト(Card List): 複数のカード(Molecule)をリスト形式で表示するもの
- サイドバー(Sidebar): メニューアイテム(Atom)やサブメニュー(Molecule)を含むナビゲーションバー
Organismsは、AtomsやMoleculesを組み合わせて、より具体的で複雑な機能を提供するコンポーネントとなる。
これにより、UIの一部として再利用しやすくなり、複数の場所で同じOrganismを使用することで、一貫性のあるデザインを実現させることが可能になる。
また、Organismsは特定の機能を持つため、様々な場所で再利用させることができるというメリットがある。
Templates
https://atomicdesign.bradfrost.com/images/content/atomic-design-templates.png!
次はAtoms(原子)、Molecules(分子)、Organisms(有機体)を組み合わせて作られるページのレイアウトや構造を定義するTemplatesについて。
Templatesは具体的なコンテンツを持たず、ページの骨格やデザインの枠組みを提供する。
https://atomicdesign.bradfrost.com/images/content/template.png
具体的には、以下のようなものがTemplatesに該当する。
- ブログのテンプレート: ヘッダー、サイドバー、記事リスト、フッターなどの配置を定義
- 商品ページのテンプレート: 商品画像、商品説明、価格、レビューセクションなどの配置を定義
- ダッシュボードのテンプレート: ナビゲーションバー、ウィジェットエリア、統計情報の配置を定義
Templatesの主な役割は、ページ全体のレイアウトを定義し、異なるコンテンツを持つページでも一貫したデザインを維持すること。
具体的なコンテンツが入る前の状態で、ページの構造や配置を決めるためのテンプレートとして機能する役割がある。
異なるページでも同じテンプレートを使用することで、デザインの一貫性を保つことができるため、一貫性の確保をすることができるメリットをもつ。
Pages
https://atomicdesign.bradfrost.com/images/content/atomic-design-pages.png
最後にPagesについて。
Pagesは、アトミックデザインの最上位の階層で、実際のユーザーインターフェースの完成形を表す。
具体的には、ユーザーが最終的に目にするウェブページやアプリの画面そのものになる。
https://atomicdesign.bradfrost.com/images/content/page.png
例えば、eコマースサイトの「商品詳細ページ」を考えてみる。このページには、以下のような要素が含まれまる。
- ヘッダー(ナビゲーションバーやロゴ)
- 商品画像(ギャラリー)
- 商品説明(テキストブロック)
- 価格情報(テキストとボタン)
- レビューセクション(ユーザーレビューのリスト)
これらの要素が組み合わさって、1つの「商品詳細ページ」が完成する。
Pagesを実装することにより、要素ごとに分けて管理するため、変更や修正が容易であり、かつ新しい機能やページを追加する際に、既存の要素を活用できるため、スケーラブルなデザインが可能であるというメリットがある。
「各コンポーネントはステートレスに」というルールを深掘りする
次に、ステートレスコンポーネントの重要性について記載をしていく。
「ステートレスなコンポーネント」とは何かというと、「状態や特定の機能というのは”親”に持たせる」ということ。
コンポーネントをステートレスに保つことで、以下のメリットを享受できる。
-
再利用性の向上
ステートレスコンポーネントは、特定の状態に依存しないため、様々な場所で再利用しやすくなる。例えば、ボタンコンポーネントがクリックされた回数を内部で管理するのではなく、親コンポーネントからその情報を受け取るようにすることで、同じボタンコンポーネントを異なるシナリオで使い回すことが可能になる。
-
テストの容易さ
ステートレスコンポーネントは、外部からデータを受け取るだけなので、テストが簡単になる。テスト時に特定の状態をシミュレートするために、必要なデータを直接渡すことができるため、テストケースの作成が容易になる。
-
予測可能な動作
ステートレスコンポーネントは、受け取ったデータに基づいてレンダリングを行うため、その動作が予測しやすくなる。これにより、バグの発生を減らし、デバッグが容易になる。
-
責任の分離
ステートレスコンポーネントは、見た目や表示に関する責任のみを持ち、状態管理やビジネスロジックは親コンポーネントや他の専用のコンポーネントに任せることで、コードの責任範囲が明確になる。これにより、コードの可読性とメンテナンス性が向上する。
メリットが分かったところで、実際に親と子の2層で簡単な例を書いてみた。
使っている人が多数いるであろうNuxt3にてコーディングを行なった。
ステートフルなアプリケーション(NGパターン)
まずは、子のコンポーネントから。
<script setup lang="ts">
import { defineComponent, ref } from 'vue'
const count = ref(0)
const increment = () => {
count.value += 1
}
</script>
<template>
<button @click="increment">
Clicked {{ count }} times
</button>
</template>
次に親のコンポーネントについて。
<script setup lang="ts">
import { defineComponent } from 'vue'
import CounterButton from '~/components/CounterButton.vue'
</script>
<template>
<div>
<CounterButton />
</div>
</template>
このように、親コンポーネントは単にCounterButtonコンポーネントをレンダリングするだけの処理になっている。
CounterButtonコンポーネントはステートフルなコンポーネントであり、内部で状態を管理している。
このようなステートフルなコンポーネントは、特定のシナリオでは便利であるが、再利用性、テストの容易さ、予測可能性、メンテナンス性といった点でデメリットがある。
特に、テストの記述の安易性と正確性に関しては、TDDで開発を行う面でも大切な側面である。
試しに上記のアプリに対してテストコードも書いてみる。着眼点として、下方にある『ステートフルなカウンターボタンのテスト』に着眼してほしい。
import { mount } from '@vue/test-utils'
import { describe, it, expect } from 'vitest'
import Index from '~/pages/Index.vue'
import CounterButton from '~/components/CounterButton.vue'
describe('Index.vue', () => {
it('renders CounterButton component', () => {
const wrapper = mount(Index)
expect(wrapper.findComponent(CounterButton).exists()).toBe(true)
})
it('increments count when button is clicked', async () => {
const wrapper = mount(Index)
const button = wrapper.find('button')
await button.trigger('click')
expect(button.text()).toContain('Clicked 1 times')
})
// ステートフルなカウンターボタンのテスト
it('increments count when button is clicked multiple times', async () => {
const wrapper = mount(CounterButton)
const button = wrapper.find('button')
// ボタンを3回クリックして状態を変更
await button.trigger('click')
await button.trigger('click')
await button.trigger('click')
expect(button.text()).toContain('Clicked 3 times')
})
})
このように、ステートフルなアプリでは特定の状態を再現するために複数のアクションを実行する必要があり、テストが複雑になる。
これらの理由から、アトミックデザインにおいては、可能な限りコンポーネントをステートレスに保つことが推奨されるのだ。
ステートフルなアプリのデメリットが分かったところで、次に、OKパターンとして、ステートレスなアプリを見ていく。
ステートレスなアプリケーション(OKパターン)
まずは、子のコンポーネントから。
<script setup lang="ts">
import { defineProps } from 'vue'
const props = defineProps<{
count: number
onClick: () => void
}>()
</script>
<template>
<button @click="props.onClick">
Clicked {{ props.count }} times
</button>
</template>
次に親のコンポーネントについて。
<script setup lang="ts">
import { ref } from 'vue'
import CounterButton from '~/components/CounterButton.vue'
const count = ref(0)
const handleClick = () => {
count.value += 1
}
</script>
<template>
<div>
<CounterButton :count="count" :onClick="handleClick" />
</div>
</template>
CounterButtonコンポーネントは、内部で状態(state)を管理していない。代わりに、countとonClickというプロパティを外部から受け取っている。これにより、内部状態を持たない実装がされていることがわかる。
このように、ステートレスなコンポーネント間で特定のビジネスロジックやデータを外部から渡すプロセスをドメインの注入と言う。基本的に、アトミックデザインでは、このドメインの注入により子コンポーネントを親で呼び出し、親の中で特定の機能を持たせる。
今回の例では、CounterButton
は表示するカウント数を親コンポーネントから受け取った count
プロパティを使って表示している。クリックイベントも親コンポーネントからドメインが注入されて渡された onClick
プロパティを使用して処理している。これにより、データが外部から渡ってくる実装がなされていることがわかる。
そして、実際にテストコードも書いてみた。
import { mount } from '@vue/test-utils'
import { describe, it, expect } from 'vitest'
import Index from '~/pages/index.vue'
import CounterButton from '~/components/CounterButton.vue'
describe('Index.vue', () => {
it('renders CounterButton component', () => {
const wrapper = mount(Index)
expect(wrapper.findComponent(CounterButton).exists()).toBe(true)
})
it('increments count when button is clicked', async () => {
const wrapper = mount(Index)
const button = wrapper.find('button')
await button.trigger('click')
expect(button.text()).toContain('Clicked 1 times')
})
// ステートレスなカウンターボタンのテスト
it('renders with the correct count', () => {
const wrapper = mount(CounterButton, {
props: {
count: 3,
onClick: () => {}
}
})
expect(wrapper.text()).toContain('Clicked 3 times')
})
})
いかがだろうか。
先ほどのテストコードと比較して、3回ボタンを押すことなくテストを実行している。
単純にprops
にデータを記述するだけで、3回ボタンが押された処理を書くことができるのだ。
以上のことから、ステートフルなコンポーネントは特定のシナリオでは便利であるが、再利用性、テストの容易さ、予測可能性、メンテナンス性といった点でデメリットがある。
これらの理由から、アトミックデザインでUIを作成する際には、可能な限りコンポーネントをステートレスにすることが重要である。
まとめ
アトミックデザインの概要をまとめてみて、改めて気付かされる部分が多数あったが、皆さんはいかがだろうか。
特に、私としてはステートをあまり考えずに今まで実装を行なっていたので、正直良い反省として活かしていきたい。
以下、アトミックデザインの各役割をまとめてこの記事を終わりとする。
https://atomicdesign.bradfrost.com/images/content/instagram-atomic.png
Atoms
- UIの最小単位で、これ以上分解できない基本的な要素
- 特定の機能をここでは持たせることは非推奨
- 基本的に状態を持たないが、”ボタンの「押された」状態”等の非常にシンプルな状態のみを管理する
Molecules
- 複数のAtomsを組み合わせてできる、意味を持つ小さなコンポーネント
- 初めてここで機能を搭載することができるが、まだ限定的な小さな機能しか実装できない
- "検索バーの入力値"等の最小限に抑えられた状態を管理する
Organisms
- 複数のMoleculesやAtomsを組み合わせてできる、より複雑で独立した機能を持つコンポーネント
- UIとしてページ全体に関わる主要な部分を構成する
- ページ特有の機能を実装し、ページ全体のレイアウトに影響する実装をすることができる
- MoleculesやAtomsの複数の要素間での状態管理を担当する
Templates
- ページ全体の骨組みを定義するレイアウト
- OrganismsやMoleculesを配置してページの構造を定義する
- 基本的には状態を持たない
- コンテンツを含ませることができず、レイアウトのガイドラインを作成する
Pages
- 実際のコンテンツを含む、ユーザーが見る最終的なページ
- Templatesに具体的なコンテンツを追加することで、ページが完成する
- 最終的なUIの形態であり、ユーザーが直接操作する部分となる
- ユーザーの認証状態、アプリケーション全体のデータフェッチング状態などのアプリケーション全体の状態を管理する
アトミックデザインのタブーである「ステートフルな実装」は控えながら、ステートレスで良い部品構成からなるアプリケーションを作っていきましょう。
ご覧いただきありがとうございました。
参考文献
https://atomicdesign.bradfrost.com/chapter-2/
https://udemy.benesse.co.jp/design/web-design/atomic-design.html
https://blog.brainpad.co.jp/entry/2021/07/02/103000