Skip to content

Metanaka Nuxt Style Rule

この repository の Nuxt code を書く・直す・レビューするときの総合ルール。

Architecture and Placement

Feature Slice Architecture を採用する。 Nuxt project root の app/features/ に feature 実装を置き、Nuxt で慣習的な app/composablesapp/components に feature code を集めない。

Nuxt の route file は app/pages/ に置き、薄く保つ。 Feature の状態、validation、API result handling、主要な状態遷移は app/features/<feature>/ に置く。

feature directory はフラットに保つ。feature の中に components/composables/model/tests/ のような役割ディレクトリを作らない。 例外として、feature-local な翻訳ファイルだけは i18n/ に置く。

text
apps/nuxt/app/features/<feature>/
  FeatureView.vue
  use-feature.ts
  use-feature.test.ts
  model.ts
  i18n/
    ja.json
    en.json

ファイルの役割は名前で表す。

  • .vue: feature の UI component、または feature の page 相当 component。表示、props/emits、軽い computed、event handler、feature composable の呼び出しを担当する。
  • use-*.ts: UI から使う stateful composable。外部境界は shared composable や adapter を module mock して useXxx の public contract で検証する。
  • model.ts / *-model.ts: Zod schema、domain type、factory、状態遷移、validation 関連型、feature 固有の API response / result 型。
  • *.test.ts: 対象 code と同じ slice に置く Vitest unit test。
  • その他の kebab-case .ts: feature-local な utility または adapter。
  • i18n/{ja,en}.json: feature が所有する文言。

shared/ に移すのは、少なくとも2つ以上の feature から必要になったとき、または design-system primitive のようにリポジトリ全体の contract として定義する意図があるときに限る。 利用箇所が1箇所であっても、特定 feature の業務知識を持たず、今後も app 全体の contract として扱いたいものは shared 配下に置いてよい。

app/shared/ は、共有概念ごとの slice として整理する。 新規 shared code や移動を伴う変更では、components/composables/model/utils/ のような役割別ディレクトリを shared 直下に増やさず、共有概念の app/shared/<kebab-case-concept>/ に model、utility、composable、adapter、unit test、i18n を同じ slice として置く。 既存の role directory にある code を小さく修正するだけなら、不要な移動を避けて近傍に合わせてよい。

