はじめに
Storybookを導入しようと思いました。
理由は、とても軽いものでした。
現在、私は既存のSvelte 4で作っていたフロントエンドをReactへポーティングしています。単純な置き換えではなく、設計やライブラリ選定も含めて見直しながら作り直しているところです。
せっかくなら、このタイミングでStorybookにも対応しておくとよさそうです。
- UIコンポーネントを単体で確認できる。
- ローディング状態やエラー状態も見やすい。
- 将来的なデグレ確認にも使える。
- MSW(Mock Service Worker)を使えば、API通信もモックできる。
なるほど、よさそうです。
私は思いました。
「まあ、MSWとか使えばなんとかなるっしょ!」
そして、試しに検索周りのコンポーネントを見てみました。
そこで気づきました。
「あれ?これは…… 思っていたよりも、深い沼かもしれない……」
この記事は 「Storybookの導入検討がきっかけで、これまでの自分のフロントエンド設計を根底から見直す必要性に気づいてしまった」という件についての、懺悔と備忘録のようなものです。
タイトルには「Storybook導入でReact Hooksに脳を破壊された話」とありますが、正確に言えば、Storybookが引き金になって私の脳を破壊したわけでありません。
Storybookは、React Hooksによってすでに破壊されていた私の脳を、丁寧に可視化してくれただけだったのです。
そのコンポーネント、Storybookに置けますか?
たとえば、次のようなヘッダーに配置する検索フィールドのコンポーネントがあったとします。
import { useSearchParams } from 'react-router-dom'
export default function HeaderSearchField() {
const [searchParams, setSearchParams] = useSearchParams()
const [searchValue, setSearchValue] = useState('')
useEffect(() => {
setSearchValue(searchParams.get('search') ?? '')
}, [searchParams])
const handleSubmitSearch = () => {
setSearchParams((prev) => {
const next = new URLSearchParams(prev)
const value = searchValue.trim()
if (value) {
next.set('search', value)
} else {
next.delete('search')
}
return next
})
}
return (
<Stack direction="row" spacing={0.5} sx={{ alignItems: 'center' }}>
<SearchQueryTextField
value={searchValue}
onChange={setSearchValue}
onSearchSubmit={handleSubmitSearch}
placeholder="Search..."
sx={mergeSx(headerSearchBarSx, {
/* ...独自のstyle定義... */
})}
endAdornment={<Search />}
/>
{/* 以下のコンポーネントの内部でも、同じく useSearchParams を使っている */}
{/* 加えて、TanStack Queryの useQuery も使っていたりする */}
<SearchDetailsDialogButton />
</Stack>
)
}
一見すると、そこまで悪くなさそうに見えます。
コード量は多くありません。検索フィールドに必要な処理がまとまっています。
react-router-dom の useSearchParamsを通してURLクエリを読み書きする。ローカルの入力状態はuseStateで持つ。
コンポーネントとしての見通しは、悪くないように見えます。
必要な機能が、必要な場所に集まっています。内部に配置される子コンポーネントも同様にそれ自体で完結しているため、余計なpropsのバケツリレーもありません。
しかし、Storybookに単体のコンポーネントとして載せようとした瞬間、問題が見えてきます。
このコンポーネントは、単体ではレンダリングできないのです。
useSearchParamsを使っているので、React Routerのコンテキストが必要です。
さらに、この内部に配置されるコンポーネントでは、データの取得にuseQueryも使用しており、TanStack QueryのQueryClientProviderの配下に設置することを前提としています。
つまり、この検索フィールドコンポーネントは「ヘッダー配置用の検索フィールド (HeaderSearchField)」という名前を持っているものの、実際にはRouterやURL状態やAPI通信に接続された、小さな画面コンテナになっていたのです。
- Storybookで検索フィールドだけを見たいのに、React Routerのコンテキストが必要になる。
- さらに内部にある子コンポーネントの存在も踏まえると、TanStack QueryやMSWも必要になる。
- さらに、TanStack Queryを動作させるために、認証コンテキストも必要になりそう。
- その他にも、必要な前提条件があるかもしれない。えーと……
ここでようやく気づきました。
「もしかして、自分は今の今まで、Reactで開発する際の『責務の分離』の意味を取り違えてしまっていたのではないか?」と。
「短いコード」と「疎結合」は同じではなかった
私は勘違いしていました。
useQueryをコンポーネントの中で呼ぶ。useSearchParamsをコンポーネントの中で呼ぶ。必要なデータを必要な場所で取得する。propsのバケツリレーが減る。コードも短くなる。見通しもよくなる。
だから、ちゃんと責務分離できていると思っていました。
もちろん、表示部分を純粋なprops駆動のコンポーネントとして切り出す設計があることは、知識としては知っていました。
たまにネットの記事やAIの提案で、XxxViewとXxxContainerを分ける例を見ることもありました。
しかし、そのたびに思っていました。
「まあ、きれいなのは分かるけど、ちょっとオーバーじゃない?」
今なら分かります。
オーバーなのではなく、目的が違ったのです。
useQueryやuseSearchParamsをコンポーネント内に直接書く設計は、画面単位では見通しがよくなります。必要なデータ取得や遷移処理が近くにあるので、見た目上は実装が追いやすいです。
しかし、それは「UIの部品単位として疎結合である」という意味ではありません。
「コンポーネントの機能単位としての区分け」と「UIの部品単位としての疎結合」は別物なのです。
私が「シンプルで見通しがよい」と思っていたものは、UIを表示する責務と、URLのルーティングやAPI通信といった外部依存が一体となり、それらがひとつのコンポーネントの中に密結合しているだけのものでした。
それはそれで、画面コンポーネントとしては悪くない場合もあります。
しかしStorybookで「UI部品として単体表示したい」と考えはじめた途端、こうした不適切な依存関係が一気に露出します。
短いコードと疎結合は、同じではありませんでした。
「UIの振る舞い」という言葉の罠
Reactが登場した当初、JSXのコンセプトは「HTMLをJavaScriptの中に書くのか」「ロジックとマークアップが密結合しているのではないか」と物議を醸したと聞きます。
しかしその後、Reactは広く受け入れられました。
HTML、CSS、JavaScriptを技術別に分けるのではなく、UIの構造・状態・振る舞いをコンポーネント単位でまとめる。これはたしかに強力な考え方です。
しかし私は、ここでひとつ勘違いをしていました。
「UIの振る舞い」と呼べるものの範囲を、広く取りすぎていたのです。
- この検索フィールドではクエリパラメータと連動するユースケースしかないから、
useSearchParamsを直接呼んでも問題ない。 - APIからデータを取って一覧表示するコンポーネントだから、
useQueryを使うのも自然。 - 処理結果としてURL遷移を伴うコンポーネントだから、
useNavigateを使うのも自然。
そんな風に考えていました。
そして厄介なのは、これが局所的にはそれなりに理屈が通っていることです。
検索フィールドが検索条件を知る。画像一覧が画像データを取得する。ページャーがクエリパラメータを更新する。userId のパスパラメータをURLから取得し、それを使ってAPI通信を行う。
どれも、そのUIのユースケースに関係があります。
しかし、Storybookに載せようとした瞬間に気づきます。
それらはUI内部の振る舞いではなく、Router、URL、API、QueryClientといった外部環境への接続でした。
本来であれば、「UIに関係すること」と「UIの責務であること」は分けて考えるべき でした。
この2つは違います。
useQueryはUIに関係しています。表示するデータを取ってくるからです。
しかし、それが表示部品の責務かというと、必ずしもそうではありません。
useSearchParamsも検索に関するUIに関係しています。
しかし、それはURLという外部状態との同期です。
つまり、UIに関係するからといって、UI部品そのものが直接持つべき責務とは限らないのです。
「この部品にはこのユースケースしかないから、外部依存を引き込んでも良いだろう」といった見方は、密結合を許す理由にはなります。
しかし、こうした密結合を許した時点で、UI部品として「画面表示」という単一の責務を受け持つ境界は失われてしまいます。
「UIに関係すること」と「UIの責務であること」は、異なるものである。
この区別を曖昧にしたまま、私は「UIの振る舞い」という言葉の名のもとに、外部依存をどんどん入れてしまっていたのです。
C#では自制が効いていたのに、Reactでは甘かった
ところで、私はフルスタックで実装や設計を受け持つエンジニアです。
フロントエンドだけでなく、バックエンドの設計・実装・レビューについても、C#(ASP.NET Core)やGo(Echo)で担当しています。
ここで振り返ってみると、私はバックエンドを書くときには、もっと依存の境界に敏感でした。
バックエンドの場合:オニオンアーキテクチャによる責務分離
バックエンドでは、オニオンアーキテクチャによるDDD(ドメイン駆動設計)を意識していて、設計やレビューでも各層ごとの責務と境界を守る観点を大事にしています。
- プレゼンテーション層のエンドポイント定義が、アプリケーション層のユースケースハンドラを呼ぶ。
- アプリケーション層のユースケースハンドラが、ドメイン層で定義されたビジネスロジックを利用する。
- DBやファイルストレージを扱うインフラ層への依存は、インターフェース越しに注入する。
- ビジネスロジックの定義が、DBや外部APIへ直接依存しないようにする。
このあたりは、バックエンドでは比較的説明しやすいです。
なぜなら、依存がコンストラクタ定義のシグネチャに現れるからです。
public sealed class UserApplicationService(
IUserRepository userRepository,
IClock clock,
ILogger<UserApplicationService> logger)
{
// ...省略...
}
こう書くと、嫌でも分かります。
このクラスはIUserRepositoryに依存している。IClockに依存している。ILoggerに依存している。外部への依存が、シグネチャとしてはっきり見えます。
また、プロジェクト(csproj)間の依存定義も関係しています。
C#の動作基盤である.NETでは、レイヤーごとにプロジェクトを分け、プロジェクト参照の向きを制限することで、依存方向をコンパイラにある程度守らせることができます。
たとえば、ドメイン層のプロジェクトがインフラ層を参照していなければ、ドメイン層のコードから MyApp.Infrastructure.Repositories にある型を使うことはできません。誤って使おうとしても、型が解決できずコンパイルエラーになります。
こうした仕組みが整っているので、バックエンド開発においては 「この依存は本当にここに置くべきか?」「このレイヤーからインフラ層に直接触っていないか?」といった問いが、目に見える形で発生しやすかった のです。
GUIアプリの場合:MVVMによる責務分離
また、これはバックエンドからは離れた話ですが、WPF、MAUI、AvaloniaといったC#でのGUIアプリ開発においても、私はMVVMによるレイヤー境界を意識していました。
- コードビハインドに平気でロジックを書かない。
- ビジネスロジックはModelに、画面表示に関するロジックはViewModelに置く。
- Source of truthとしての状態はModelに、画面にバインドする状態はViewModelに置く。
- 副作用をどの階層に置くかを考える。
そうした思想と美意識を、わりと強く持っていました。
苦労して設計したMVVMの構成を見て、「ああ、俺が組んだこのMVVM、美しい構造だな……」などと自画自賛することもありました。
React Hooksの場合:関数型コンポーネントの「気楽な美しさ」
そんな風に自分なりに設計への美意識を育ててきた私ですが、Reactになると、なぜか途端にそのあたりが甘くなっていたのです。
では、なぜ甘くなりがちなのか。
私はその原因として、Reactの長所である「React Hooksを軸とした、シンプルで気楽な設計哲学」に、悪い意味で引きずられてしまっていたところがあると自覚しています。
- 内部状態だろうと、外部依存だろうと、
useXXXのカスタムフックで一纏めにできる。 - 一つのUI部品を作るのに、いくつもファイルを作るような設計をしなくてもいい。
- 「状態を入力すれば、UIが返却される」という、関数型プログラミングの思想を反映したシンプルな設計哲学。
――いろいろな縛りがあるMVVMと比べると、なんと気楽なことでしょう!
特に私が痺れたのは、TanStack Queryの存在でした。
初めてこのライブラリの存在を知った時、「なんて革命的なんだ!」と感動したのを覚えています。
import { useQuery } from '@tanstack/react-query'
import axios from 'axios'
type ItemDetailsDto = {
id: string
name: string
description?: string
}
export default function ItemDetailsDisplay({ itemId }: { itemId: string }) {
const { data, isLoading, error } = useQuery({
queryKey: ['item-details', itemId],
queryFn: async () => {
const response = await axios.get<ItemDetailsDto>(`/api/items/${itemId}`)
return response.data
},
})
if (error) {
return <p>Error: {error.message}</p>
}
if (isLoading || !data) {
return <p>Loading...</p>
}
return <p>Item's name is {data.name}.</p>
}
useEffectや非同期処理を意識せず、普通のstateを読み取るような感覚で、API通信の状態を扱えます。
副作用はライブラリ内に隠蔽され、あたかもその結果の状態を「コンポーネント関数への入力値」のような形で扱えるようになっています。
まさに、「状態を入力すれば、UIが返却される」というReact Hooksの設計哲学を、最も強力に利用している代表例と言えるでしょう。
「React Hooksの方法論は、気楽さと美しさを兼ね備えている。」
「それに、サードパーティによるエコシステムだって、こうしたやり方にお墨付きを与えている。」
そんなふうに、私は思っていました。
実際、React Hooksの仕組みは、MVVMや(Elmish的な意味での)MVUよりも気楽で、しかもシンプルな美しさがあります。その気楽さやシンプルさこそが、Reactの魅力の一つだと言えます。
ただし、その気楽さやシンプルさによって、画面と外部依存の密結合まで気楽に許してしまっていたのではないか。
今回Storybookを検討する際、壁にぶつかって、私はそうした怠慢に気づかされました。
境界にうるさいMVVM、境界を溶かすReact Hooks
React Hooksに比べると、C#でのMVVMはたしかに面倒です。
ViewModel、Model、INotifyPropertyChanged、Command、Binding、Behavior、XAML……
ある機能を持ったUI部品を一つ表示するのにも、様々な仕組みの存在を前提として、複数層の間でのやり取りを書く必要があります。
こうした設計方針に沿っていれば責務分離は綺麗にまとまりますが、一方で「これはもはや『UI召喚の儀式』だな……」と感じることもしばしばありました。
しかし、そうやって毎回儀式を繰り返すことにも、意味はあったのです。
MVVMの面倒くさい儀式は、Viewと外部依存の境界を意識させる「警報」でもありました。
- ロジックの本体をModelを置いて、ViewModelに繋げる。
- ViewModelにはCommandを定義して、Model内のメソッドに透過的に繋げる。
- ViewModelのフィールドとCommandを、XAMLで定義されたViewにバインドする。
こうした儀式は面倒です。ですが、その面倒な儀式によって「今、自分はViewからロジックを遠ざけている」という意識が自然と生まれます。
一方でReact Hooksの場合、責務の境界を飛び越える際も、あまりにもスムーズで静かです。
状態、ロジック、副作用、UI表示…….
それらの全てがコンポーネントやカスタムフックという形をとって、まるで一体に溶け合うような形で配置できます。
useXxx()と書くだけで、外部依存が関数本体の中に入ってきます。
- オニオンアーキテクチャやMVVMには、境界を守るための段差が多く、大きい。
- React Hooksには、境界を越えるための段差が少なく、小さい。
この違いに気づいた時、私はハッとしました。
ReactにMVVMの重さをそのまま持ち込む必要はありません。
しかし、C#で大事に持っていたはずの「画面と外部依存を分ける警戒心」だけは、Reactにも持ち込むべきだったのです。
問題は知識の欠如ではなく、心理的コストだった
どのフック呼び出しも「全部同じ」に見えてしまう
ここで一つ、正直に懺悔しておきます。
私は、useQueryやuseNavigateが外部環境に依存していることについて、まったく理解していなかったわけではありません。
頭では分かっていました。
-
useQueryはAPI通信に依存している。 -
useNavigateはRouterに依存している。 -
useAuthは認証コンテキストに依存している。 - StorybookやVitestで単体表示や単体テストをするなら、本来は扱いづらくなる。
そうしたことまでは、自分でも分かっていました。
たまに良心も囁いていました。
「本当はこういうの、StorybookとかVitestのことを考えたらダメなんだろうな……」
しかし、次の瞬間、ついこう考えてしまうのです。
「でもまあ、MSWでAPIをモックすればいいし、StorybookのdecoratorでProviderを包めばいいし、ほかのやつについても、なんか専用のモックコンテキストとかあるんじゃない?
……まあ、なんとかなるっしょw」
問題は、外部依存に気づいていなかったことではありません。
気づいてはいたのです。
しかし、React Hooksの書き味が、外部依存を増やす際の「心理的コスト」を下げていたのです。
const { data } = useQuery(...)
const navigate = useNavigate()
const { user } = useAuth()
この形は、あまりにも軽いです。
コンストラクタに依存が並ぶわけではありません。
DIコンテナに登録するわけでもありません。
ViewModelやModelを作るわけでもありません。
ロジックのバインドのためにCommandを作るわけでもありません。
ただ、関数本体の中でuseXxx()を呼ぶだけです。
さらに言うと、フックの注入はどんな処理を行うものでも、コードでの見た目が均一に揃っています。
const disclosure = useDisclosure()
const debouncedValue = useDebouncedValue(value)
const { data, isLoading } = useItemListQuery(...)
const navigate = useNavigate()
const controller = useItemSearchController()
const [value, setValue] = useState('')
useDisclosureはUI内部状態かもしれません。
useItemListQueryはAPI通信を含んでいるかもしれません。
useItemSearchControllerは、Router、Query、Mutation、認証をまとめて扱っているかもしれません。
しかし、コード上の顔を見ると、どれもuseXxx()です。
この見た目の上での均一性が、境界を区別する認知を鈍らせます。
コードに並んでいるフックの顔は同じでも、やっていることの中身や責務の範囲は、お互い全く違うものなのに。
React Hooksの書き味の軽さによって、「これは本当にここで扱うべき依存なのか?」と立ち止まるための心理的コストが下がっていました。
UI内部状態を扱うフックと、外部世界に接続するフックを、同じノリで同一のコンポーネントにまとめて入れるべきではなかったのです。
副作用の定義は useEffectだけではない
また、依存関係への意識が溶けるという点でいうと、副作用への意識も関係しています。
Reactで副作用というと、stateの変化に応じた副作用を定義する useEffect を思い浮かべます。
コンポーネントやカスタムフックの中にuseEffect を書く時、「自分は今、副作用を書いている」という意識が自ずと生まれます。
しかし、設計上問題になるのは、useEffectを書いているかどうかということではありません。
重要なのは、そのコンポーネントが外部の世界を必要としているかどうかです。
たとえば次のようなフックの呼び出しは、いずれも直接useEffectを書いていなくても外部依存を持っています。
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const { data, isLoading } = useQuery(...)
const mutation = useMutation(...)
const { user } = useAuth()
これらを呼んだ瞬間、そのコンポーネントは何らかの外部環境への接続を必要とします。
つまり、これは純粋な意味でのUI表示のための定義ではなく、外部依存による副作用を前提とした定義なのです。
当然ですが、useQueryやuseNavigateを使うこと自体が悪いわけではありません。
悪いのは、そうした外部依存による副作用を使っているコンポーネントを、何も考えずに「UI表示のための部品」として扱ってしまうことなのです。
「めんどくさい」に引きずられていた
ここで、読者の皆さんの頭にはこのようなツッコミが浮かんでいるかもしれません。
「色々言ってるけど、単体テストを意識して組んでいれば、そんな風に外部依存が入り混じった設計にはならないんじゃないか?」
ここで、二つめの懺悔です。
ここまで私は偉そうにフロントエンドの設計境界について語ってきましたが、私が携わっているプロジェクトのフロントエンドには、現時点でテストが一行もありません。
package.jsonには、Vitestすら入っていません。
はい。自分でも「さすがにこのままだとヤバいかもな……」とは思っています。
一方で、バックエンドにはそれなりにテストがあります。
ただし、こちらも理想的な単体テストが揃っているわけではありません。
ドメイン層のビジネスロジックを細かく単体テストするというより、テスト用コンテナやインメモリのSQLiteを立ち上げた上で、ユースケース単位・シナリオ単位で動作を確認するテストが中心です。
それでも、少なくともバックエンドでは「テストを書いて動作を確認する」文化がありました。
フロントエンドには、それすらほとんどありませんでした。
では、なぜフロントエンドのテストを整えていなかったのでしょうか。
言い訳はいくつかあります。
- 今すぐ必要に迫られていなかったから。
- 画面を手で触れば確認できたから。
- APIの動作さえ保証できていれば、少しくらいフロントが壊れていても、致命的な炎上までは行かないだろうから。
- そして何より…… めんどくさかったから。
身も蓋もない話ですが、バックエンドに比べて、フロントエンドのテストを組もうとすると「うーん、めんどくさそうだな……」という感情が出てきてしまうのです。
ただ、今になって思うと、この「めんどくささ」は単なる怠惰ではなかった気がします。
一見きれいにまとまっているコードの裏側から、うっすらと怪しいニオイが漂っていたのです。
「このコンポーネント、テストを書くとなると、なんか沼りそうだな……」
「このカスタムフック、ちゃんとリファクタするとなると、面倒なことになりそうだな……」
「外部依存をどうモックするか、考えたくないな……」
「テストのしやすさ」という観点からフロントエンドのコードを読んだ時、そうした直感が次々と浮かんできます。
ですが私は、その直感を見なかったことにしていました。
「まあ、フロントのテストは手動でやればいいや。
今の書き方でも、保守性の観点では問題になってないしな。
こういう類の負債は、見なかったことにしとこう……」
そして今回、私は軽い気持ちでStorybookに立ち寄りました。
「いい機会だし、そろそろStorybookってやつでも始めてみますかw」
という、非常に軽いノリで。
その瞬間、これまで目を背けてきたコンポーネントやカスタムフックたちの声が、一斉に聞こえてきたのです!!
「おい、それならまず俺を直してくれよ…… 」
「私も、このままだとStorybookに載れません……」
「僕の中身、けっこうひどいよ……」
「あたし、あのProviderがないと生きられないの……」
「うわあああああ!!すまんかった!!呪わないで!!やめてーーー!!!」
Storybookは、私がこれまで見なかったことにしていたものを、かなり丁寧に、そして残酷に可視化してくれました。
それは単に「UIカタログを作るための道具」ではありませんでした。
私にとっては、見なかったことにしていた依存関係の除霊装置でした。
なお、後に述べますが、除霊には失敗しました👻
私は自身が生み出したコードたちの怨嗟の声に耳を塞ぎつつ、今回はそっと蓋を閉じました。
解決策の基本:表示部品と外部依存部品を分ける
今回の反省から、私はAI(ChatGPT 5.5 Thinking)と相談や議論を重ねた上で、次のように考えることにしました。
「表示に関するロジックと、外部への依存は、しっかり分離しておこう」 と。
ということで、以下からは解決策編です。AIが出してくれた提案について、私なりに咀嚼しつつまとめてみました。
コンポーネントをViewとContainerに分ける
UIコンポーネントには、大きく分けて2種類があります。
ひとつは、画面表示のための部品であり、Viewと呼ばれるものです。
これはpropsで必要な値を受け取り、ユーザー操作をコールバックで親に通知します。Storybookで表示しやすく、単体テストもしやすいです。
もうひとつは、外部依存をViewに差し込む部品であり、Containerと呼ばれるものです。
これはRouter、URL、API、認証などの外部依存を扱います。依存同士の連携を行い、結果となるstateやコールバックを内部にあるViewのコンポーネントに渡します。
たとえば、検索フィールドなら次のように分けられます。
interface HeaderSearchFieldViewProps {
value: string
onChange: (value: string) => void
onSearchSubmit: () => void
placeholder?: string
actions?: React.ReactNode
}
export function HeaderSearchFieldView({
value,
onChange,
onSearchSubmit,
placeholder = 'Search...',
actions,
}: HeaderSearchFieldViewProps) {
return (
<Stack direction="row" spacing={0.5} sx={{ alignItems: 'center' }}>
<SearchQueryTextField
value={value}
onChange={onChange}
onSearchSubmit={onSearchSubmit}
placeholder={placeholder}
sx={mergeSx(headerSearchBarSx, {
/* ...独自のstyle定義... */
})}
endAdornment={<Search />}
/>
{actions}
</Stack>
)
}
このViewコンポーネントは、Routerも、APIも、クエリパラメータも、認証も知りません。
知っているのは「渡されたデータをどう表示するか」と「ユーザー操作をどのcallbackで親に通知するか」だけです。
Storybookでは、ただpropsを渡せば表示できます。
export const Default = {
args: {
value: '',
placeholder: 'Search...',
onChange: fn(),
onSearchSubmit: fn(),
},
}
一方で、外部依存を扱うContainerは次のようになります。
import { useSearchParams } from 'react-router-dom'
export function HeaderSearchFieldContainer() {
const [searchParams, setSearchParams] = useSearchParams()
const [value, setValue] = useState('')
useEffect(() => {
setValue(searchParams.get('search') ?? '')
}, [searchParams])
const handleSubmitSearch = () => {
setSearchParams((prev) => {
const next = new URLSearchParams(prev)
const searchText = value.trim()
if (searchText) {
next.set('search', searchText)
} else {
next.delete('search')
}
return next
})
}
return (
<HeaderSearchFieldView
value={value}
onChange={setValue}
onSearchSubmit={handleSubmitSearch}
actions={<SearchDetailsDialogButtonContainer />}
/>
)
}
こちらはRouterを知っています。クエリパラメータを知っています。画面固有の状態を知っています。
RouterやURL、API、認証などの外部環境と接続し、その結果をViewが扱いやすいpropsへ変換する役割を持ちます。
Storybookにおいては単体表示する主役ではなく、アプリケーションの環境に接続する役割の方を持っています。
Viewには何を渡すべきか
ここでちょっと悩ましいのが、「Viewには何を渡すべきか」という問題です。
何でもprops経由で渡せばよいわけではありません。
Viewに渡すべきなのは、表示に必要な値と、ユーザー操作を表すコールバック、この2種類が基本になります。
たとえば、次のようなものです。
type GoodViewProps = {
value: string
items: SomethingListItemDto[]
isLoading: boolean
errorMessage?: string
disabled?: boolean
selectedIds: string[]
onChange: (value: string) => void
onSubmit: () => void
onRetry: () => void
onDeleteClick: (id: string) => void
}
逆に、次のようなものをViewに渡すのは危険信号です。
type BadViewProps = {
queryClient: QueryClient
queryKey: QueryKey
navigate: NavigateFunction
searchParams: URLSearchParams
apiClient: AppApiClient
authToken: string
routeMatch: AppPathMatch
}
これらは表示のための値ではなく、外部世界と接続するための情報です。
また、外部世界と接続する特定のライブラリの型情報に依存している点も気になります。
もちろん、Viewに特定のライブラリ由来の型を渡すこと自体が常に悪いわけではありません。
重要なのは 「その値がViewの内部において、どのような用途で使われているか」 ということです。
たとえば、itemNameを画面に表示するだけなら、それはViewに渡してよいでしょう。
しかし、itemIdを使ってURLを組み立てたりAPIを叩いたりするなら、それはContainer側の責務です。
- 外部の世界を触るUI部品は、ViewとContainerに分ける。
- Viewのprops設計は、「表示に必要な情報」と「ユーザー操作の通知」に寄せる。
- 外部世界との接続情報は、Container側に寄せる。
これが、まず守るべき基本方針になります。
すべてをViewとContainerに分けるべきか
ここまで書くと、「外部依存を持っているコンポーネントは、すべてViewとContainerに分けるべきだ」という話に見えるかもしれません。
ただ、少なくとも現時点では、そこまで機械的に分ける必要はないのではないかと考えています。
というより、すべてを一律に分けようとすると、今度はファイル数や抽象が増えすぎて、別のつらさが出てきそうです。
今回の反省を踏まえると、重要なのはすべてを分けることではなく、「何を単体で扱いたいのか」「どの外部依存をどこで受け止めるのか」を意識することなのだと思います。
たとえば、以下のようなコンポーネントは、ViewとContainerを分ける価値があります。
- Storybookで単体表示したい
- 再利用したい
- 単体テストしたい
- 外部依存が重い
一方で、Page専用のコンポーネントや、アプリ全体のコンテキスト込みでしか意味を持たないものは、無理に純粋化しなくてもよいでしょう。
その場合は、propsだけで表示できる純粋なStoryとして扱うのではなく、RouterやQueryClientProviderやMSWなどをDecoratorで用意し、アプリケーションの文脈込みで動かすStoryとして扱う方が現実的だと思われます。
解決策1:新規コードでは境界を決める
まずは、新規のコードでUI表示と外部依存の境界を定める構成例を示します。
コンポーネントの境界を作る
たとえば、以下のようなルールが考えられます。
-
components/配下にはViewを置き、原則としてprops駆動にする -
components/ではuseNavigate、useSearchParams、useQuery、useMutation、useAuthといった外部依存を直接呼ばない - Router、API、認証、ストレージ、windowなどに触る処理は、
containers/、pages/、controllers/に置く - Storybookに載せたいものは、Viewとして切り出す
ディレクトリ構成としては、たとえば次のようなイメージです。
features/
search/
components/
HeaderSearchFieldView.tsx
SearchResultListView.tsx
SearchResultCard.tsx
containers/
HeaderSearchFieldContainer.tsx
SearchResultListContainer.tsx
hooks/
ui/
useDisclosure.ts
useDebouncedValue.ts
useSelectionState.ts
external/
useSearchParamsState.ts
useCurrentRouteId.ts
useAuthSession.ts
controllers/
useHeaderSearchController.ts
useSearchResultListController.ts
pages/
SearchPage.tsx
api/
generated/
adapters/
stories/
HeaderSearchFieldView.stories.tsx
HeaderSearchFieldContainer.stories.tsx
重要なのは、外部依存を置く場所が予測できること です。
以上の例では、表示部品、外部依存と接続する部品、カスタムフックの種類を、ディレクトリによって見分けられるようにしておきます。
カスタムフックも分類する
今回の反省で、カスタムフックも分類が必要だと感じました。
先に述べていますが、カスタムフックというだけでは、どこに接続してどんな役割を担っているのか分かりません。
const disclosure = useDisclosure()
const debouncedValue = useDebouncedValue(value)
const { data, isLoading } = useItemListQuery(...)
const navigate = useNavigate()
const controller = useSearchController()
useDisclosureのように、UI内部状態を扱うだけのフックもあります。
一方で、useSearchControllerのように、Router、Query、Mutation、認証をまとめて扱うフックもあります。
どちらもカスタムフックですが、性格はまったく違います。
View / Containerのような形での責務分離を参考にして考えると、カスタムフックはざっくり次のように種類ごとに分けておくとよさそうです。
hooks/
ui/
useDisclosure.ts
useDebouncedValue.ts
useSelectionState.ts
useInputState.ts
external/
useSearchParamsState.ts
useCurrentRouteId.ts
useAuthSession.ts
controllers/
useSearchController.ts
useListController.ts
ui/ には、UI内部の状態だけを扱うフックを置きます。開閉状態、入力値、選択状態、debounce など、外部環境に接続しなくても成立するものです。
この種類のフックは、そのままViewの中に入れても問題ありません。
external/ には、Router、URL、認証、Storage など、外部環境に接続するフックを置きます。
これらはView の中に直接入れると Storybook や単体テストで外部依存が一気に露出します。Container や Controller でのみ扱うのがよいでしょう。
controllers/ には、UI状態と外部依存を組み合わせて画面固有の振る舞いを作るフックを置きます。たとえば、URLクエリを読み取り、検索条件を組み立て、API取得やmutationと連携し、Viewへ渡すpropsを作るようなものです。
こちらもViewの中に直接入れるのではなく、ContainerやPageで扱うのがよいでしょう。
アーキテクチャのイメージ
View / Containerのコンポーネントとカスタムフックの繋がりを図にすると、次のようなイメージです。
重要なのは、Viewが外部の世界を触るような部品(Container、External Hooks、Controller Hooksなど)に直接依存しないことです。
Viewの中でコンポーネントやカスタムフックを直接置いて使う場合でも、基本的には外部の世界に直接繋がっていない部品(ViewコンポーネントやUI Hooks)に限定するように設計します。
一方で、外部依存につながるExternal HooksやController Hooksは、ContainerやPageに寄せます。
リファクタリングの流れ
では、既存のコンポーネントとカスタムフックを上記の構成に合わせてリファクタリングするには、どうすればよいでしょうか。
まずは、コンポーネントが使っているカスタムフックから手を付けることにします。
たとえば、 useHeaderSearchField という名前で、RouterやQueryを触っているカスタムフックがあるとします。この内部では、UIの制御、URLの読み取り、API通信など、画面表示と外部依存の関心が混ざっている状態です。
このような場合だと、まずは一旦 useHeaderSearchController とリネームした上で hooks/controllers/ に置いたほうが正直です。
そのあとで、内容をUI HooksとExternal Hooksに分割して繋げていきます。
Controller Hooksは、Containerで使うUI HooksとExternal Hooksを繋ぐだけの役割になるのが望ましいです。もしくは、連携のためのコードがそこまで長くない場合、Container内で直接繋いでしまってもよいでしょう。
その後は、コンポーネントのリファクタリングです。
対象のコンポーネントをViewとContainerに分け、依存関係を意識しながらコンポーネントとカスタムフックを繋ぎ、ContainerからViewにpropsを渡す形にします。
解決策2:AIエージェントも使ってみる
上記のようなリファクタリングは、GitHub CopilotやClaude CodeなどのAIエージェントに手伝わせたくなります。
しかし、何も指定せずに「このコンポーネントをStorybookに対応させて」と投げると、かなり危険です。
表示と外部依存の境界をどうするのか。どこまでリファクタしてよいのか。Viewに渡すpropsの単位は何か。MSWを使うのか。既存の挙動を変えてよいのか。
こうした前提を決めずにAIに任せると、変更範囲が広がりすぎたり、一貫性がブレたりします。
そこで、プロジェクト用の小さなAgent Skillや AGENTS.md を用意するとよさそうです。
たとえば、GitHub Copilotで /create-skill コマンドを使う場合、以下のようなプロンプトでAgent Skillを作成します。
/create-skill
以下の目的と要件を満たすAgent Skillを作成して。
## 目的
既存のReactコンポーネントおよびカスタムフックについて、Storybook対応またはリファクタリングするため、UI表示関連と外部依存を分離する。
## 要件
### Viewコンポーネントの切り出し
- Storybookで単体表示したいUIは、props駆動のViewコンポーネントとして切り出す
- Viewコンポーネントは、表示に必要な値と、ユーザー操作を表すコールバックだけをpropsで受け取る
- Viewコンポーネントでは、React Router、TanStack Query、認証、APIクライアント、Storage、Windowなどの外部依存を直接扱わない
### Viewに渡すprops
- QueryClient、navigate、URLSearchParams、auth token、API clientなど、外部世界と接続するための情報をViewのpropsに渡さない
- 追加ボタンや補助UIをViewに差し込む必要がある場合は、画面固有の名前ではなく `actions` のような汎用的な名前のスロットとして渡す
### Container / Page / Controller Hookの責務
- 外部依存はContainer、Page、Controller Hookに寄せ、Viewが扱いやすいpropsへ変換する
- Router、URL、API、認証、Storage、Windowなどに接続する処理は、ViewではなくContainer、Page、Controller Hookで扱う
### カスタムフックの分類
- カスタムフックは、UI内部状態を扱う `hooks/ui/`、外部環境と接続する `hooks/external/`、それらを組み合わせる `hooks/controllers/` に分類する
- Viewから `hooks/external/` や `hooks/controllers/` を直接使わない
- Viewの中で使うカスタムフックは、基本的にサードパーティのライブラリを含めて、 `hooks/ui/` に置けるような外部環境に接続しないものに留める
### 変更時の注意
- 変更時は既存の見た目と挙動を維持する
- Storybook対応と関係ないリネームや大規模な整理は、同じ変更に混ぜない
このくらいのルールがあるだけでも、AIに作業させるときのブレは減るはずです。
今回のケースに限りませんが、AIエージェントを効果的に使う際のポイントとしては、AIに設計判断を丸投げしないことが重要です。
今回のように新しく設計境界を設ける際は、人間が最初にAgent Skillなどのハーネスでその境界の定義をしっかり決めておき、AIにそのルールに従って作業させるのが望ましいです。
解決策3:既存コードはdecoratorで包んで妥協する(暫定策)
理想を言えば、最初からViewとContainerの境界を意識して設計するのが一番です。
しかし現実には、すでに多くのコンポーネントが外部依存を抱えていることもあります。
そうした状況で全コンポーネントを一気にView / Containerへ分離しようとすると、Storybook導入のはずが、大規模リファクタリング大会の幕開けになってしまいます。
また、先に示したようなAgent Skillを使ったとしても、量が多いとやはりかなり大変です。
今すぐに手が付けられない既存コードに対しては、次のように対応するのが現実的だと考えられます。
まず、Storybookで使うモック用のdecoratorを用意します。Router、QueryClientProvider、ThemeProviderなど、既存コンポーネントが必要とするProviderを包みます。
export function StoryAppProviders({
children,
initialEntries = ['/'],
}: {
children: React.ReactNode
initialEntries?: string[]
}) {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
staleTime: Infinity,
gcTime: 0,
refetchOnWindowFocus: false,
},
},
})
return (
<MemoryRouter initialEntries={initialEntries}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</MemoryRouter>
)
}
API通信がある場合は、MSWでmockします。
import { http, HttpResponse } from 'msw'
export const handlers = [
http.get('/api/items', () => {
return HttpResponse.json({
items: [
{
id: 'item-1',
name: 'Sample Item',
description: 'This is a sample item for testing purposes.',
},
],
totalCount: 1,
})
}),
]
このような方法でモックを用意してdecoratorで提供することで、外部依存を抱えたコンポーネントでも、とりあえずはStorybook上で表示できる導線を整えることが出来ます。
これは王道ではないかもしれません。本来なら、表示系と外部依存をきちんと分けるべきなのでしょう。
しかし、Storybookにはdecoratorがあります。MSWもあります。RouterやQueryClientProviderで包めば、外部依存を抱えたコンポーネントでも、とりあえずStorybook上で動かすことはできます。
まとめると、既存コードについては以下のような方針が現実的かと思います。
- いったんはdecoratorで包んでおいて、お茶を濁しておく
- 余裕がある時に、少しずつView / Container構成へのリファクタリングを行う
- 既存コードは全部を一度に直すのではなく、Storybookを書いていて、つらかったところ・気になったところから直していく
実際、これである程度はなんとかなるとも思います。
(だからこそ、余計にタチが悪いのですが……)
とはいえ…… 今すぐ全部やるのはつらい
ここまで書いておいて何ですが、実際にここまで手を付けるとなると、かなり大変です。
Agent Skillを使ってリファクタしたとしても、相当数の手間がかかるケースは多いと思います。
私が現在受け持っている案件で言えば、片方はリリース間際のラストスパートです。もう片方は、すでにかなりの規模まで育ったプロダクトです。
私は悩みました。
「今、手を付けるべきか?いや、あるいは……」
そして、私は決めたのです。
「直したいのは山々だけど…… 今回は、ちょっとしょうがない。
今の時点では、見なかったことにしよう。」
ここで、三つ目の懺悔です。
私は今回、「戦略的撤退」を選ぶことにしました。
別に、今まさにStorybookがないせいで開発が詰まっているわけでもありません。
アプリの規模的にも、今のところは「あればいいな」くらいのものです。
いつものように、Docker Composeと、バックエンドと、Viteを立ち上げてデバッグする。
フロントのテストは、実際にブラウザから触って確認する。
うん、今までどおり。とりあえずは、それでいいじゃないか。
AIと相談しながら、この記事を書いた。
学んだ。懺悔した。方向性も、ちょっと見えてきた。
それができたから、収穫としてはもう十分だ。
落ち着いたタイミングで、またじっくり検討することにしよう。
慣れないことで頭を悩ませる前に、今はまず、動くものを作って出さなきゃ!
だから…… 今回は、何も見なかったことにしよう!
――さて、気を取り直して、リリース前の最後の追い込みに戻るとするか。
がんばるぞー!🏃
――こうして、負債の亡霊は今日も着実に生まれ続けていくのでした👻
おわりに:あえて「邪道」を引き受けるということ
ここまでの懺悔をまとめると、だいたいこうです。
- ダメなのは薄々分かっていたが、React Hooksの見た目が軽すぎて、「責務分離」「UIの振る舞い」といった概念を自分の都合のいいように解釈していた。
- フロントエンドのテストやStorybook対応は、正直めんどくさかった。しかも、それはどう見てもパンドラの箱だった。
- 今は細かい設計境界よりも、リリースが優先。戦略的撤退。まずは動くものを届けたい。
――こうして並べると、なかなか情けないことになってしまいました。
とはいえ、今回の件は、私の中で学びが大きかったです。
Storybook対応は、単なるUIカタログ作成ではありませんでした。
場合によっては、コンポーネントの責務、外部依存、カスタムフックの置き場所、propsで渡すべき単位を見直すきっかけになります。
そして、それはプロダクトが育ち切ってからだと、かなりしんどいです。
短いコードと疎結合は、同じではありませんでした。
React Hooksの気楽さは、外部依存を増やす心理的コストを下げます。
そしてStorybookは、その依存境界を映す鏡だったのです。
そして同時に、今回、身をもって感じたことがあります。
「これはちょっと無理そうだな」と思ったら、潔く身を引くのも手です。
ある程度の負債を引き受ける覚悟があれば、ときには「王道の正解パターン」ではなく、あえて「邪道のアンチパターン」を引き受ける手もあるのです。
技術的にはヘタレな選択になってしまったかもしれません。
しかし、代わりとなる退路やトレードオフを踏まえた上でなら、このような戦略的撤退もまた、エンジニアリングの一つの形なのではないか。
そんなことを、思ったり、思わなかったりするのです。
以上、私の懺悔と備忘録でした。