Todoリスト育成日記
ディレクトリ構成
App Routerで、こうなってる
.
├── app
├── common
│ ├── components
│ │ ├── functional
│ │ ├── layout
│ │ └── ui
│ ├── hooks
│ └── lib
│ └── date
├── model
│ ├── todo
│ │ ├── components
│ │ ├── hooks
│ │ ├── lib
│ │ └── query
│ └── user
│ ├── components
│ ├── hooks
│ ├── lib
│ └── query
├── public
└── styles
ファイルまで書くとこう
.
├── app
│ ├── favicon.ico
│ ├── layout.tsx
│ ├── page.stories.tsx
│ ├── page.tsx
│ └── providers.tsx
├── common
│ ├── components
│ │ ├── functional
│ │ ├── layout
│ │ └── ui
│ ├── hooks
│ │ └── debounce.ts
│ └── lib
│ ├── cn.ts
│ ├── date
│ ├── error.ts
│ ├── fetcher.ts
│ ├── generateId.ts
│ ├── getInitial.ts
│ ├── guard.ts
│ ├── log.ts
│ ├── result.ts
│ └── schema.ts
├── model
│ ├── todo
│ │ ├── atom.ts
│ │ ├── components
│ │ ├── hooks
│ │ ├── index.ts
│ │ ├── lib
│ │ ├── mock.ts
│ │ ├── query
│ │ └── type.ts
│ └── user
│ ├── atom.ts
│ ├── components
│ ├── hooks
│ ├── index.ts
│ ├── lib
│ ├── mock.ts
│ ├── query
│ └── type.ts
app/common/modelで分けてる
- app: routing + page特有のモジュール
- common: どこでも使う、他のプロジェクトにも使い回せる、low-context
- model: アプリケーション特有のモデルに関心があるモジュール、high-context
features/xxx
は「ディレクトリの機能的凝集」という本質的な良さがある一方で、機能同士の結合度が高いとき(例: 閲覧-プレビュー-編集)に分割基準をどこに置くかが難しいという問題があるのでnot for meだった。featuresを単にmodelとするだけで、線引が明確になることに気がついた。
実際workしていると思う。
モデル定義は model/xxx/type.ts
などでやる
たまたまvalibotでやったけど、valibot良かった
前まではcomponents/以下だけmodelを置いてて、libとかでもcommonとmodel分けたいなあっていう悩みがあった(正確には、とりあえずapp/でコロケーションさせてたものがページをまたいで使われるようになったときにどこに置くか迷った)のだが、そういう悩みが一切なくなった。
todo/lib/とかはこんなかんじ
モジュールの命名とかも意識はしてて、例えばuseDeleteTodo
はmodel/todo/hooks/delete.ts
においてある。これをmodel/todo/hooks/useDeleteTodo.ts
とかいうファイル名にしないほうがいい。todo/hooks/の時点でuse〇〇Todo
が導けるので、〇〇のところをファイル名に書くだけでいい。
コンポーネントの命名・ファイル名も同様で、なるべくコンパクトで納得感が高いようにしている。
import { TodoTableRow } from "@/model/todo/components/table/row";
model/xxx/以下のモジュールは必ず名前にxxxが入るようにする。commonや他のモデルのモジュールとと区別するため。
エラーハンドリング
この記事を全体的に参考にして、Result型を使っている。
カスタムエラーを書いて
Result型を書いて(今回はoption-tを使用)
API callを若干抽象化して
エンドポイントごとにquery
関数を定義している
valibotのsafeParseはもともとResult型っぽいインターフェイスだったが、プロジェクトのResult型に合わせた。
try/catch書いてた頃より断然気が楽な感じがする。コントロールできている感じ、というか。
globalにDialogを仕込んで、エラーのときはとりあえずDialogを出すようにしている。
shadcn/ui/toastみたいな感じで抽象化してあるけれど、Errorのためのより具体的な呼び出し関数も予め用意している
isErrの確認のときにほんまにエラーだったらloggingするようにしている。これも完全に最初の記事の受け売り。
これもあってもいいかもしれないけどまだやってない
State設計
Server StateはTanstack、それ以外のGlobal StateはJotaiでちょこちょこ書いている感じ
App RouterのキャッシュとTanstackのキャッシュが二重にあって色々うざいなあって思ってたけどまだちゃんと向き合ってない
TodoリストをリストのStateだけで持つと各アイテムの要素の変更を書くのがだるいので、atomFamilyを使ってアイテムのStateを分離して保持している。
二重Stateっぽさは否めないものの、setStateは直感的だし勝手にOptimisticになるし結構気に入っている
リストStateから各アイテムを変更するのはかなりだるくて、Result使っているとなおさらだった(一度検証しなきゃなので)
fetchingが絡むとき、T[]に対する操作はTanstackで状態管理して、Tに対する操作はatomFamilyで状態管理するのは、これからもちょくちょく使うかも。
そういえば、NextのRequest dedupingがうまく効いてなくて、このコンポーネントを複数並べたときにコンポーネントの数だけ/usersへのリクエストが飛んでたことがあった。当時はuseQueryで書いてた。
atomWithQueryにしたことでデータフェッチがコンポーネントの外側で行われるようになったので、1回で済むようになった。
こゆこと
いやまあ、Nextが期待通りにworkするのが一番なんだけれど。
Suspense設計
ページでfetchするとgetServerSidePropsみたいにall-or-nothingになっちゃうのでコンポーネント側でなるべくデータ取得するのは当然として、データ取得層はContainerとして分けるようにしている。
Tanstack Queryはsuspense modeのときにundefinedを消して欲しい
大体イメージこんな感じになる
some-component
├── container.tsx
├── index.tsx
└── skeleton.tsx
<Suspense fallback={<ComponentSkeleton />}>
<ComponentContainer />
</Suspense>
Containerをコンポーネントの名前に含めることで、どれを<Susupense>で囲うべきなのか明示する。これもESLint rule書けそう。
データフェッチもmodel側で完結するので、app側でやることは本当にただの組み立てだけだなーって感じ。
一方で、コンポーネントの使用箇所ごとにfetchのoptionsを指定し直したかったらそう簡単にはいかなさそう。
まあそれもRoute Group側の設定で書けばいいのか。
containerもSkeletonもindexからexportしたいのはある
ので、こうでも良いかも。
some-component
├── container.tsx
├── index.tsx
├── skeleton.tsx
└── view.tsx
単純なTodoリストのつもりだけど、それでも作るのこんなにむずいんだなっていう感想を持った。そりゃソフトウェア開発むずいわ。
たぶん10hも手を動かしてないと思うけど、その間にも設計の改善のために右往左往リファクタした。思いがけずいい経験になったし、やっぱり設計に向き合うのは好き。
チーム開発するとなると、設計思想は全員で共有してこそはじめて威力を発揮するので、言語化・布教活動も頑張っていきたい。特にディレクトリ構成みたいな大枠でスムーズに合意したい。
まだ全然触ってないとこあるし、例えばo-tel入れてみたりOAuth実装してみたりCI/CDちゃんと組んでみたりServer Actions使ってみたりと試せることはたくさんあるので、気が向いたら都度育てていきたい。