SCSKの畑です。
今年度の Web アプリケーション開発関連のテーマは大体書きたいもの書けたからもう良いかなと思ってたんですが、本件がそれなりに大変だったことを今更思い出したので備忘として残しておこうと思います。
背景
本 Web アプリケーションの開発を始めたのが 2024 年の 5 月頃だったと思うのですが、その時点での最新版は Nuxt.js が 3.x 系、Nuxt UI が 2.x 系でした。事前調査で Nuxt.js は 2.x 系と 3.x 系で仕様がかなり異なることが分かっていたので最初から 3.x 系を入れたのですが、Nuxt UI の 3.x 系のリリースは今調べたら2025 年の 3 月ということでそもそも選択肢に上がらず。
今年度も当初は(他に優先すべきタスクがあったこともあり)特に移行することは考えていなかったのですが、お客さんから要望頂いた機能を実装するのに以下 URL のコンポーネントをどうしても使いたくなってしまい。更にその頃には既に Nuxt.js / Nuxt UI 共に 4.x 系がリリースされ始めており、Nuxt.js はまだしも Nuxt UI は 2.x からそろそろ上げておかないと EOL になってしまうかも?と思ったこともあって、少し手が空いたタイミングでやってしまうことにしました。
ちなみに Nuxt UI 4.x では 2.x や 3.x では有料だった Pro コンポーネントが使用できるようになったため一気に 4.x に移行してしまうことも考えたのですが、その場合 Nuxt.js も 4.x 系への移行が必要になりそうだったので今回のタイミングでは断念しました。
移行ガイド
公式から移行ガイドが出ているので、まずはそれを見ながら進めていくことになります。特に 2.x / 3.x の非互換については「Changes from v2」セクション以降にまとまっているため、このセクションの内容については必ず確認しておきましょう。

ただし、残念ながら非互換となる項目が網羅されている訳ではないようで、他にも動かないコンポーネントが大量に出てくる有様だったため、最終的にはほぼ全てのコンポーネントについて Nuxt UI のドキュメントとにらめっこしながら修正していくことになりました。
ということで、あくまで今回のケースに関する内容にはなりますが、上記 URL 以外の観点で修正が必要だったコンポーネントとその内容をざっくりまとめてみました。
個別に修正したコンポーネント
以下、順番に記載していきます。
FileUpload (Input から変更)
一部画面でブラウザからファイルをアップロードするために UInput コンポーネントを使用していたのですが、3.x に移行後は正常に動作しなくなってしまいました。3.x のマニュアルを見る限り使用方法は変わらないように見受けられたので原因が良く分からなかったのと、複数ファイルを同時にアップロードする要件も出てきたことから、コンポーネント自体を 3.x で追加された UFileUpload に変更することで解決しました。今思うと、UForm の validate のロジックに原因があった可能性が高そうですが・・
Modal
2.x のサンプル実装だと以下 URL のように UCard と合わせて使用されているのですが、これをそのまま 3.x で動かしたところ UCard 部分が悪さをしているのか画面レイアウトがおかしなことになってしまいました。画面レイアウト上 UCard の使用がマストではなかったため、使用しない実装に変更しました。

