Nuxt 3 で runtimeConfig を useRuntimeConfig() で使うと @click が動かなくなるケースと原因

Nuxt 3 の useRuntimeConfig() をコンポーネント内で使うと、突如として @click やその他のイベントが発火しなくなるという報告が時々上がります。単純に “useRuntimeConfig を呼んだだけで動かなくなった” と見える場合の原因は複数あり、誤った呼び出し位置(setup 外)や SSR と CSR の差分(hydration mismatch)、ランタイム値の取り扱いミス(分割代入/非リアクティブ化) が代表例です。

本稿では再現例、原因別に丁寧に切り分け、実践的な対策コードを示します。

1. 背景と症状の整理(何が起きるか)

useRuntimeConfig() は Nuxt が用意する composable で、サーバーとクライアントで共有する設定(runtime config)を取得します。正しく使えば便利ですが、使い方を誤ると次のような症状に遭遇します。

  • 画面は描画されるが、@click が呼ばれない(クリックしても無反応)
  • ブラウザコンソールにエラーや hydration mismatch の警告が出る
  • 特定コンポーネントだけイベントが効かない(同じページの別コンポーネントは問題なし)

これらは「イベント自体の登録がスキップされている」「コンポーネントのマウントが途中で失敗している」「クライアント側で DOM が差し替えられて Vue のバインドが外れている」等の状態によって生じます。
useRuntimeConfig() 自体は副作用が少ないものの、呼び出す場所・値の取り扱い方次第で上記のトラブルを引き起こします。

2. 再現パターン(実際に起きやすいコード例)

以下は「典型的に誤りやすい」再現コード例と、問題が起きる状況の説明です。

悪い例 A — setup 外で呼ぶ(致命的なルール違反)

<script>
// ❌ NG: setup の外で composable を呼んでいる
const config = useRuntimeConfig() // ここで runtime error: useRuntimeConfig() called outside setup が発生する

export default {
  setup() {
    return {}
  }
}
</script>

<template>
  <button @click="onClick">Click</button>
</template>

結果: コンソールにエラーが出てコンポーネントのマウントが途中で止まり、@click が機能しなくなる。

悪い例 B — destructuring によるリアクティビティの損失(間接的な不整合)

<script setup>
const { public: publicConfig } = useRuntimeConfig() 
// ↓ ここで分割代入すると、元のリアクティブな参照を切り離す可能性がある
</script>

<template>
  <button @click="handle">Click</button>
</template>

結果: 分割代入で期待した値が undefined になったり、SSR/CSR で値がずれて hydration mismatch を起こし、DOM が差し替えられイベントが剥がれることがある。

悪い例 C — サーバとクライアントで値が異なり hydration mismatch に

<script setup>
const config = useRuntimeConfig()
// server: config.public.enableButton = false (build-time default)
// client: environment sets enableButton = true
</script>

<template>
  <button v-if="config.public.enableButton" @click="doSomething">Click</button>
</template>

結果: サーバーではボタンが無く、クライアントでは存在するため Hydration mismatch が発生。Vue が差し替え処理を行い、場合によってはイベントが適切にバインドされない。

上記いずれかのパターンで「useRuntimeConfig を書いただけでクリックが効かない」ように見えます。次節で原因別に詳述し、対策を示します。

3. 原因別解析(なぜ @click が効かなくなるのか)

ここでは代表的な原因を列挙し、技術的に説明します。各原因はいずれも イベントバインドが行われない/剥がれる/DOM が置換される のいずれかに帰着します。

3-1. useRuntimeConfig() を setup 外で呼んでいる(即時エラーでマウント失敗)

Vue の composable は setup コンテキスト内で呼ばれることが前提。外で呼ぶとランタイムエラー(useRuntimeConfig() called outside setup)が発生し、そのコンポーネントは正常にマウントされません。結果、イベントハンドラが登録されません。

3-2. サーバ/クライアントの値不一致(Hydration mismatch)

SSR での HTML とクライアント初期 DOM が一致しないと Vue は hydration 警告を出し、場合によってはクライアント側で DOM を差し替えます。差し替えられたノードには Vue が付与したイベントが無いため、@click が効かなく見えます。これが「最初は動く(SSR)→ナビゲーション後動かない」につながることもあります。

3-3. 分割代入・非リアクティブ化による参照切断

const { public: p } = useRuntimeConfig() のように分割代入してしまうと、リアクティブ参照(あるいは read-only proxy)を直接切断してしまい、意図した再レンダリングや値の一致が保たれなくなります。特にテンプレート内で v-if="p.enable" のように使うと hydration の不整合を招きます。

