Composition APIとは、ref や computed などの関数を組み合わせて、コンポーネントの状態とロジックを書くVue 3の書き方です。 現在は <script setup> と組み合わせて使うのが標準で、export default { data() {...} } のような枠を書かずに、変数と関数をそのまま並べるだけでテンプレートから使えます。

この記事では、Vue 3.5.43(2026年9月時点の最新安定版)+ Vite 8.3.1 で、よく使うAPIをすべて <script setup> 形式のコピペで動くコードにまとめました。さらに、ブラウザ(Chromium)で実際にボタンを押して画面の値を読み取った結果を載せています。「分割代入するとリアクティブでなくなる」「watch と watchEffect の実行タイミングの違い」なども、実行結果で確かめられます。最後に、リリース候補版の Vue 3.6(3.6.0-rc.9)の Vapor Mode を実際にビルドしてサイズを比較しています。

Composition APIの早見表

やりたいこと 使うAPI 最小コード(<script setup> 内)
数値・文字列の状態を持つ ref const count = ref(0) → 変更は count.value++
オブジェクトの状態を持つ reactive / ref const form = reactive({ name: '' })
値から別の値を計算する computed const total = computed(() => a.value + b.value)
値の変化で処理する(前後の値が欲しい) watch watch(keyword, (n, o) => {...})
使った値を自動で追跡して処理する watchEffect watchEffect(() => console.log(keyword.value))
画面表示後に処理する onMounted onMounted(() => {...})
後片付けする onUnmounted onUnmounted(() => clearInterval(id))
親から値を受け取る defineProps const props = defineProps({ title: String })
親にイベントを送る defineEmits const emit = defineEmits(['order'])
親と v-model で双方向に結ぶ defineModel const note = defineModel('note')
孫コンポーネントに値を渡す provide / inject provide('theme', 'dark') / inject('theme')
DOM要素に触る useTemplateRef const el = useTemplateRef('search')
ロジックを再利用する composable export function useCounter() {...}
reactive を分割代入する toRefs const { done } = toRefs(task)

<script setup> では defineProps / defineEmits / defineModel はimport不要です(コンパイラが処理するマクロのため)。それ以外の ref や watch は import { ... } from 'vue' で読み込みます。

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Vue 3 Composition APIの使い方|早見表と実行結果」

Options APIとの対応表

Options API(data / methods などのオプションで書く従来の書き方)から移る方向けに、対応をまとめます。どちらもVue 3で公式にサポートされている書き方で、同じプロジェクト内で混在もできます。

Options API Composition API(<script setup>)
data() { return { count: 0 } } const count = ref(0)
computed: { double() {...} } const double = computed(() => ...)
methods: { add() {...} } function add() {...}(普通の関数)
watch: { keyword(n, o) {...} } watch(keyword, (n, o) => {...})
mounted() {...} onMounted(() => {...})
beforeUnmount() / unmounted() onBeforeUnmount() / onUnmounted()
props: ['title'] defineProps(['title'])
emits: ['order'] / this.$emit defineEmits(['order']) / emit('order')
this.$refs.search useTemplateRef('search')(3.5以降)
provide / inject オプション provide() / inject() 関数
mixins composable(関数)
this.xxx でアクセス this は使わない(変数を直接参照)

いちばん大きな違いは「関連するロジックを1か所にまとめられる」ことです。Options APIでは1つの機能のコードが data・computed・methods・watch に分かれますが、Composition APIでは機能ごとに上から並べて書けて、そのまま関数(composable)に切り出せます。

検証環境と動かし方

以下のバージョンで実行しています。

項目 バージョン
Node.js 22.22.2
vue 3.5.43
vite 8.3.1
@vitejs/plugin-vue 6.0.9
画面確認 Playwright 1.56.1 + Chromium

新規プロジェクトは公式の npm create vue@latest で作るのが手軽です。この記事の検証では、仕組みがわかるように最小構成を手で作りました。

npm init -y
npm i vue
npm i -D vite @vitejs/plugin-vue
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({ plugins: [vue()] })
// src/main.js
import { createApp } from 'vue'
import App from './App.vue'

createApp(App).mount('#app')