Progress
インジケータの進捗状況を示す value プロパティが v-model ディレクティブに変更されています。使い方自体はこれまでと大きく変わりません。
SelectMenu
プルダウンメニューのコンテンツを指すプロパティが options から items に変更されている他、プルダウンメニューにおけるラベルと値をコンテンツのプロパティにバインドする方法も変わっています。
2.x だと option-attribute でラベル、value-attribute で値のプロパティを指定していましたが、3.x ではラベルのプロパティは label 固定で、値のプロパティを指定する場合は value-key を使用します。なお、2.x/3.x どちらも値のプロパティを指定しない場合は選択したメニュー項目に対応する全ての値がバインドされるようです。
Table
テーブルのコンテンツ(行データ)を指すプロパティが rows から data に変更されている他、列情報の定義方法(指定すべきプロパティ)も変更されています。また、テーブルデータを変換・加工してテーブル内に表示する場合や、何らかのアクションボタンなどをテーブルデータとは別の列として表示したい場合の実現方法が変わっています。
2.x の場合は列定義に対象列の情報のみを含めた上で、template 構文の中で列定義(key プロパティ)に対応した名前付きスロットを定義して行う形式でした。以下、該当部分を抜粋した実装例です。
<UTable :columns="TableCols" :rows="TableRows" :loading="!TableLoadStatus">
<template #status-data="{ row }">
<div class="flex items-center place-content-center">
<MTStatusBudge :table_name=row.name :display_normal=true class="ml-2"/>
</div>
</template>
<template #update-data="{ row }">
<UButton icon="i-material-symbols-edit" size="2xs" variant="outline" @click="getSettingModal('update_table', row.name)"/>
</template>
<template #delete-data="{ row }">
<UButton icon="i-material-symbols-delete" color="pink" size="2xs" variant="outline" @click="deleteTable(row.name, row.logi_name"/>
</template>
</UTable>
<script setup lang="ts">
TableCols.value = [
{label: "論理名", key: "logi_name", sortable: true },
{label: "物理名", key: "name", sortable: true },
{label: "編集可能組織", key: "groups" },
{label: "ステータス", key: "status", sortable: true },
{label: "ロック元テーブル", key: "locked_by", sortable: true },
{label: "編集者", key: "editor", sortable: true },
{label: "承認者", key: "author", sortable: true },
{label: "更新内容", key: "temp_changes" },
{key: "update" },
{key: "delete" },
]
</script>
3.x の場合は、以下のように template 構文で定義していた情報も列定義に含めるような形式になっているようです。編集・削除機能をボタンからプルダウンメニューに変更しているため、2.x の実装例とは等価になっていない部分がありますが。
<UTable :columns="TableCols" :data="TableRows" :loading="!TableLoadStatus">
<script setup lang="ts">
TableCols.value = [
{header: "論理名", accessorKey: "logi_name" },
{header: "物理名", accessorKey: "name" },
{header: "編集可能組織", accessorKey: "groups" },
{
header: "ステータス",
accessorKey: "status",
meta: {
class: {
th: 'text-center',
td: 'text-center'
}
},
cell: ({ row }) => {
return h(
MTStatusBudge, { table_name: row.getValue('name'), display_normal: true }
)
}
},
{header: "ロック元テーブル", accessorKey: "locked_by" },
{header: "編集者", accessorKey: "editor" },
{header: "承認者", accessorKey: "author" },
{header: "更新内容", accessorKey: "temp_changes" },
{
id: 'actions',
cell: ({ row }) => {
return h(
'div',
{ class: 'text-right' },
h(
UDropdownMenu,
{
content: {
align: 'end'
},
items: getActions(row),
'aria-label': 'Actions dropdown'
},
() =>
h(UButton, {
icon: 'i-lucide-ellipsis-vertical',
color: 'neutral',
variant: 'ghost',
class: 'ml-auto',
'aria-label': 'Actions dropdown'
})
)
)
}
}]
</script>
Tabs
2.x ではタブの切替イベントを以下のように @change イベントで検知できたのですが、3.x ではこの仕組みが使えなくなっているようでした。実際の画面では選択されているタブに応じて表示するデータを変更する実装としていたため、影響が大きかったです。また、初期選択されているタブを指定する方法も変更されており、以下のように Tab_Items 内のインデックス値を指定する方法は使えず、合わせて実装の変更が必要となりました。
<UTabs :items="Tab_Items" :default-index="1" @change="onChangeTabs">
<script setup lang="ts">
const Tab_Items = ref([{
label: 'オリジナルデータ表示',
icon: 'material-symbols:table-chart-outline',
}, {
label: '更新差分表示',
icon: DiffTabIcon.value,
}, {
label: 'リレーションシップ(ERD図)表示',
icon: 'material-symbols:dashboard-2-outline',
}])
const onChangeTabs = (index: number) => {
const tab_item = Tab_Items.value[index]
// 以下、具体的なタブ切替時の処理内容を記述 //
}
</script>
一方 3.x における代替手段はというと、移行ガイドには @change の代わりに @update:modelValue を使用する旨記載があったものの 、Tabs の場合はタブの選択状態も合わせて変更する必要があるためその処理と合わせてどう実装するのかが良く分からず。v-model ディレクティブを使用する必要がありそうなことは分かったものの、2.x のように Tab_Items 内のインデックス値を指定しても正常に動作せず、どのような値を指定すべきか分からなかったのであれこれ試行錯誤する羽目になりました。
結論としては、Tab_Item に value プロパティを追加した上でそのプロパティの値を指定することで対応するタブを選択することができました。タブ切替時の処理を含めて考えると上記 URL のサンプル通り v-model に computed() を指定するのが筋が良さそうだったのでそれも踏まえて以下のような実装としています。最も、このサンプルが正直分かり難かったのが実装に手間取った理由というか、@change からの移行パスとして分かるような形で書いておいて欲しかったところではありますが・・
<UTabs :items="Tab_Items" v-model="Tab_Activate"/>
<script setup lang="ts">
const Tab_Items = ref([{
label: 'オリジナルデータ表示',
icon: 'material-symbols:table-chart-outline',
value: 'original_data',
}, {
label: '更新差分表示',
icon: DiffTabIcon.value,
value: 'diff_data',
}, {
label: 'リレーションシップ(ERD図)表示',
icon: 'material-symbols:dashboard-2-outline',
value: 'erd_view',
}])
// 初期選択されるタブを変更
const currentTabValue = ref<string>('diff_data')
const Tab_Activate = computed({
get() {
return currentTabValue.value
},
set(value: string) {
currentTabValue.value = value
// valueに基づいて対応するタブ項目を検索
const tabItem = Tab_Items.value.find(item => item.value === value)
// 以下、具体的なタブ切替時の処理内容を記述 //
}
})
</script>
Toast(旧 Notification)
移行ガイドの内容以外で 1 点使い勝手が大きく変わっているところがありました。画面上に表示した特定のポップアップを削除する場合の方法が変更されています。
2.x の場合は以下のように、ポップアップ表示(toast.add)時に任意の id を定義した上で、その id をポップアップ削除(toast.remove)の引数に指定することで対象のポップアップを削除します。
toast.add({ id: 'toast_sample', title: 'toastのサンプル表示です。'})
toast.remove('toast_sample')
3.x の場合はこの方法が使用できなくなっており、 その代わりに toast.add の返り値として返却された id を toast.remove 時に指定する方法に変更されているようです。ただ、これが 3.x のマニュアルのどこにも書いておらず、調べるのに結構苦労しました。。
const toast_info = toast.add({title: 'toastのサンプル表示です。'})
toast.remove(toast_info.id)
NavigationMenu(旧 HorizontalNavigation, VerticalNavigation)
移行ガイドだとサラッと名前が変わっている程度に受け取れなくもないのですが、実態としては上記 2 つのコンポーネントが統合されているので、両方のコンポーネントを使用している状態で単純に名前を置換しただけだと画面がえらいことになります。メニューの並べ方 (horizontal or vertical) は orientation オプションで指定します。
他、2.x ではメニュー構造のカスタマイズをするためには #default スロットを使用する必要がありましたが、3.x の場合は children オプションでメニューをネストできるため、その目的で #default スロットを使用していた箇所を変更しました。VerticalNavigation の場合は 以下 URL の通り Accordion と組み合わせることでメニューのネスト構造を実現していた箇所もあったのでそちらも合わせて変更しています。相対的にシンプルな実装にはなったのでこの変更自体は良かったですが、変更箇所は多岐に渡りました。

まとめ
来年は Nuxt.js / Nuxt UI 共に 4.x 系に上げないといけないかなーと思っています。どちらも 2.x 系から 3.x 系に上げるのよりは大変じゃないよ!みたいなことが書いてあったので、いまのところは楽観視していますが。それより先にバックエンド処理に使用している Lambda の Python バージョンアップをまずやらないといけなさそうなのがちょっと厄介そうです。
ざっと書いたこともあり全量を網羅できているかちょっと怪しいので、もし他に思い出したら追記しようと思います。
本記事がどなたかの役に立てば幸いです。
