React脳でVue 3を書いたら事故った話:Composition APIとPiniaの正しい入り方
Reactから来た僕がVue 3でやらかした失敗を出発点に、ref/reactive/computed/watchの使い分け、script setup、Piniaのsetup storeを、コピペで動くコードで解説します。
「Reactと似たようなもんでしょ」と思って、僕は初めてのVue 3の画面を書き始めました。
useState のノリで ref を置いて、JSXの感覚で reactive なオブジェクトを分割代入で受け取って、副作用は全部 watch に押し込んだ。動きました。デモも通った。ところが翌週、検索フォームの値だけが「たまに反映されない」というバグが出たんです。原因は、リアクティブなオブジェクトを const { query } = state で取り出した瞬間に、つながりが切れていたから。Reactなら毎回再レンダリングでごまかせる場所が、Vueでは静かに壊れる。
このとき気づきました。VueはReactと「似て非なるもの」で、つまずく場所が全部ちがうんだと。
この記事は、その僕の失敗を出発点に、Vue 3のComposition API(ref/reactive/computed/watch)とSFC(単一ファイルコンポーネント、画面1枚を1ファイルにまとめる書き方)、そしてPiniaでの状態管理を、コピペで動くコードと一緒に整理したものです。React経験者が「どこで考え方を切り替えればいいか」が分かるように書きました。
この記事の要点
- Vue 3はReactと別物。
refは中身を.valueで触る箱、reactiveは分割代入で壊れる。迷ったらref中心で始めるのが安全。 - 派生値は
computed、外への副作用だけwatch。この線引きを守るだけでバグが激減する。 - 状態は画面(SFC)に貯めない。複数画面で共有するものはPiniaのsetup store(
refとcomputedをそのまま返す書き方)に逃がす。 - React経験者がハマる罠は「props直接更新」「
reactiveの分割代入」「watch乱用」の3つ。コピペできるフォーム実装で回避策を示す。 - Claude Codeに任せるときは、対象ファイル・禁止事項・確認コマンドを先に渡すと差分の質が安定する。
Vue 3とReactは、どこが決定的にちがうのか
まず全体像です。React経験者が最初に頭を切り替えるべきポイントを表にしました。
| やりたいこと | Reactでの感覚 | Vue 3 Composition APIでの書き方 |
|---|---|---|
| ローカル状態を持つ | const [x, setX] = useState(0) | const x = ref(0)(読み書きはx.value) |
| 派生値を作る | useMemo(() => ..., [deps]) | const y = computed(() => ...)(依存は自動追跡) |
| 副作用を起こす | useEffect(() => ..., [deps]) | watch(source, () => ...) または watchEffect |
| 子に値を渡す | props | defineProps(読み取り専用、書き換え禁止) |
| 子から親へ通知 | コールバックをpropsで渡す | defineEmits でイベントを発火 |
いちばん大事なのは2つです。
ひとつ、refの中身は.valueで触る。テンプレート(<template>の中)では自動でほどけるので.valueは要りませんが、<script>の中ではcount.value++と書きます。ここを忘れると「なぜか更新されない」が起きます。
ふたつ、依存配列がない。computedもwatchも、中で使ったリアクティブな値を自動で見張ってくれます。ReactのuseMemoで依存配列を書き忘れて沼る、あの苦労がVueにはありません。これは正直うらやましかった。
refとreactive、迷ったらどっち
僕が最初に事故ったのがここなので、先に結論を置きます。初心者と移行組はrefを中心に使ってください。
ref は値を1個の箱に入れて持ち運ぶイメージです。数値・文字列・真偽値はもちろん、配列やオブジェクトを丸ごと差し替える用途にも向きます。
reactive はオブジェクトそのものをリアクティブにします。一見ラクですが、落とし穴があります。
import { reactive } from 'vue';
const state = reactive({ query: '', page: 1 });
// これをやると query は「ただの文字列のコピー」になり、
// state とのリアクティブなつながりが切れる
const { query } = state; // ← 僕がやった事故。更新が反映されない
冒頭のバグの正体がこれです。reactive なオブジェクトを分割代入すると、リアクティブ性が外れる。ReactのuseStateから来ると、ついconst { ... } = stateと書きたくなるんですよね。だから僕は、共有しない画面ローカルの状態はぜんぶrefで持つことにしました。
import { ref } from 'vue';
const query = ref('');
const page = ref(1);
// 読み書きは .value 経由。つながりは切れない
query.value = 'バグ報告';
page.value = 1;
reactive が向くのは、深くネストしたまとまったオブジェクトを「差し替えずに中身を更新し続ける」ケースくらい。判断に迷う時間がもったいないので、まずはref一本でいい、というのが僕の実感です。
フォームを1枚のSFCで書く(コピペで動く)
説明より動くコードです。問い合わせチケットを登録するフォームを、<script setup lang="ts">で書きます。script setupは、Composition APIをいちばん短く書ける記法で、Vue 3ではこれが標準だと思ってもらって大丈夫です。
このコードに、React経験者がハマる罠の回避策を全部入れてあります。「propsは直接いじらない」「入力値はローカルのref」「派生値はcomputed」「送信はstoreに寄せる」。
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useTicketStore, type TicketPriority } from '@/stores/ticketStore';
// props は読み取り専用。withDefaults で初期値を付ける(Vue 3.3以前でも安全)
const props = withDefaults(
defineProps<{
initialTitle?: string;
defaultPriority?: TicketPriority;
}>(),
{
initialTitle: '',
defaultPriority: 'medium',
},
);
// 親へ「登録できたよ」と通知するイベント
const emit = defineEmits<{
submitted: [id: string];
}>();
const store = useTicketStore();
// 入力はローカルの ref で持つ(props を直接書き換えない)
const title = ref(props.initialTitle);
const description = ref('');
const priority = ref<TicketPriority>(props.defaultPriority);
const touched = ref(false);
// エラー表示は派生値なので computed。watch は使わない
const titleError = computed(() => {
if (!touched.value) return '';
if (title.value.trim().length < 5) return 'タイトルは5文字以上で入力してください。';
return '';
});
const descriptionError = computed(() => {
if (!touched.value) return '';
if (description.value.trim().length < 20) return '本文は20文字以上で入力してください。';
return '';
});
const canSubmit = computed(() => {
return (
title.value.trim().length >= 5 &&
description.value.trim().length >= 20 &&
!store.isSaving
);
});
async function submit() {
touched.value = true;
if (!canSubmit.value) return;
// 保存処理は store の action に任せる
const ticket = await store.saveTicket({
title: title.value.trim(),
description: description.value.trim(),
priority: priority.value,
});
// 送信後はローカルの ref をリセット
title.value = '';
description.value = '';
priority.value = props.defaultPriority;
touched.value = false;
emit('submitted', ticket.id);
}
</script>
<template>
<form class="ticket-form" @submit.prevent="submit">
<label>
タイトル
<!-- テンプレートでは .value 不要。v-model で双方向バインド -->
<input v-model="title" name="title" @blur="touched = true" />
</label>
<p v-if="titleError" class="error">{{ titleError }}</p>
<label>
本文
<textarea v-model="description" name="description" @blur="touched = true" />
</label>
<p v-if="descriptionError" class="error">{{ descriptionError }}</p>
<label>
優先度
<select v-model="priority" name="priority">
<option value="low">低</option>
<option value="medium">中</option>
<option value="high">高</option>
</select>
</label>
<button type="submit" :disabled="!canSubmit">
{{ store.isSaving ? '保存中...' : 'チケットを作成' }}
</button>
</form>
</template>
注目してほしいのは、<script>ではtitle.valueと書くのに、<template>ではv-model="title"で済む点です。テンプレート側は.valueが自動でほどける。ここを行き来できるようになると、Vueがぐっと書きやすくなります。
ちなみにVue 3.4以降なら、withDefaultsの代わりに「Reactive Props Destructure」という新しい書き方で const { initialTitle = '' } = defineProps<...>() と初期値を直接書けます。ただチームのVueバージョンがバラつくうちは、互換性の広いwithDefaultsから入るのが無難です。
状態はPiniaのsetup storeに逃がす
フォーム1枚ならrefで足ります。でも「チケット一覧を別画面でも出したい」「保存中フラグを共有したい」となった瞬間、画面に状態を貯めるのは破綻します。そこでPinia。Vueチームが公式に推している状態管理ライブラリで、Composition APIとそのまま地続きに書けます。
書き方には2種類(option storeとsetup store)ありますが、Composition APIに慣れるならsetup store一択でいいです。refがそのままstate、computedがgetter、関数がactionになる。さっきのフォームと同じ語彙で書けます。
以下をsrc/stores/ticketStore.tsとして置きます。
import { computed, ref } from 'vue';
import { defineStore } from 'pinia';
export type TicketPriority = 'low' | 'medium' | 'high';
export type TicketStatus = 'open' | 'closed';
export interface Ticket {
id: string;
title: string;
description: string;
priority: TicketPriority;
status: TicketStatus;
createdAt: string;
}
export type NewTicketInput = Pick<Ticket, 'title' | 'description' | 'priority'>;
export const useTicketStore = defineStore('tickets', () => {
// ref が state になる
const tickets = ref<Ticket[]>([]);
const isSaving = ref(false);
// computed が getter になる
const openTickets = computed(() => {
return tickets.value.filter((ticket) => ticket.status === 'open');
});
// 関数が action になる
function addTicket(input: NewTicketInput) {
const ticket: Ticket = {
id: crypto.randomUUID(),
...input,
status: 'open',
createdAt: new Date().toISOString(),
};
tickets.value.unshift(ticket);
return ticket;
}
async function saveTicket(input: NewTicketInput) {
isSaving.value = true;
try {
// 本番では API 呼び出し。ここではダミーの待機
await new Promise((resolve) => setTimeout(resolve, 150));
return addTicket(input);
} finally {
isSaving.value = false;
}
}
function closeTicket(id: string) {
const ticket = tickets.value.find((item) => item.id === id);
if (ticket) ticket.status = 'closed';
}
// 公開するものを返す(返さないものは外から触れない)
return { tickets, isSaving, openTickets, addTicket, saveTicket, closeTicket };
});
ReduxやZustandを触ったことがあるなら、storeの考え方は近いです。違いは、Vueのリアクティブシステムにそのまま乗るので、openTicketsのような派生値をcomputedで書けば自動で更新されること。アクションも普通の関数で、dispatchの儀式が要りません。ここはVueの気持ちよさだと思います。
ロジックはComposableに切り出す(Reactのカスタムフック相当)
検索・フィルタ・ページングのようなロジックを画面に書くと、SFCがすぐ太ります。VueではComposableに逃がします。Composableは「画面からリアクティブなロジックだけ取り出した関数」で、Reactでいうカスタムフックのポジションです(命名もuseXxxで揃う)。
src/composables/useFilteredTickets.tsに切り出します。
import { computed, ref, watch, type Ref } from 'vue';
import type { Ticket, TicketPriority } from '@/stores/ticketStore';
export function useFilteredTickets(tickets: Ref<Ticket[]>) {
const query = ref('');
const selectedPriority = ref<TicketPriority | 'all'>('all');
const currentPage = ref(1);
const pageSize = ref(10);
// 絞り込み結果は派生値。だから computed
const filteredTickets = computed(() => {
const normalizedQuery = query.value.trim().toLowerCase();
return tickets.value.filter((ticket) => {
const matchesQuery =
normalizedQuery.length === 0 ||
ticket.title.toLowerCase().includes(normalizedQuery) ||
ticket.description.toLowerCase().includes(normalizedQuery);
const matchesPriority =
selectedPriority.value === 'all' ||
ticket.priority === selectedPriority.value;
return matchesQuery && matchesPriority;
});
});
const totalPages = computed(() => {
return Math.max(1, Math.ceil(filteredTickets.value.length / pageSize.value));
});
const pagedTickets = computed(() => {
const start = (currentPage.value - 1) * pageSize.value;
return filteredTickets.value.slice(start, start + pageSize.value);
});
// ここだけ watch。「検索条件が変わったら1ページ目に戻す」という副作用だから
watch([query, selectedPriority], () => {
currentPage.value = 1;
});
return {
query,
selectedPriority,
currentPage,
pageSize,
filteredTickets,
pagedTickets,
totalPages,
};
}
ここでwatchを使っているのは1か所だけです。「検索条件が変わったらページを1に戻す」という、画面に対する副作用だから。逆に、絞り込み結果や総ページ数のような派生値は全部computedです。
この線引きが、Vue 3でいちばん効くコツだと僕は思っています。ReactのuseEffectに何でも詰め込む癖があると、ついwatchで値を作りたくなる。でもVueでは「派生はcomputed、副作用だけwatch」と決めるだけで、データの流れが追えるコードになります。
Vitestで「壊れると困るところ」だけ守る
実装が速くなるほど、回帰テスト(直したつもりが別の場所を壊していないか確かめるテスト)の価値が上がります。ただ、フォームの見た目をスナップショットで大量に固めるのは正直しんどいし、すぐ壊れる。僕はstoreの状態遷移から守ることにしています。
src/stores/ticketStore.test.tsです。
import { createPinia, setActivePinia } from 'pinia';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useTicketStore } from './ticketStore';
describe('useTicketStore', () => {
beforeEach(() => {
// 各テストごとに新しい Pinia を用意して状態を隔離
setActivePinia(createPinia());
vi.stubGlobal('crypto', { randomUUID: () => 'ticket-1' });
});
it('新しいチケットを open 状態で追加する', () => {
const store = useTicketStore();
const ticket = store.addTicket({
title: '経費フォームが送信できない',
description: '全項目を埋めても送信ボタンが押せない状態のままになる。',
priority: 'high',
});
expect(ticket.id).toBe('ticket-1');
expect(store.tickets).toHaveLength(1);
expect(store.openTickets).toHaveLength(1);
});
it('既存のチケットを閉じる', () => {
const store = useTicketStore();
const ticket = store.addTicket({
title: 'プロフィール画像が表示されない',
description: '設定画面で画像URLが404を返している。',
priority: 'medium',
});
store.closeTicket(ticket.id);
expect(store.openTickets).toHaveLength(0);
expect(store.tickets[0]?.status).toBe('closed');
});
});
このテストは、実装の細部ではなく「チケットが作られる」「閉じられる」という業務上の振る舞いを確認しています。Claude Codeにテストを書かせるときも、スナップショットを盛るより、こういう壊れたら困る状態遷移を指定したほうが、実務でちゃんと役に立ちます。
検証環境がまだない人は、ViteのテンプレートでVue 3 + TypeScript + Pinia + Vitestの最小構成をひと息で作れます。
npm create vue@latest support-desk-vue -- --typescript --router --pinia --vitest
cd support-desk-vue
npm install
npm run dev
Claude Codeに任せるなら、足場を先に渡す
ここまでのコードをClaude Codeに書かせるとき、「Vueでフォーム作って」だけでは弱いです。Vueは書き方の幅が広いので、曖昧な指示だと「動くけど保守しにくい」差分が返ってきます。
僕はいつも、対象ファイル・禁止事項・確認コマンドをセットで渡しています。これは「エージェントを安全に動かす足場(harness)」の考え方で、自由に触らせず、見るべき場所と守るべきルールを先に固定するやり方です。
あなたは Vue 3 + TypeScript + Pinia のプロジェクトで作業します。
ゴール:
チケットフォームのバリデーションを、型付き・再利用可能・Vitestでカバー済みにリファクタする。
触ってよいファイル:
- src/components/TicketForm.vue
- src/composables/useFilteredTickets.ts
- src/stores/ticketStore.ts
- src/stores/ticketStore.test.ts
ルール:
- <script setup lang="ts"> を使う
- props を直接書き換えない
- any を入れない
- 派生値は computed を使う
- watch は明示的な副作用のときだけ使う
- UIの文言は、不足しているバリデーション以外いじらない
確認コマンド:
- npm run typecheck
- npm run test:unit
編集後、リスクのある箇所と追加したテストを説明すること。
ポイントは「Claude Codeに考えさせる部分」と「人間が決める部分」を分けること。バリデーションの文言、既存UIの互換性、API仕様は人間が決める。型の整備、Composable抽出、テスト追加はClaude Codeに任せる。この分担だけで、あとから手で直す時間が大きく減ります。型まわりの詰め方はTypeScript活用テクニック、テストの広げ方はテスト戦略ガイドに分けて書いています。
React経験者がVueでやらかす罠5つ(僕の実体験つき)
正直に、自分がハマった順に挙げます。
ひとつ目、reactiveの分割代入。冒頭のバグです。const { query } = stateでつながりが切れる。共有しない状態はrefで持てば避けられます。
ふたつ目、propsの直接更新。Reactでもアンチパターンですが、Vueはより静かに壊れます。親から来た値を子で書き換えると流れが読めなくなる。ローカル編集用のrefを作るか、defineModel/emitで親に返しましょう。
みっつ目、watchの乱用。useEffectの癖で何でもwatchに入れると、値の出どころが追えなくなります。派生はcomputed、副作用だけwatch。
よっつ目、Options APIとの混在。Vue 3はdata/methodsの古い書き方(Options API)も動きます。が、同じ画面でscript setupと混ぜると、人間もClaude Codeも変更箇所を追えなくなる。新規はscript setupに統一、既存は1コンポーネント単位で寄せます。
いつつ目、型がいつの間にかany。Claude Codeがエラー回避でas anyを入れることがあります。レビューではany・unknown・型アサーション・空配列の推論を必ず見ます。
よくある質問
Q. Vue 3でReactのuseStateにあたるのはrefとreactiveのどっち?
A. 基本はrefです。<script>内では.valueで読み書きします。reactiveはオブジェクトを丸ごとリアクティブにしますが、分割代入でつながりが切れる罠があるので、移行組はまずrefに寄せると安全です。
Q. computedとwatchはどう使い分ける?
A. 「値を計算して返す」ならcomputed、「値の変化をきっかけに何か外向きの処理をする」ならwatchです。検索結果や合計値はcomputed、ページ番号のリセットやAPI呼び出しはwatch、と覚えると迷いません。
Q. PiniaのVuexとの違いは?setup storeとoption storeどっちを使う?
A. PiniaはVuexの後継にあたる、Vueチーム公式が推す状態管理ライブラリです。TypeScriptとの相性が良く、mutationの概念がなくシンプルです。Composition APIに慣れているなら、ref/computed/関数をそのまま書けるsetup storeがおすすめです。
Q. script setupは使うべき?Options APIはもう古い?
A. 新規コードはscript setupで問題ありません。Vue 3で最も簡潔にComposition APIを書ける記法で、公式ドキュメントも推奨しています。Options APIも動きますが、1つの画面で混在させないことが大事です。
Q. Composableはいつ作る? A. 検索・ページング・フォーム制御など、複数の場所で再利用しそうなロジックや、SFCが太ってきたと感じたタイミングです。逆に1回しか使わないものを無理に切り出すと、配線だけ増えて読みにくくなります。
実際に試した結果
この流れを小さなVue 3 + TypeScriptの検証プロジェクトで通してみて、いちばん効いたのは「派生はcomputed、副作用だけwatch」を最初に決めたことでした。これを徹底しただけで、冒頭の「たまに反映されない」系のバグが出なくなった。Reactから来た僕の事故は、ほぼ全部「Reactの癖をVueに持ち込んだこと」が原因だったんだと、書きながら腑に落ちました。
Claude Codeへの依頼も、script setup・props不変・computed優先・確認コマンドを先に渡す形に変えたら、生成直後の差分で直す箇所が目に見えて減りました。Vueは自由だからこそ、ルールを先に置いた人が勝ちます。React/Svelte/Angularとの比較や、チームでの導入ルールづくりは、Reactでの実戦投入やCLAUDE.mdの粒度設計もあわせて読むと立体的に掴めます。基礎はVue公式のComposition API + TypeScriptガイドが一次情報として確実です。
もし「自分のVueコードを棚卸ししたい」「チームにClaude Codeを入れたい」なら、研修・相談でも個別に手伝っています。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
制作会社がClaude Codeに触らせる前に決める権限チェックリスト
クライアントサイトを壊さずにAI編集を使うための、制作会社向け権限と確認の型です。
SaaSサポートのバグ報告をClaude Codeで再現手順に変える実務フロー
問い合わせ文をそのまま開発へ投げず、再現手順、証拠、次の一手に整えるサポート向け手順です。
Obsidianの古いメモをClaude Codeの指示書に変える10分ルーチン
Obsidianに溜めたメモが毎回ゴミになる人へ。事実・決定・未確認に仕分けして、Claude Codeがそのまま動ける指示書に変える朝の10分の型を紹介します。