index.html に <div id="app"></div> と <script type="module" src="/src/main.js"></script> を書き、npx vite で起動します。

ref と reactive で状態を持つ

コピペ用コード

<!-- src/components/Counter.vue -->
<script setup>
import { ref, reactive, computed, toRefs } from 'vue'

const count = ref(0)                                   // プリミティブは ref
const task = reactive({ title: '備品発注', done: 0 })  // オブジェクトは reactive も可

// 分割代入:リアクティブが失われる
let { done } = task
// toRefs で分割代入:リアクティブが保たれる
const { done: doneRef } = toRefs(task)

const label = computed(() => `${task.title}:${task.done}件完了`)

function add() {
  count.value++   // script 内では .value が必要
  task.done++     // reactive は .value 不要
}
</script>

<template>
  <section>
    <button id="add" @click="add">+1</button>
    <p id="count">count: {{ count }}</p>
    <p id="done">task.done: {{ task.done }}</p>
    <p id="destructured">分割代入 done: {{ done }}</p>
    <p id="torefs">toRefs doneRef: {{ doneRef }}</p>
    <p id="label">{{ label }}</p>
  </section>
</template>

実行結果(「+1」を2回クリック)

Chromiumで画面を開き、ボタンを2回押したあとの表示をそのまま読み取りました。

--- 初期表示
#count => count: 0
#done => task.done: 0
#destructured => 分割代入 done: 0
#torefs => toRefs doneRef: 0
#label => 備品発注:0件完了
--- +1 を2回クリック後
#count => count: 2
#done => task.done: 2
#destructured => 分割代入 done: 0
#torefs => toRefs doneRef: 2
#label => 備品発注:2件完了

let { done } = task で取り出した done だけが 0 のままです。分割代入はその時点の値(数値の0)をコピーするだけなので、元の task との結びつきがなくなります。toRefs(task) を通すと、各プロパティが ref になって結びつきが保たれ、2 に更新されました。

また、テンプレート内では ref の .value は自動で外れるため、{{ count }} と書けば中身が表示されます。.value が必要なのは <script> の中だけです。

ref と reactive の使い分け判断表

観点 ref reactive
扱える値 何でも(数値・文字列・配列・オブジェクト) オブジェクト・配列・Map・Set
script 内のアクセス .value が必要 そのまま form.name
丸ごと入れ替え list.value = newList でOK 変数への再代入では画面と結びつかない(Object.assign などで中身を更新)
分割代入 ref のまま渡せる toRefs が必要
composable の戻り値 分割代入しても使いやすい そのまま返すと分割代入で結びつきが切れる

迷ったら ref に統一するのがわかりやすい選び方です。公式ドキュメントも、reactive の制約(再代入・分割代入)を理由に ref を主に使うことを勧めています。フォームのように「まとまったオブジェクトを項目ごとに更新する」場面では reactive も読みやすく書けます。

computed で値を計算する

import { ref, computed } from 'vue'

const items = ref([{ name: 'ノート', qty: 2 }, { name: 'ペン', qty: 5 }])
const total = computed(() => items.value.reduce((s, i) => s + i.qty, 0))
// total.value → 7。items が変わると自動で再計算される

computed は依存する値が変わったときだけ再計算され、結果がキャッシュされます。 テンプレートで関数を呼ぶ({{ calcTotal() }})と再描画のたびに実行されるので、計算結果を表示する用途には computed が向いています。書き込みもしたい場合は computed({ get() {...}, set(v) {...} }) の形にします。

watch と watchEffect で変化に反応する

コピペ用コード

<!-- src/components/Watchers.vue -->
<script setup>
import { ref, watch, watchEffect } from 'vue'

const keyword = ref('')
const logs = ref([])

watch(keyword, (newVal, oldVal) => {
  logs.value.push(`watch: "${oldVal}" -> "${newVal}"`)
})

watchEffect(() => {
  // 中で読んだ keyword を自動で追跡。登録直後にも1回実行される
  logs.value.push(`watchEffect: "${keyword.value}"`)
})
</script>

<template>
  <input id="kw" v-model="keyword" />
  <ul id="logs"><li v-for="(l, i) in logs" :key="i">{{ l }}</li></ul>
</template>

実行結果(入力欄に「ab」を入力)