text
apps/nuxt/app/shared/
  components/
    ui/
      DadsButton.vue
      DadsTextInput.vue
  server-error/
    model.ts
    get-server-error-keys.ts
    get-server-error-keys.test.ts
    i18n/
      ja.json
      en.json
  auth-api/
    create-auth-api.ts
    create-auth-api.test.ts
    model.ts
  • shared/components/ui/: リポジトリ全体の UI primitive と design-system wrapper。Reka UI primitive はここで wrap し、feature code から Reka UI を直接 import しない。
  • shared/<concept>/model.ts: その共有概念の domain type、value object、公開型。
  • shared/<concept>/use-*.ts: その共有概念の composable。
  • shared/<concept>/*.ts: その共有概念の utility、adapter、resolver。
  • shared/<concept>/*.test.ts: 対象 file と同じ slice に置く unit test。
  • shared/<concept>/i18n/{ja,en}.json: その共有概念が所有する app-wide な文言。

例:

  • shared: server error code と表示用 message key の対応、ProblemDetails を表示用 key に変換する処理。
  • feature: host の room create validation error を扱う処理。

命名は次の方針に揃える。

  • feature directory と Vue 以外の TypeScript file は kebab-case。
  • Vue component file は PascalCase。
  • export する TypeScript symbol は用途に応じて camelCase または PascalCase。
  • composable は use- で始める。
  • test は対象 file 名に合わせる。例: use-room-create.tsuse-room-create.test.ts
  • feature-specific API adapter は、1 feature だけで使う間は app/features/<feature>/<feature>-api.ts のように feature slice に置く。2 feature 以上から使う、または app-wide contract として扱う意図が出た時点で shared concept へ移す。

Vue component

Vue component は domain logic や主要な状態遷移を抱え込まず、composable と shared UI wrapper を組み合わせる。

  • <script setup lang="ts"> を使う。
  • <script setup lang="ts"> 内の handler は arrow function にする。
  • computed の getter、watch callback、event handler も arrow function にする。
  • Reka UI primitive を feature から直接 import せず、app/shared/components/ui/ などの wrapper を使う。
  • form submit や click handler は小さく保ち、validation、API result handling、主要な状態遷移は composable に委譲する。
  • component 内に domain logic を溜めず、純粋関数または composable に切り出す。
  • composable の結果を受けた画面固有の routing や query 組み立ては、handler が薄い限り Vue component に残してよい。
  • ユーザーに見える文言は useI18n().t(...) で key から表示する。
  • validation error は composable/model が持つ i18n key を UI 境界で t(key) する。
  • 成功時の単純な route 遷移は Vue component の薄い handler に残してよい。server result の分類、error mapping、retry、pending 制御は composable に置く。

RoomSetup.vue のように、画面は「選択肢」「submit handler」「template の接続」程度に留める。 ただし、画面固有の event handler、routing、query 組み立てまで何でも composable に押し込まない。 domain / state / API 境界は composable に寄せ、button click から composable を呼び、成功時にその画面の遷移先へ進む程度の処理は Vue component に書いてよい。

vue
<script setup lang="ts">
import DadsButton from "../../shared/components/ui/DadsButton.vue"
import DadsField from "../../shared/components/ui/DadsField.vue"
import DadsHeading from "../../shared/components/ui/DadsHeading.vue"
import DadsPanel from "../../shared/components/ui/DadsPanel.vue"
import DadsTextInput from "../../shared/components/ui/DadsTextInput.vue"
import { useRoomCode } from "./use-room-code.js"

const { t } = useI18n()
const { roomCode, error, getRoomId } = useRoomCode()

const handleJoin = async () => {
  const roomId = await getRoomId()
  if (!roomId) return
  await navigateTo({ path: "/room/join", query: { roomId } })
}
</script>

この例では、room code の状態と roomId 解決は useRoomCode が持ち、どの route へ遷移するかは画面の責務として Vue component に残す。 同じ flow を複数画面で再利用する、遷移条件が複雑になる、pending / toast / analytics などが増えて handler が読みにくくなる場合にだけ、routing を含む composable への切り出しを検討する。

Composable

composable は、まず UI から見える useXxx の public contract を小さく保つ。 外部境界は shared composable、API wrapper、repository adapter などの小さな module に寄せ、unit test では vi.mock で差し替えて useXxx を直接検証する。 テストのために useXxx とは別の composable を作らない。

ts
export const useFeature = () => {
  const { $authApi } = useNuxtApp()
  const serverErrorKey = ref<ServerErrorKey[]>([])

  const submit = async (): Promise<FeatureResult> => {
    const result = await $authApi.requestApi<FeatureResponse>("/things", {
      method: "POST",
    })
    serverErrorKey.value = getServerErrorKeys(result)
    return result
  }

  return { submit, serverErrorKey: readonly(serverErrorKey) }
}

composable の state は次の方針で扱う。

  • UI が編集する form は writable な ref として返す。
  • UI が読むだけの state は readonly(...) で返す。
  • refreactive の更新は UI state の境界として扱い、計算可能な値は computed または純粋関数に寄せる。
  • 現在時刻、random、router、HTTP、Firebase、localStorage は直接 pure function に入れず、引数または依存として渡す。
  • pending guard を入れ、多重送信時は client result を返す。
  • request 前に server error を clear し、validation 失敗時も server error を clear する。
  • try/finally で pending を必ず戻す。
  • server result は呼び出し元へ返し、UI は成功時の遷移など画面固有の処理だけを行う。

Model and Validation

form、validation、error shape は model に寄せる。

  • form、request body、URL/query 由来などの user input の shape・値制約 validation は、原則として必ず Zod schema を通す。
  • composable 側で trim()、空文字チェック、数値範囲チェック、field name からの error key 組み立てを個別に実装しない。
  • pending guard、認証状態の確認、同一値なら API を呼ばないなどの flow control は Zod validation ではなく composable の責務として扱う。
  • Zod schema の message には表示文ではなく i18n key を入れる。
  • z.infer<typeof schema> から form type を作る。
  • field が 1 つだけの form でも object として扱い、default form と empty error は factory function で作る。
  • field error は Record<keyof Form, string> を基本にし、空文字で error なしを表す。
  • API response type と、その response を包む feature 固有の Result type は feature の public contract として model.ts または *-model.ts に置く。use-*.ts の中に閉じた local type として定義しない。

Zod issue から field error へ変換するときは shared utility の applyZodFieldErrors を使う。 composable 側で validation.error.issues[0]?.message を直接読む、field name から i18n key を組み立てる、fallback の i18n key を直書きする、といった処理は避ける。 field が 1 つだけの form でも、model に form factory、Record<keyof Form, string> の error shape、empty error factory を置き、同じ shared utility で error key を取り出す。

優先する書き方:

ts
const validation = userAccountFormSchema.safeParse(form.value)
if (!validation.success) {
  formError.value = applyZodFieldErrors(
    createEmptyUserAccountFormError(),
    validation.error.issues,
  )
  return { ok: false, source: "client", reason: "validation" }
}

避ける書き方:

ts
const validation = userAccountFormSchema.safeParse(form.value)
if (!validation.success) {
  errorKey.value =
    validation.error.issues[0]?.message ??
    "userAccount.error.displayNameRequired"
  return { ok: false, source: "client", reason: "validation" }
}

Result and Errors

server request は shared の Result 型と request helper の contract に合わせる。

  • 成功は { ok: true, data }
  • validation や pending のような client-side failure は { ok: false, source: "client", reason }
  • server failure は { ok: false, source: "server", error }
  • server error code は shared の resolver で i18n key に変換する。
  • form field error と server error key は別 state にする。

UI が raw ProblemDetails や HTTP status を直接解釈し始めたら、composable または shared concept に責務を戻す。

Unit Test

composable、model、utility の振る舞いは Vitest で直接検証する。

  • test file は対象 file と同じ feature/shared slice に置く。
  • describe は対象 composable / module 名にする。
  • it のタイトルは日本語で、前提または操作と期待値が分かる形にする。
  • 外部境界は shared composable や adapter module を vi.mock で差し替える。
  • validation failure、pending guard、成功 request、server error mapping、error clear、reject 時の cleanup を確認する。
  • async の途中状態を検証する場合は deferred Promise を最後まで解決し、未解決の Promise を残さない。

TDD 対象なら、最初にテストリストを作り、ひとつずつ RED/GREEN/REFACTOR を進める。

i18n

i18n source は文言を所有する概念の近くに置く。

  • feature 固有の文言は apps/nuxt/app/features/<feature>/i18n/{ja,en}.json に置く。

  • 共有概念の文言は apps/nuxt/app/shared/<concept>/i18n/{ja,en}.json に置く。

  • 生成先の locale file だけを Nuxt project root の i18n/locales/{ja,en}.json に置く。

  • 既存の横断 i18n directory と concept slice が混在している場合、新規 source と移動を伴う変更では app/shared/<concept>/i18n/{ja,en}.json を優先する。既存横断 directory の小修正だけなら不要な移動を避ける。

  • namespace は所有する feature または shared concept 名を camelCase にしたものを使う。

  • leaf key は短い camelCase にする。

  • Zod validation message も同じ namespace の key にする。

  • ja.jsonen.json の key set を揃える。

  • generated locale file を直接編集しない。

i18n source を変更したら Nuxt project root で pnpm run i18n:merge を実行する。

Verification

変更内容に応じて狭く確認する。

  • composable/model/utility/test: pnpm test:unit -- <path/to/test.test.ts>
  • TypeScript、Vue props/emits、composable 戻り値、i18n key、route、Firestore 保存型、test type に触れた場合: pnpm tc
  • i18n source を触った場合: pnpm run i18n:merge
  • UI の見た目や主要操作を変えた場合: dev server と Playwright / screenshot で確認する。

Reference Examples

references/room-setup/ に、現在の apps/nuxt/app/features/room-setup を同梱している。

  • RoomSetup.vue: Vue component が composable と shared UI に委譲する例。
  • use-room-setup.ts: useRoomSetup の public contract を直接 test する例。
  • use-room-setup.test.ts: validation、pending、server error、cleanup を検証する例。
  • model.ts: Zod schema、form type、error type、factory、response type の例。
  • i18n/ja.json / i18n/en.json: feature-owned locale source の例。