3-4. v-html / 直接 DOM 書き換えにより Vue バインドが消える

runtimeConfig の値をそのまま v-htmlinnerHTML に流し込むとクライアントで DOM が生成され、Vue のイベントバインド外の DOM になってしまうためハンドラが動きません。

4. 対策(すぐ使える修正版コード)

以下は「問題を解決するための実践的な修正例」です。状況に応じて使い分けてください。

解決 A — setup 内で正しく呼ぶ(絶対ルール)

<script setup>
const config = useRuntimeConfig() // ✅ setup 内で呼ぶ

function onClick() {
  console.log('clicked', config.public.apiBase)
}
</script>

<template>
  <button @click="onClick">Click</button>
</template>

useRuntimeConfig()必ず setup 内で呼びます。これで「呼び出し位置」問題は解消します。

解決 B — 分割代入しない/toRef を使う

<script setup>
const config = useRuntimeConfig()
// Never: const { public: p } = config
import { toRef } from 'vue'
const apiBase = toRef(config.public, 'apiBase') // preserves reactivity

function onClick() {
  console.log(apiBase.value)
}
</script>

toRef で個別プロパティを参照すればリアクティブ性と一致性を保てます。

解決 C — SSR/CSR の不整合を防ぐ(デフォルト値 or client-only)

<script setup>
const config = useRuntimeConfig()
// Ensure default at nuxt.config.ts
// runtimeConfig: { public: { enableButton: process.env.NUXT_PUBLIC_ENABLE_BUTTON==='true' || false } }
import { onMounted } from 'vue'
const showButton = ref(false)
onMounted(() => {
  // client-side decision — this avoids SSR/CSR mismatch
  showButton.value = !!config.public.enableButton
})

function onClick(){ console.log('client click') }
</script>

<template>
  <button v-if="showButton" @click="onClick">Click</button>
</template>

onMounted でクライアント評価にするか、<client-only> を使うことで hydration mismatch を回避します。

解決 D — v-html を避ける、または client-only で挿入

v-html に runtimeConfig の HTML を流すケースは危険。イベントを紐付けたい要素は Vue 側で生成する。

5. デバッグ手順とチェックリスト(優先度高)

実務で当該障害が出たら、以下を順に確認してください。

5-1. コンソールのエラー確認(最重要)

  • useRuntimeConfig() called outside setup のような即時エラーがないか。
  • hydration mismatch の警告が出ていないか。

5-2. 呼び出し位置を確認

  • composable はすべて setup 内で使われているか。<script>(setup なし)で呼んでいないか。

5-3. サーバーとクライアントの値確認

  • サーバー側ログと onMounted 内での client-side ログを比較して config.public.* の値が一致しているかを確認する。簡易チェック:
console.log('SSR', useRuntimeConfig()) // server-side
onMounted(()=> console.log('client', useRuntimeConfig()))

5-4. 分割代入チェック

  • const { public } = useRuntimeConfig() のような分割代入をしていないか。している場合は toRef/computed に置き換える。

5-5. v-html / innerHTML / 外部スクリプト挿入チェック

  • runtimeConfig の値を直接 DOM に流していないか。流している場合は Vue の描画に置き換えるか client-only にする。

6. ベストプラクティスと運用ルール

最後に、チームで共有すべきルールをまとめます(レビュー時のチェックリスト化推奨)。

  • composable(useRuntimeConfig() を含む)は必ず setup 内で呼ぶ。ESLint ルールで強制することを検討する。
  • runtimeConfig.public の値は nuxt.config.ts でデフォルト値を与え、SSR と CSR の初期差異を減らす。
  • 分割代入を避け、個別参照は toRef(config.public, 'key') または computed(() => config.public.key) を使う。
  • クライアント専用処理は <client-only>onMounted でラップする。
  • v-html で外部文字列を挿入するのは避ける(どうしても使うならイベントを JavaScript で再登録しないといけない)。

まとめ

useRuntimeConfig() 自体はイベントに悪影響を与えるものではありませんが、呼び出し位置の誤り(setup 外)、サーバ/クライアントでの値不一致(hydration mismatch)、分割代入によるリアクティビティの破壊、v-html 等の直接 DOM 操作 と組み合わさることで @click 等のイベントが効かないという表層的な症状が発生します。対処はまずコンソールエラーの確認、setup 内での呼び出し徹底、toRefonMounted を活用したクライアント評価への振り分け、nuxt.config.ts でのデフォルト値設定です。これらを守れば多くのケースで問題は解決します。

参考

タイトルとURLをコピーしました