[ 'watchEffect: ""', 'watch: "" -> "ab"', 'watchEffect: "ab"' ]

watchEffect は登録した直後に1回実行され、watch は値が変わるまで実行されません。 また watch は変更前と変更後の値を受け取れます。

観点 watch watchEffect
監視対象 第1引数で明示 関数内で読んだ値を自動追跡
初回実行 しない({ immediate: true } で実行) 登録直後に実行
変更前の値 受け取れる 受け取れない
向いている用途 検索語の変化でAPIを呼ぶ、など対象が明確な処理 複数の値を使う同期処理

reactive のプロパティを監視するときは getter で渡す

import { reactive, watch } from 'vue'

const task = reactive({ done: 0 })
watch(task.done, (v) => console.log('A', v))            // 数値の 0 を渡している
watch(() => task.done, (v) => console.log('B: done =', v)) // getter で渡す
task.done++

Node.jsで実行した結果です(開発モード)。

[Vue warn]: Invalid watch source:  0 A watch source can only be a getter/effect function, a ref, a reactive object, or an array of these types.
B: done = 1

task.done と書くとその瞬間の数値が渡るため監視できません。() => task.done のように関数で渡すと動きます。

ライフサイクルと useTemplateRef で DOM に触る

<script setup>
import { ref, onMounted, useTemplateRef } from 'vue'

const inputEl = useTemplateRef('search')  // Vue 3.5 以降
const lifecycle = ref([])

onMounted(() => {
  inputEl.value.focus()
  lifecycle.value.push(`onMounted: focus=${document.activeElement === inputEl.value}`)
})
</script>

<template>
  <input ref="search" id="search" />
  <p id="lifecycle">{{ lifecycle.join(',') }}</p>
</template>

実行結果です。

#lifecycle => onMounted: focus=true

DOM要素は onMounted の時点で取得できます。 <script setup> の直下(マウント前)では inputEl.value は null です。useTemplateRef はVue 3.5で追加されたAPIで、3.4以前は const search = ref(null) と同名の変数を用意する書き方をします(3.5でも動きます)。

Options API Composition API 実行タイミング
beforeCreate / created (<script setup> 直下に書く) セットアップ時
beforeMount / mounted onBeforeMount / onMounted DOMに挿入される前 / 後
beforeUpdate / updated onBeforeUpdate / onUpdated 再描画の前 / 後
beforeUnmount / unmounted onBeforeUnmount / onUnmounted 破棄の前 / 後

props・emits・defineModel で親子をつなぐ

子コンポーネント

<!-- src/components/Child.vue -->
<script setup>
const props = defineProps({
  title: { type: String, required: true },
  stock: { type: Number, default: 0 },
})
const emit = defineEmits(['order'])
const note = defineModel('note', { default: '' })
</script>

<template>
  <div>
    <p id="child-title">{{ props.title }}(在庫 {{ stock }})</p>
    <button id="order" @click="emit('order', props.title)">発注</button>
    <input id="note" v-model="note" />
  </div>
</template>

親コンポーネント

<script setup>
import { ref } from 'vue'
import Child from './components/Child.vue'

const ordered = ref('')
const memo = ref('初期メモ')
</script>

<template>
  <Child title="コピー用紙" :stock="3" @order="t => ordered = t" v-model:note="memo" />
  <p id="ordered">発注: {{ ordered }}</p>
  <p id="memo">親のmemo: {{ memo }}</p>
</template>

実行結果(「発注」をクリックし、子の入力欄に「至急」と入力)

#memo => 親のmemo: 初期メモ        ← 初期表示
#ordered => 発注: コピー用紙       ← emit で親に届いた
#memo => 親のmemo: 至急            ← defineModel で親の memo が更新された

defineModel(Vue 3.4以降)を使うと、子で note.value = ... するだけで親の v-model の値が更新されます。 以前は props + emit('update:note', v) を組み合わせて書く必要がありました。

TypeScriptでは型で宣言できます。

const props = defineProps<{ title: string; stock?: number }>()
const emit = defineEmits<{ order: [title: string] }>()
const note = defineModel<string>('note', { default: '' })

