# コンポーネント
この章では、コンポーネントの設計パターンについて見ていきます。コンポーネントにはUIの表示以外にも、ビジネスロジックや非同期処理、ライフサイクルなど、様々な処理が含まれます。これらの処理が一つのコンポーネントに詰め込まれると、コードが肥大化してメンテナンス性が著しく低下します。コンポーネントの設計は、適切な粒度でコンポーネントを分けること、UIとロジックを分けること、再利用性を高めることが重要になります。具体的には、Container/Presentationalパターンを使ったロジックの切り離しや、Atomic Designでのコンポーネント設計を見ていきたいと思います。
# Container/Presentational
Container/Presentationalパターンは、ContainerとPresentationalという二つのコンポーネントに分割して、コンポーネントのロジックをUIから分離するための設計パターンです。 Container/Presentationalパターンは、React Hooksが導入される前に提案 (opens new window)されたコンポーネントの設計パターンです。コンポーネント内部のビジネスロジックとUIのロジックを分離するために登場しました。React16.8以降では、 React Hooksが導入され、コンポーネントとロジックの分離が可能となりました。しかし、React Hooksがスタンダードになった今でも、レイヤーを分けて複雑性を緩和するという意味では有用な設計パターンになります。
まずは、基本的なContainer/Presentationalパターンの構成を見てみましょう。Container/Presentationalパターンは次の二つのコンポーネントで構成されます。
- Presentational Component
- コンポーネントの表示に関する処理を定義する
fetch
などの非同期処理は含まない- 代わりにデータの取得はPropsを経由する
- Container Component
- ビジネスロジックに関わる処理を定義する
fetch
などの非同期処理を含む- 取得したデータはPresentational Componentに渡す
- コンポーネントの表示に関する処理はない
Presentational Componentの責務は、UIを表示することだけになります。APIからデータを取得したり、状態を保持することはありません。データの取得は全てPropsを経由して行われます。一方、Container ComponentはUIに関する処理はありません。Container Componentの責務は、Presentational Componentが必要なデータを取得、管理することです。具体的には、APIへの非同期処理や状態の変更、ビジネスロジックなどの実装をします。
簡単な例で確認してみましょう。ブログ記事を表示するコンポーネントで、データはAPIから取得するとします。通常、一つのコンポーネントで書く場合は次のようになります。
import { useState, useEffect } from 'react';
import styles from "./home.module.css";
type Article = {
body: string
id: number
title: string
userId: number
}
export function Blog() {
const [articles, setArticles] = useState<Article[]>([])
useEffect(() => {
(async () => {
const res = await fetch('https://jsonplaceholder.typicode.com/posts');
const data = await res.json()
setArticles(data)
})()
}, [])
return (
<>
<h1>Blog</h1>
<ul>
{articles.map(l =>(
<li key={l.id} className={styles.listItem}>{l.title}</li>
))}
</ul>
</>
)
}
fetch
による非同期処理を実行して、データを取得します。取得したあとは、リストを表示しています。このコンポーネントはデータの取得とUIの表示の責務を担っているということになります。一つのコンポーネントで全て完結している点では優れているのですが、ビジネスロジックやAPIの取得、UIのロジックなども混在するようになると、コンポーネントは肥大化して見通しが悪くなる可能性があります。また、依存関係が多くなるにつれてコンポーネントの再利用性も失われる可能性があります。
では、このコンポーネントをPresentational ComponentとContainer Componentに分けてみましょう。まずは、Presentational Componentを実装します。
import styles from "./home.module.css";
import { Article } from '@/components/Home/types';
type Props = {
articles: Article[]
}
export function Presentational(props: Props) {
return (
<>
<h1>Blog</h1>
<ul>
{props.articles.map(l =>(
<li key={l.id} className={styles.listItem}>{l.title}</li>
))}
</ul>
</>
)
}
前述した通り、Presentational ComponentにはUIに関する処理だけを定義します。表示するための記事データはPropsを経由して取得しています。
次に、Container Componentを書いてみましょう。
import { useState, useEffect } from 'react';
import { Article } from './types'
import { Presentational } from './Presentational'
export function Blog() {
const [articles, setArticles] = useState<Article[]>([])
useEffect(() => {
(async () => {
const res = await fetch('https://jsonplaceholder.typicode.com/posts');
const data = await res.json()
setArticles(data)
})()
}, [])
return (
<>
<Presentational articles={articles} />
</>
)
}
非同期処理はそのままで、<Presentational>
をレンダリングしています。ここでは、UIに関する処理はPresentational Componentに委ねて、状態管理やデータ取得、副作用のある処理をのみを実装します。このようにレイヤーを分けることで、お互いの責務をハッキリさせることができます。それにより、複雑性が緩和し、コンポーネントの見通しがよくなります。また、Presentational Componentの再利用性も高まります。なぜなら、Presentational Componentは純粋なStatelessな関数なので、Propsを渡すだけでUIの実装ができるからです。例えば、Special Blogというコンポーネントを実装する際は、データの取得だけ実装すればあとは同じように再利用することができます。
import { useState, useEffect } from 'react';
import { SpecialArticle } from './types'
import { Presentational } from 'src/components/Blog/Presentational'
export function SpecialBlog() {
const [specialArticles, setSpecialArticles] = useState<SpecialArticle[]>([])
useEffect(() => {
(async () => {
// SpecialBlog用のデータを取得
const res = await fetch('https://jsonplaceholder.typicode.com/special_posts');
const data = await res.json()
setSpecialArticles(data)
})()
}, [])
return (
<>
{/*UIの実装は変わらない*/}
<Presentational articles={specialArticles} />
</>
)
}
また、StorybookやVisual Regression TestingのときもPresentational Componentを利用することでUIのテストがしやすくなるでしょう。
Container/Presentationalパターンは、React Hooksで置き換えることも可能です。Container ComponentをReact Hooksに置き換えるため、非同期処理の部分をHooks化してみましょう。
以下の箇所をReact Hooksで切り出します。
import { useState, useEffect } from 'react';
import { Article } from './types'
import { Presentational } from './Presentational'
export function Blog() {
const [articles, setArticles] = useState<Article[]>([])
useEffect(() => {
(async () => {
const res = await fetch('https://jsonplaceholder.typicode.com/posts');
const data = await res.json()
setArticles(data)
})()
}, [])
return (
<>
<Presentational articles={articles} />
</>
)
}
useArticlesQuery.tsx
というファイルを作成して、以下のように実装します。
import { useEffect, useState } from 'react';
import { Article } from './types';
export const useArticlesQuery = () => {
const [articles, setArticles] = useState<Article[]>([])
useEffect(() => {
(async () => {
const res = await fetch('https://jsonplaceholder.typicode.com/posts');
const data = await res.json()
setArticles(data)
})()
}, [])
return {
articles
}
}
そして、Presentational ComponentでこのHooksを直接インポートして使用します。
import styles from "./home.module.css";
export function Presentational() {
const { articles } = useArticlesQuery()
return (
<>
<h1>Blog</h1>
<ul>
{articles.map(l =>(
<li key={l.id} className={styles.listItem}>{l.title}</li>
))}
</ul>
</>
)
}
このように、React Hooksを使うことで、Container Componentを使わなくてもロジックの切り分けをすることができました。しかし、関心の分離ができたとはいえ、Presentational Componentは非同期処理を伴う副作用のあるコンポーネントになってしまいました。この状態では、<Presentational>
が特定の非同期処理と密接になるため、上記で示したような再利用性が失われてしまいます。そのため、<Presentational>
の再利用性とStatelessを維持するには、Container ComponentでHooksを実行する必要があります。
import { Presentational } from './Presentational'
import { useArticlesQuery } from './useArticlesQuery';
export function Blog() {
const { articles } = useArticlesQuery()
return (
<>
<Presentational articles={articles} />
</>
)
}
非同期処理をContainer Componentで実行することで、副作用のある処理をContainer層に閉じることができました。 React Hooksを使う場合でも、このように関心ごとにレイヤーを分けることでコンポーネントのメンテナンス性が向上します。Presentational Componentでは、データの関心はなく、純粋にUIの処理だけに関心を寄せることができます。そのため、単体テストやStorybookなどの使い回しがしやすくなります。
Container Componentでは、Hooksや非同期処理、ビジネスロジック、状態管理を実装することで、副作用のある処理を局所化できるようになります。肥大化する恐れもありますが、そうなった場合はHooks化して分離する、あるいは、適度にコンポーネントを分離して下の層でさらに、Container Presentationalと分ける方法で回避できるでしょう。例えば、Blog以下にCommentsコンポーネントを作成して、その中でContainerとPresentational、Hooksを分けることができます。
src/components/
└── Blog
├── Comments
│ ├── Comments.tsx
│ ├── Presentational.tsx
│ └── useCommentsQuery.ts
├── Blog.tsx
├── Presentational.tsx
├── blog.module.css
├── index.ts
├── types.ts
└── useArticlesQuery.ts
# HOCs
HOCs(Higher-Order Components)パターンは、コンポーネントに特定の機能や処理を追加するための設計パターンです。複数のコンポーネントに同じロジックやスタイルを実装するために使われます。React16.8以降は、React Hooksが使われるようになったのでHOCsパターンを使う機会は減りましたが、そのコンセプトや思想を理解しておくことは重要です。
HOCsの実装方法は、コンポーネントをラップし特定の機能や処理を追加して、コンポーネントを返します。例えば、特定のスタイルを付与したい場合は次のように書くことができます。
const withStyles = (Component) => {
return props => {
const style = { padding: '1rem', margin: '1rem', ...props.styles }
return <Component style={style} {...props} />
}
}
const Text = () => <p>テキスト</p>
const StyledText = withStyles(Text)
withStyles
という関数を定義して、コンポーネントを第一引数で受け取ります。受け取ったコンポーネントに対してスタイルを適用し、新しいコンポーネントとして返しています。
また、ログを取るようなケースだと次のように実装することができます。
const withLog = (Component) => {
return props => {
useEffect(() => {
logger.log()
}, [])
return <Component {...props} />
}
}
const PageComponent1 = (props) => {...}
const PageComponent2 = (props) => {...}
const LoggedComponent1 = withLog(PageComponent1)
const LoggedComponent2 = withLog(PageComponent2)
withLog
関数を介して、PageComponent1とPageComponent2のログを取ることができます。 このように共通の処理を複数のコンポーネントに適用したいときに、HOCsパターンは使われます。
HOCsパターンの背景には、関数型プログラミングの高階関数 (opens new window)とカリー化 (opens new window)いう概念がベースになっています。高階関数とは、関数を引数にとる、あるいは関数を返す関数になります。例えば、次のような関数を引数に取るケースだったり、関数から別の関数を返す関数を指します。
// 関数を引数に取る
const fn = (callback) => {
callback();
}
// 関数を返す
const fn = () => {
return () => {
console.log("hello")
}
}
カリー化とは、この高階関数の特性を使って、もとの関数の引数を複数の関数で利用して結果を返す関数のことを言います。例えば、最初の関数にnum1
を指定して、次の関数でそのnum1
を使用して計算した結果を返すことができます。
const calc = (num1) => {
return (num2) => {
return num1 * num2
}
}
calc(1)(2) // 2
calc(1)(3) // 3
カリー化を使うことで、最初の引数をもとに関数の呼び出し時の振る舞いを変化させることができます。例えば、次のような関数ではこんにちは、
という文字列を保持しつつ、その後の名前を変化させることができます。
const greet = (arg) => {
return (name) => {
return arg + name
}
}
const greetYamada = greet('こんにちは、')
greetYamada('山田') // こんにちは、山田
const greetSuzuki = greet('こんにちは、')
greetYamada('鈴木') // こんにちは、鈴木
このように、関数の実行結果を保持しつつ新しい関数に適用することを部分適用と言います。この部分適用を使うことで、処理の共通化ができたり、関数を組み合わせることができます。関数を組み合わせる使い方で言うと、JavaScriptライブラリのRamda (opens new window)のcomposeを使って実現できます。
例えば、次のような足し算と2倍にする関数を定義します。
const add = (a, b) => a + b
const double = (a) => a * 2
普通に使うと、次のように実行できるでしょう。
double(add(1, 2)) // 6
composeを使うと、次のように実行できます。
const func = compose(double, add);
func(1, 2) // 6
add
の実行結果(3
)をdouble
に渡して2倍にした計算結果(6
)を返しています。composeを使うことで、関数の実行結果を保持しつつ次の引数に適用することができます。このように書くことで、関数の純粋性を維持でき、関心の分離を実現することができます。add
関数は引数に対して足し算することだけに関心を持ちます。double
は値を2倍にするだけの責務を負います。仮に追加で、ログの処理を入れたければ次のように関数を追加できます。
const add1 = (a, b) => a + b
const double = (a) => a * 2
const log = (v) => {
console.log(v)
return v
}
compose(
log, // logを追加
double,
add1,
)(1, 2)
double
の処理が必要無くなったとしましょう。その場合、composeからdouble
を削除すればいいだけなので、他の関数への影響を与えることもありません。
compose(
log,
add1,
)(1, 2)
それぞれの関数が状態を持たずに、与えられた引数のみで処理を実行することができます。その結果、副作用がなく、冪等性を維持できます。冪等性を維持できることで、関数の純粋さを保つことができます。また、宣言的に書くことができるのでそれぞれの処理が分かりやすくなり、コードの見通しが良くなります。関数をシンプルに保てるのでテストも書きやすくなるでしょう。
TIP
JavaScriptでは、Pipeline Operator (opens new window)という仕様が策定中で、これを使用すると、前述の処理も次のように書くことができます。
add(1, 2)
|> double(_)
|> log(_)
Pipeline Operatorの仕様は策定中なので今後変わる可能性はあります。
ReactのHOCsも高階関数の考え方がベースになっています。もう一度、withStyles
を見てみましょう。
const withStyles = (Component) => {
return props => {
const style = { padding: '1rem', margin: '1rem', ...props.styles }
return <Component style={style} {...props} />
}
}
const Text = () => <p>テキスト</p>
const StyledText = withStyles(Text)
第一引数にComponentを受け取り、新しいコンポーネント(関数)を返しています。上記で見た、カリー化と同じテクニックが使われているのが分かります。部分適用が処理の共通化で使われるように、HOCsでもコンポーネントに対する処理の共通化で使われています。
また、HOCsはcomposeのように組み合わせることができます。前述のwithLog
とwithStyles
を組み合わせると、次のようになります。
const EnhancedText = withLog(withStyles(Text))
上記の書き方をcompose
に直すと、次のように書くことができるでしょう。
const compose = (...fns) =>
fns.reduceRight((prevFn, nextFn) =>
(...args) => nextFn(prevFn(...args)),
value => value
);
const EnhancedText = compose(
withLog,
withStyles
)(Text)
このように書くことで、HOCsでもお互いのコンポーネント(関数)に影響を与えずに変更を適用することができます。
Reactは、関数型プログラミングの思想が多く取り入れらているため、その仕組みや成り立ちを理解することは設計パターンを考える上での手助けになります。コンセプトを理解しておくと、新しいAPIや書き方が導入されたときでも柔軟に対応することができます。HOCsパターンはReact Hooksでほとんどの場合、置き換えられるため使う機会は少なくなっていますが、その仕組みや関数型プログラミングの考え方を理解しておくことは重要になります。
# Hooks
HooksはReact16.8から登場したAPIです。従来、ReactではClassでコンポーネントを書き、Stateやライフサイクル、メソッドの管理をしていました。しかし、近年では、関数で書くFunctional Componentが推奨されるようになりました。Functional Componentは、純粋なJavaScriptの関数のため、Stateやライフサイクルを持つことができません。Hooksは、Functional ComponentでもStateやライフサイクルを使用するために導入されました。代表的なHooksは、useState
やuseEffect
などがあります。ここでは、Hooksの使い方については説明しませんが、Hooksを用いたコンポーネントの設計パターンについて見ていきたいと思います。
React Hooksを使うことのメリットは、コンポーネントとロジックを分離し再利用性を高めることです。また、コンポーネントの見通しが良くなるため、メンテナンス性も向上します。
前述したBlogコンポーネントの例を見てみましょう。
import { useState, useEffect } from 'react';
import { Article } from './types'
import { Presentational } from './Presentational'
export function Blog() {
const [articles, setArticles] = useState<Article[]>([])
useEffect(() => {
(async () => {
const res = await fetch('https://jsonplaceholder.typicode.com/posts');
const data = await res.json()
setArticles(data)
})()
}, [])
return (
<>
<Presentational articles={articles} />
</>
)
}
このコンポーネントでは、非同期処理でブログ記事を取得しており、非同期処理の部分がコンポーネントのロジックとして実装されているのが分かります。
他のページでもこのブログ記事を表示すると仮定しましょう。ただ、表示するコンポーネントは別のものを使用するものとします。その場合、ブログ記事を取得する非同期処理だけを共通化する必要があります。そのようなケースの場合、React Hooksを使うことで共通化することができます。
以下の非同期処理の部分をHooks化します。
import { useState, useEffect } from 'react';
import { Article } from './types'
import { Presentational } from './Presentational'
export function Blog() {
const [articles, setArticles] = useState<Article[]>([])
useEffect(() => {
(async () => {
const res = await fetch('https://jsonplaceholder.typicode.com/posts');
const data = await res.json()
setArticles(data)
})()
}, [])
return (
<>
<Presentational articles={articles} />
</>
)
}
useArticlesQuery.tsx
というファイルを作り、以下のように実装します。
import { useEffect, useState } from 'react';
import { Article } from './types';
export const useArticlesQuery = () => {
const [articles, setArticles] = useState<Article[]>([])
useEffect(() => {
(async () => {
const res = await fetch('https://jsonplaceholder.typicode.com/posts');
const data = await res.json()
setArticles(data)
})()
}, [])
return {
articles
}
}
Hooks化することで、非同期処理を関数に移すことができました。コンポーネントでは、次のように使用します。
import { Presentational } from './Presentational'
import { useArticlesQuery } from 'src/hooks/useArticlesQuery';
export function Blog() {
const { articles } = useArticlesQuery()
return (
<>
<Presentational articles={articles} />
</>
)
}
コンポーネントのロジックが移動して、見通しが良くなりました。また、useArticlesQuery
と宣言的に書くことで、具体的な処理が明確に分かります。
このHooksを他のページで使うとすると、以下のように実装することができます。
import { OtherPagePresentational } from './OtherPagePresentational'
import { useArticlesQuery } from 'src/hooks/useArticlesQuery';
export function OtherPage() {
const { articles } = useArticlesQuery()
return (
<>
<OtherPagePresentational articles={articles} />
</>
)
}
このようにロジックをHooks化することで再利用性を高めることができます。
Hooksパターンは、異なるUIに対して同じ挙動を実装したいケースでも有効的です。例えば、次のようなお気に入りボタンで見てみましょう。
import { useCallback, useState } from 'react';
import styles from './styles.module.css'
export function FavoriteButton() {
const [isFavorite, setIsFavorite] = useState(false)
const handleToggle = async () => {
if (!isFavorite) {
await fetch('http://api/favorite')
} else {
await fetch('http://api/unfavorite')
}
}
const style = isFavorite ? styles.on : styles.off
return (
<>
<button className={style} onClick={handleToggle} />
</>
)
}
お気に入りのオンとオフに応じてスタイルを変えていて、ボタンが押下されるとAPIへデータの更新を問い合わせています。このロジック部分をHooksで切り出してみましょう。
import { useCallback, useState } from 'react';
export const useFavorite = () => {
const [isFavorite, setIsFavorite] = useState(false)
const toggleFavorite = useCallback(async () => {
if (!isFavorite) {
await fetch('http://api/favorite')
} else {
await fetch('http://api/unfavorite')
}
}, [])
return {
isFavorite,
toggleFavorite
}
}
そして、コンポーネントからこのHooksを使用します。
import styles from './styles.module.css'
import { useFavorite } from '@/hooks/useFavorite'
export function FavoriteButton() {
const { isFavorite, toggleFavorite } = useFavorite()
const style = isFavorite ? styles.on : styles.off
return (
<>
<button className={style} onClick={toggleFavorite} />
</>
)
}
見通しが良くなったと同時に、責務の切り分けができました。このコンポーネントはお気に入りボタンの挙動には関与せず、与えられた状態を判定してスタイルを適用することだけに関心を寄せることができます。そのため、仮にAPI先の挙動が変わってもこのコンポーネントが影響を受けることはなく、useFavorite.ts
内で修正を抑えることができます。
では、お気に入りボタンが別のページでも必要となったとしましょう。別のページでは、お気に入りボタンのスタイルや構造が多少異なります。配置する場所なども異なるため、別のコンポーネントを実装する必要があります。そのため、次のようにAnotherFavoriteButton
を作成します。
import styles from './styles.module.css'
export function AnotherFavoriteButton() {
const [isFavorite, setIsFavorite] = useState(false)
const handleToggle = async () => {
if (!isFavorite) {
await fetch('http://api/favorite')
} else {
await fetch('http://api/unfavorite')
}
}
const style = isFavorite ? styles.on : styles.off
return (
<>
<button className={style} onClick={handleToggle} />
</>
)
}
スタイルやUIが異なっていても、このコンポーネントは<FavoriteButton>
と同じ挙動を求められます。その場合、Hooksを使うことで同じロジックを利用するができます。
import styles from './styles.module.css'
import { useFavorite } from '@/hooks/useFavorite'
export function AnotherFavoriteButton() {
const { isFavorite, toggleFavorite } = useFavorite()
const style = isFavorite ? styles.on : styles.off
return (
<>
<button className={style} onClick={toggleFavorite} />
</>
)
}
Hooksを経由して同じ挙動を実現することができました。Hooksパターンでは、複数のコンポーネントでロジックの再利用ができ、コンポーネントの見通しが良くなります。また、それぞれの責務を分けることで、複数のHooksを組み合わせることができます。
import { useArticlesQuery } from 'src/hooks/useArticlesQuery';
export function Blog() {
const res = useArticlesQuery() // ブログ記事の取得
const { articles } = useArticlesFilter(res) // ブログ記事のフィルタリング
useLog() // ログ収集
useSubscribe() // subscribe
...
}
HOCsパターンで見たように、関数の処理をシンプルにして組み合わせるように使うことで再利用性が増し、メンテナンスしやすい設計になります。また、個々のHooksの依存関係が少なくなればテストも書きやすくなるでしょう。
# Atomic Design
Atomic Design (opens new window)とは、小さな部品を組み合わせてUIを設計するデザインフレームワークです。コンポーネントを、Atoms、Molecules、Organisms、Templates、Pagesで分類し、それぞれのコンポーネントを組み合わせてアプリケーションを構築します。
出典: Atomic Design (opens new window)
Atomic Designの目的は、UIの一貫性を保つことやデザイナーとエンジニアの共通認識を高めること、コンポーネント指向でUIを構築することなどがあります。コンポーネントの一つ一つを一覧にして管理することで、UIの共通認識が生まれ、デザインと開発の協業をサポートします。
フロントエンド開発でAtomic Designは広く使われるようになりましたが、同時にいくつかの課題も見えてくるようになりました。例えば、次のようなものが挙げられます。
- コンポーネントの粒度がMoleculeかOrganismsか分からない
- 特定のページでしか使われていないコンポーネントが多い
- コンポーネントの影響範囲が見えづらい
- CSSはどうやって分けるのか
- 状態はどこで管理するのか
これらが発生する原因としては、そもそもデザインの段階でAtomic Designが機能していなかったり(あるいは、導入していない)、デザイナー・エンジニアの共通認識がずれていたり、フォルダ構成が複雑化している、などがあります。同時に、近年のフロントエンド開発とAtomic Designがマッチしていない可能性もあります。Atomic DesignはあくまでUIデザインのフレームワークなので、フロントエンドのコンポーネント指向開発に当てはめると、足りない点がいくつか出てきます。例えば、次のような点はAtomic Designでは考慮されていません。
- ロジックでの境界線
- 状態管理
- CSSの設計
- TypeScriptの型設計
- データの受け渡し
Atomic Designはデザイン的視点で、コンポーネントを振り分けます。しかし、開発の視点では、必ずしも見た目上だけで分離できるわけではありません。Organismsだと、ビジネスロジックが頻繁に含まれる可能性はあります。また、Moleculesのような小さな部品でも、ロジックを含めて使い回したいなどのケースもあるでしょう。Reactでは、Hooksの登場により、ロジックの再利用が可能となりました。そのため、コンポーネントの凝集度を高めて、ロジックを含めた再利用性を重視するような動きもあります。このようなロジックやデータの扱いを含めると、オリジナルのAtomic Designの境界線では難しくなるでしょう。
そのため、Atomic Designにはプラスアルファの設計が必要になります。必ずしも原理原則を守る必要はなく、チームに合わせて柔軟に設計することが重要です。この章では、Atomic Design +αの一例としてプロジェクト構成を考えたいと思います。Reactベースで構成しますが、Vue、Angularなどその他のUIフレームワークでも応用可能になります。
# Atomic Design +α の設計
Atomic Design +α の設計では、Atomic Designのデザイン視点での境界線とロジックを含めた境界線を明確に分けて構成したいと思います。具体的には次の二つのカテゴリで分けます。
- UI
- Features
UIは、純粋なUIの部品のみで構成します。例えば、ボタンやテキスト、アイコンといったUIライブラリにあるような汎用的なUIを指します。モーダルやタブ、DatePickerなどのUIもここに含まれます。
Featuresは、アプリケーション固有のコンポーネントで構成します。共通で使うコンポーネントのみを定義し、コンポーネントにはアプリケーションに必要なロジックやAPI通信などの非同期処理が含まれることもあります。具体的には、検索ボックスやユーザーアバター、キャンペーンのモーダル画面など、アプリケーションで再利用をするコンポーネントになります。UIとの違いは、ロジックやドメイン知識が含まれるか含まれないかです。Featuresにはロジックが含まれるコンポーネント、UIはロジックを含まない純粋なコンポーネントになります。 まとめると次のようになります。
カテゴリ | 再利用性 | 純粋なUIか | ドメイン・ページ固有の知識 | APIなどの非同期処理 | 例 |
---|---|---|---|---|---|
UI | ○ | ○ | × | × | Button,Icon, Input, Text, Modal, Tab |
Features | ○ | × | ○ | ○ | SearchBox, UserAvatar,CampaignModal |
フォルダは次のように構成します。
src/components/
├── features
│ ├── molecules
│ └── organisms
├── pages
│ ├── Blog
│ └── Home
└── ui
├── atoms
├── molecules
└── organisms
ui
とfeatures
にはそれぞれAtomic Designのレイヤーを設けています。Storybookなどで閲覧する際に見つけやすくするのと、コンポーネントの見通しを良くするために階層を分けています。もし、MoleculesやOrganismsの判定コストを掛けたくないならフラットの構成でも問題ないでしょう。だた、ui
などは純粋なUI部品になるため、そこまで迷う要素はないかと思います。
src/components/ui
├── atoms
│ ├── AspectRatio
│ ├── Avatar
│ ├── Badge
│ ├── Box
│ ├── Button
│ ├── ButtonGroup
│ ├── Center
│ ├── Flex
│ ├── Form
│ ├── Grid
│ ├── Head
│ ├── Heading
│ ├── Icon
│ ├── IconButton
│ ├── Image
│ ├── Input
│ ├── InputText
│ ├── Label
│ ├── Link
│ ├── List
│ ├── Logo
│ ├── Media
│ ├── MoreLink
│ ├── Portal
│ ├── PortalManager
│ ├── Progress
│ ├── Radio
│ ├── Select
│ ├── Skeleton
│ ├── Slide
│ ├── Spinner
│ ├── Stack
│ ├── Switch
│ ├── Text
│ ├── Textarea
│ ├── Wrap
│ └── index.ts
├── molecules
│ ├── ColorModeSwitch
│ ├── CustomField
│ ├── FavoriteIconButton
│ ├── Forms
│ ├── InputWithIcon
│ ├── LikeIconButton
│ ├── PageLoader
│ ├── Toast
│ ├── Tooltip
│ ├── Transitions
│ └── index.ts
└── organisms
├── Accordion
├── Carousel
├── DatePicker
├── Drawer
├── Editor
├── Layout
├── Menu
├── Mobile
├── Modal
├── PdfViewer
├── Popover
├── Table
├── Tabs
└── VideoPlayer
features
では、アプリケーション固有で、かつ、共通で使われるコンポーネントを定義します。ここでは、React Hooksでのロジックの実装やAPI通信なども含まれます。例えば、次のようなUIを定義することができます。
src/components/features/
├── molecules
│ ├── AttachmentBox
│ ├── AttachmentUploadingBox
│ ├── Chips
│ ├── LikeTaskIconButton
│ ├── ThumbnailAttachment
│ └── Tooltips
└── organisms
├── Inbox
├── MainHeader
├── Menus
├── Modals
├── MyAvatar
├── Navigation
├── Popovers
├── Projects
├── TaskDetail
├── TaskDetails
├── Tasks
└── TeammateAvatar
pages
は、ページ単位で構成します。 特定のページでしか使われないコンポーネントは、features
ではなく、pages
フォルダ下に定義します。例えば、Blogページでコメント一覧を表示したい場合、次のような構成になります。
├── pages
│ ├── Blog
│ │ ├── Blog.tsx
│ │ ├── Comments // コメント一覧のコンポーネント
│ │ ├── Presentational.tsx
│ │ ├── blog.module.css
│ │ ├── index.ts
│ │ ├── types.ts
│ │ └── useArticlesQuery.ts
このような構成にすることで、コンポーネントの影響範囲を限定的にでき、かつ、コンポーネントの見通しが向上します。リファクタリングや修正する際も、他のページに影響を及ぼすことがないので、心理的負荷も軽減されるでしょう。もし、他のページでも利用されるようになったら、抽象化をしてfeatures
に移動しましょう。最初から汎用化すると逆に複雑化する恐れがあるので、まずはpages
下に実装して、徐々に共通のコンポーネントを増やしていく方針の方が安全に運用できます。
続いて、features
の詳細を見てみましょう。features
のコンポーネントは、Container ComponentとPresentational Componentに分けることとします。理由は、StorybookやVisual Regression TestingsのときにUIのテストをしやすくするためです。例えば、以下のようなコンポーネントがあるとしましょう。
このコンポーネントは次のような構成になっています。
src/components/features/molecules/AttachmentBox/
├── AttachmentBox.tsx
├── index.ts
└── sizes.ts
AttachmentBox
コンポーネントは次のような実装になっています。
export const AttachmentBox: React.FC<Props> = (props) => {
const { size, color, taskFileId, isHovering, ...rest } = props
const { taskFile } = useTaskFile(taskFileId)
const sizeStyle = sizes[size]
const icon = getTaskFileIcon(taskFile.fileType.typeCode)
const taskFileName = getTaskFileName(taskFile.fileType.typeCode)
return (
<Flex
borderRadius="lg"
border="1px"
borderColor={isHovering ? 'gray.400' : 'gray.200'}
alignItems="center"
transition={transitions.base()}
p={4}
{...sizeStyle}
{...rest}
>
<Icon icon={icon} color="text.muted" size="2xl" />
<Flex ml={4} flexDirection="column" flex={1} minW={0}>
<Text fontSize="sm" noOfLines={1}>
{taskFile.name}
</Text>
<Flex>
<Text as="span" fontSize="xs" color="text.muted">
{taskFileName}・
<Link
href={taskFile.src}
fontSize="xs"
color="text.muted"
download
hover
onClick={(e) => e.stopPropagation()}
>
Download
</Link>
</Text>
</Flex>
</Flex>
</Flex>
)
}
このコンポーネントは、受け取ったIDをもとにStoreからデータを取得しています。そしてファイル名やアイコンなどを整形して表示しています。アプリケーション全体で同じロジックが使われることを想定しています。このコンポーネントをStorybookなどで表示する場合、Storeなどの関連する依存関係を解決しなければなりません。また、テストを実行する際も同様です。そのようなコストを減らすためには、UIに関する処理をPresentationalに移して、Presentational Component単体でテストできるように実装する必要があります。
フォルダ構成を以下のように変えてみましょう。
src/components/features/molecules/AttachmentBox/
├── AttachmentBox.stories.tsx
├── AttachmentBox.tsx
├── Component.tsx
├── index.ts
└── sizes.ts
Component.tsx
がPresentational Componentになります。必要なデータはProps経由して、次のように実装することができます。
type Props = FlexProps & {
size: Sizes
name: string
src: string
fileName: string
icon: IconType
isHovering?: boolean
}
export const Component: React.FC<Props> = (props) => {
const { size, color, name, isHovering, fileName, src, icon, ...rest } = props
const sizeStyle = sizes[size]
return (
<Flex
borderRadius="lg"
border="1px"
borderColor={isHovering ? 'gray.400' : 'gray.200'}
alignItems="center"
transition={transitions.base()}
p={4}
{...sizeStyle}
{...rest}
>
<Icon icon={icon} color="text.muted" size="2xl" />
<Flex ml={4} flexDirection="column" flex={1} minW={0}>
<Text fontSize="sm" noOfLines={1}>
{name}
</Text>
<Flex>
<Text as="span" fontSize="xs" color="text.muted">
{fileName}・
<Link
href={src}
fontSize="xs"
color="text.muted"
download
hover
onClick={(e) => e.stopPropagation()}
>
Download
</Link>
</Text>
</Flex>
</Flex>
</Flex>
)
}
Container ComponentであるAttachmentBox.tsx
は次のようになります。
import { Component } from './Component'
export const AttachmentBox: React.FC<Props> = (props) => {
const { size, color, taskFileId, isHovering, ...rest } = props
const { taskFile } = useTaskFile(taskFileId)
const icon = getTaskFileIcon(taskFile.fileType.typeCode)
const taskFileName = getTaskFileName(taskFile.fileType.typeCode)
return (
<Component
size={size}
color={color}
name={taskFile.name}
fileName={taskFileName}
icon={icon}
src={taskFile.src}
{...rest}
/>
)
}
UIの処理がPresentational Componentに移され、ロジックのみを実装します。
そして、Storybookを追加する際は、Component.tsx
を使うことができます。
import { ComponentStory, ComponentMeta } from '@storybook/react'
import React from 'react'
import { Container } from 'src/storybook/decorators/Container'
import { Component } from './Component'
type Props = React.ComponentProps<typeof Component>
export default {
title: 'Features/molecules/AttachmentBox',
component: Component,
parameters: {
layout: 'fullscreen',
},
decorators: [
(Story) => (
<Container>
<Story />
</Container>
),
],
} as ComponentMeta<typeof Component>
const Template: ComponentStory<typeof Component> = (args) => (
<Component {...props()} {...args} />
)
export const PDF = Template.bind({})
function props(options?: Partial<Props>): Props {
return {
size: 'md',
name: '/files/pdf-test.pdf',
fileName: 'PDF',
icon: 'outlineFilePdf',
src: '/files/pdf-test.pdf',
...options,
}
}
Storybookで確認すると、次のようになります。
依存関係の解決をする必要がなく、Propsを渡すだけでUIのテストができるようになりました。 このようにContainerとPresentationalに分けることで、テストの実装が書きやすくなります。
Atomic Design +α の設計をまとめると次の通りです。
- pages
- ページ単体で構成
- ページ特定のコンポーネントは
pages
配下に定義 - 再利用する場合は
features
へ昇華する
- features
- アプリケーション固有のロジックを含むコンポーネント
- アプリケーション全体で再利用される
- ui
- 純粋なUI
- ロジックは含まない
ロジックを含むコンポーネントと純粋なUIを分けることで、それぞれの責務が明確になります。オリジナルのAtomic Designの構成だと、ロジックが入り込んでしまい、コンポーネント再利用性が低下しがちでした。レイヤーを分けることで、ロジックを含む境界線の課題は緩和されたかと思います。ただ、全ての課題が解決されたわけではないので、新たな改良は必要になります。Atomic Designだけの問題ではなく、コンポーネント設計では、影響範囲を抑えてなるべく依存関係が少ない設計にすることが重要です。ただ、やみくもにレイヤーを分けすぎても見通しが悪くなる可能性があるので、まずは、必要最低のレイヤー分けで設計するのが有効的でしょう。
← プロジェクトアーキテクチャ 状態管理 →