Vue 3.5からは const { title, stock = 0 } = defineProps<...>() のように props を分割代入してもリアクティブが保たれ、既定値もこの形で書けます(Reactive Props Destructure)。ただし分割代入した変数を watch するときは watch(() => stock, ...) のように getter で渡します。

provide / inject で離れたコンポーネントに渡す

<!-- 親(App.vue) -->
<script setup>
import { provide } from 'vue'
provide('theme', 'dark')
</script>
<!-- 子孫(Theme.vue) -->
<script setup>
import { inject } from 'vue'
const theme = inject('theme', 'light')          // 第2引数は見つからないときの既定値
const missing = inject('notProvided', '既定値')
</script>
<template><p id="inject">theme: {{ theme }} / missing: {{ missing }}</p></template>

実行結果です。

#inject => theme: dark / missing: 既定値

provide に ref を渡すと、子孫側でも変更に追従します。 子孫から値を書き換えられたくないときは provide('theme', readonly(theme)) のように readonly で包み、変更用の関数を別に provide するとデータの流れが追いやすくなります。

composable でロジックを再利用する

composable(コンポーザブル)は、Composition APIを使った状態とロジックをまとめた関数です。名前は use で始めるのが慣例です。

// src/composables/useCounter.js
import { ref, computed } from 'vue'

export function useCounter(initial = 0) {
  const count = ref(initial)
  const double = computed(() => count.value * 2)
  const increment = () => { count.value++ }
  const reset = () => { count.value = initial }
  return { count, double, increment, reset }
}
<script setup>
import { useCounter } from './composables/useCounter'
const { count: c2, double, increment } = useCounter(10)
</script>

<template>
  <button id="inc" @click="increment">composable +1</button>
  <p id="composable">{{ c2 }} / {{ double }}</p>
</template>

実行結果(クリック前 → 1回クリック後)です。

#composable => 10 / 20
#composable => 11 / 22

composable から ref を返しておくと、呼び出し側で分割代入しても値が追従します。 これが「迷ったら ref」をおすすめする理由の1つです。コンポーネントごとに useCounter() を呼べば、それぞれ独立した状態を持ちます。

composable の設計ポイント 理由
戻り値は ref を含むオブジェクトにする 呼び出し側で分割代入できる
イベント登録は onUnmounted で解除する 画面を離れたあとも処理が残らない
引数に ref も受け付けるなら toValue() で読む 値・ref・getter のどれでも受け取れる(3.3以降)
<script setup> の直下で同期的に呼ぶ ライフサイクル関数がコンポーネントに紐づく

よくあるつまずきと直し方

症状 原因 直し方
script 内で値が更新されない、[object Object] になる .value を付け忘れた script 内では count.value
分割代入した値が画面で変わらない reactive を分割代入した toRefs(state)、または最初から ref で持つ
Invalid watch source の警告 reactive のプロパティを直接 watch した watch(() => state.x, ...)
reactive を再代入したら画面が変わらない 変数を別オブジェクトに差し替えた Object.assign(state, newData) か ref に変更
useTemplateRef の値が null マウント前に参照した onMounted 内で使う
inject の値が undefined 祖先で provide されていない 第2引数に既定値を書く
子で props を直接書き換えて警告が出る props は読み取り専用 emit か defineModel を使う

Vue 3.6 の Vapor Mode を試す(リリース候補版)

Vapor Mode は、仮想DOMを使わずにテンプレートを直接DOM操作のコードへコンパイルする、Vue 3.6で追加予定の新しい描画方式です。2026年9月時点では 3.6.0-rc.9(リリース候補版) で、npmの latest は3.5.43です。本番採用は安定版の公開を待ち、検証用に試すのがおすすめです。

公式のリリースノートによると、有効化は次のとおりです。

  • <script setup vapor> と書く(Composition APIの <script setup> またはテンプレートのみのSFCが対象。Options APIは非対応)
  • すべてVaporコンポーネントなら createVaporApp() でアプリを作ると、仮想DOMのランタイムを含まずに済む
  • 既存の createApp() のアプリに混ぜる場合は vaporInteropPlugin を登録する

同じ画面をビルドしてサイズを比較

リストに項目を追加して合計を表示する同じコンポーネントを、通常版と Vapor 版でビルドしました。

<!-- App.vue(Vapor 版は 1行目を <script setup vapor> にする) -->
<script setup>
import { ref, computed } from 'vue'
const items = ref([{ id: 1, name: 'ノート', qty: 2 }, { id: 2, name: 'ペン', qty: 5 }])
const total = computed(() => items.value.reduce((s, i) => s + i.qty, 0))
const add = () => items.value.push({ id: Date.now(), name: '付箋', qty: 1 })
</script>
<template>
  <button id="add" @click="add">追加</button>
  <ul><li v-for="i in items" :key="i.id">{{ i.name }} × {{ i.qty }}</li></ul>
  <p id="total">合計 {{ total }}</p>
</template>
// main.js(Vapor 版)
import { createVaporApp } from 'vue'
import App from './App.vue'
createVaporApp(App).mount('#app')

npm i vue@3.6.0-rc.9 のあと @vitejs/plugin-vue@6.0.9 を入れると、peerDependencies(vue@"^3.2.25")がプレリリース版に一致せず ERESOLVE になりました。検証では --legacy-peer-deps を付けて入れています。

vite build(Vite 8.3.1)の出力です。

== 通常版(createApp + <script setup>)
dist/assets/index-BTE8xCbK.js  65.45 kB │ gzip: 25.55 kB
== Vapor 版(createVaporApp + <script setup vapor>)
dist/assets/index-BNcBgcMm.js  48.48 kB │ gzip: 18.48 kB
ビルド JS サイズ gzip 後
通常版(3.6.0-rc.9) 65.45 kB 25.55 kB
Vapor 版(3.6.0-rc.9) 48.48 kB 18.48 kB
差 約26%減 約28%減

この小さな画面では、Vapor版のJSがgzip後で約7kB小さくなりました。 どちらもChromiumで「追加」を押すと 合計 8 になり、リストに「付箋 × 1」が加わることを確認しています(エラーなし)。サイズの差は主にランタイムの違いなので、コンポーネントが増えたときの比率は画面の構成によって変わります。

Composition APIと <script setup> で書いておくと、Vapor Modeへの切り替えは属性の追加が中心になります。 これから書くコードを <script setup> にそろえておく価値は大きいといえます。

よくある質問

Q. Options APIとComposition API、どちらを使えばよいですか? A. どちらも公式にサポートされています。新規で書くなら、ロジックの再利用や型推論のしやすさから <script setup> + Composition APIが選ばれることが多いです。Vapor ModeもComposition API(<script setup>)が対象です。既存のOptions APIのコードは、そのまま動かしながら少しずつ移せます。

Q. setup() 関数と <script setup> の違いは何ですか? A. <script setup> は setup() 関数を短く書くための構文です。setup() では使う値を return で返す必要がありますが、<script setup> ではトップレベルの変数や関数がそのままテンプレートで使えます。

Q. ref と reactive はどちらを使えばよいですか? A. 迷ったら ref に統一するとわかりやすくなります。reactive は再代入や分割代入で結びつきが切れるため、使うときは toRefs や Object.assign とあわせて使います。

Q. Vuex の代わりに composable だけで状態共有できますか? A. composable の外(モジュールのトップ)で ref を作れば、複数コンポーネントで共有できます。ただしSSRや開発ツールとの連携を考えると、アプリ全体の状態は公式推奨の Pinia を使うと管理しやすくなります。

まとめ

  • Composition APIは <script setup> と組み合わせ、変数と関数を並べて書くのが標準
  • script 内では .value、テンプレートでは不要。迷ったら ref に統一
  • reactive の分割代入は結びつきが切れる(実行結果で 0 のまま)→ toRefs で解決
  • watchEffect は登録直後に実行、watch は変化時のみ。reactive のプロパティは getter で watch
  • 親子は defineProps / defineEmits / defineModel、離れた階層は provide / inject
  • 再利用は ref を返す composable にまとめる
  • Vue 3.6 rc の Vapor Mode は今回の画面で gzip 約28%減。安定版の公開を待って採用を検討

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Vue 3 Composition APIの使い方|早見表と実行結果」

株式会社RJCでは、Vueの新しい書き方や描画方式の変化を、実際に手を動かして確かめながらプロジェクトの中で共有しています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?