This guide helps React developers rebuild their migration path to Vue 3. It focuses on the reactivity system, mapping Hooks to the Composition API, differences in component communication and state management, and resolves three common pain points:
.valueconfusion,watchmisuse, and choosing Pinia. Keywords: React to Vue 3, reactivity system, Pinia.
Technical Specifications Snapshot
| Parameter | Description |
|---|---|
| Language | TypeScript / JavaScript |
| Frameworks | React, Vue 3 |
| Core Paradigms | Component-based UI, declarative rendering, reactive updates |
| Star Count | Not provided in the source |
| Core Dependencies | react, vue, pinia |
The fundamental difference between React and Vue 3 is their update model
React drives component re-renders through state changes, then uses Virtual DOM diffing to converge on the minimal real DOM updates. Vue 3, by contrast, uses Proxy-based dependency tracking, so it updates exactly where the data is consumed.
This means the biggest migration challenge is not syntax, but mental models. React emphasizes immutable state and control over the rendering process. Vue 3 emphasizes declarative reactivity and automatic dependency collection.
React state updates depend on explicit triggers
import { useEffect, useState } from 'react';
function UserProfile({ userId }: { userId: number }) {
const [user, setUser] = useState<{ name: string } | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchUser(userId).then((data) => {
setUser(data); // Explicitly update state and trigger a component re-render
setLoading(false); // Trigger another update
});
}, [userId]); // Re-run the side effect when dependencies change
return loading ? <span>Loading</span> : <div>{user?.name}</div>;
}
This code shows the typical React flow: state changes trigger a re-render, and then the framework decides the minimum DOM changes.
Vue 3 reactive updates depend on automatic tracking
import { ref, computed, onMounted } from 'vue';
const user = ref<{ name: string } | null>(null);
const loading = ref(true);
const userId = ref(1);
const displayName = computed(() => user.value?.name ?? 'Loading'); // Automatically collect dependencies
onMounted(async () => {
const data = await fetchUser(userId.value);
user.value = data; // Assign directly and let Vue 3 track updates automatically
loading.value = false;
});
<template>
<div>{{ loading ? 'Loading' : displayName }}</div>
</template>
The core value of this example is that update actions are naturally tied to template subscriptions, so developers do not need to manually reason about re-render boundaries.
Component communication requires a full replacement strategy during migration
React commonly uses props + callback functions for parent-child communication. Vue 3 still keeps props as inputs, but uses emit on the output side. This aligns better with framework conventions and improves type declarations.
Props and events are more clearly separated in Vue 3
interface UserCardProps {
name: string;
age: number;
avatar?: string;
}
const props = defineProps
<UserCardProps>();
const emit = defineEmits<{
update: [id: number, name: string];
}>();
const handleClick = () => {
emit('update', 1, props.name); // Send data back to the parent component with an event
};
This code shows how Vue 3 explicitly separates input and output, reducing the semantic overload of callback props in React.
Lifecycle logic cannot be mapped mechanically from useEffect
React often puts mounting, updates, and cleanup into useEffect. Vue 3 splits these concerns into onMounted, watch, and onUnmounted, making each responsibility more direct.
import { onMounted, onUnmounted, watch } from 'vue';
onMounted(() => {
console.log('Component mounted'); // Handle initialization logic
});
watch(userId, (newId) => {
fetchUser(newId); // Precisely watch a specific dependency change
});
onUnmounted(() => {
console.log('Component unmounted and cleaned up'); // Release subscriptions, timers, and other resources
});
The key takeaway is that Vue 3 encourages developers to organize side effects by intent, instead of putting all timing-related logic into a single Hook.
Mapping Hooks to the Composition API is semantic reconstruction, not one-to-one translation
The most common mapping is useState → ref/reactive, useEffect → watch/watchEffect, and useMemo → computed. But keep in mind that they are similar, not equivalent.
ref, reactive, and computed form the first core migration layer
import { ref, reactive, computed, watchEffect } from 'vue';
const count = ref(0); // Use ref for primitive values
const user = reactive({ name: '', age: 0 }); // Prefer reactive for objects
const profileText = computed(() => `${user.name}-${user.age}`); // Use computed for derived state
watchEffect(() => {
document.title = profileText.value; // Automatically collect dependencies and run immediately
});
count.value++; // In script, refs must be read and written through .value
user.name = 'Tom'; // reactive objects can be mutated directly
This example summarizes Vue 3’s basic data layering: source state, object state, and derived state each have a dedicated API.
Composables are a more natural replacement for custom React Hooks
import { ref, watch, readonly, type Ref } from 'vue';
export function useUserSearch(keyword: Ref
<string>) {
const results = ref<string[]>([]);
const loading = ref(false);
watch(
keyword,
async (kw) => {
if (!kw) {
results.value = []; // Clear results when the keyword is empty
return;
}
loading.value = true;
results.value = await search(kw); // Fetch search results asynchronously
loading.value = false;
},
{ immediate: true }
);
return {
results: readonly(results), // Expose read-only state externally
loading: readonly(loading)
};
}
This code demonstrates the advantage of composables: logic reuse feels more natural, and state exposure boundaries are clearer.
The most common pitfalls for React developers fall into three categories
The first category is continuing to think in immutable object spreads. Once Vue 3 wraps objects with reactive or ref, many scenarios should use direct property mutation instead of blindly replacing objects with spread syntax.
const user = ref({ name: 'old name', age: 18 });
user.value.name = 'new name'; // Correct: preserve the reactive reference
Object.assign(user.value, { age: 19 }); // Correct: update object fields in batch
This example shows that in Vue 3, direct mutation is usually not a code smell. It is often the recommended path.
The second category is treating watchEffect like useEffect with a dependency array. watchEffect runs immediately and collects dependencies automatically. If you mutate the same reactive source inside it, you can easily create circular triggers.
The third category is forgetting .value. The rule is simple: in script, reading and writing a ref requires .value; in template, refs are usually unwrapped automatically, so you do not need to repeat it.
Pinia aligns more naturally with Vue 3’s compositional style than Redux
React projects often layer state management with Context, Redux, or Zustand. In the Vue 3 ecosystem, prefer ref/reactive for local component state, provide/inject for cross-component sharing, and Pinia for global business state.
Pinia reduces boilerplate significantly
import { defineStore } from 'pinia';
export const useUserStore = defineStore('user', {
state: () => ({
name: '',
token: ''
}),
actions: {
setUser(data: { name: string; token: string }) {
this.name = data.name; // Mutate store state directly
this.token = data.token;
},
async fetchUser(id: number) {
const data = await api.getUser(id); // Fetch remote user data
this.setUser(data);
}
}
});
This code shows that Pinia no longer forces a strict separation between actions, reducers, and dispatch. It fits Vue 3’s low-boilerplate development experience much better.
A migration checklist can significantly reduce trial-and-error cost
| Checklist Item | React Habit | Correct Vue 3 Approach |
|---|---|---|
| State updates | setState/useState | ref.value or reactive.key |
| Object mutation | Spread and return a new object | Direct assignment or Object.assign |
| Side effects | useEffect | watch / watchEffect |
| Derived data | useMemo | computed |
| Child-to-parent communication | callback props | defineEmits |
| Global state | Redux / Context | Pinia |
| Cleanup logic | effect return | onUnmounted |
FAQ: The 3 questions developers ask most often
Q1: Is ref in Vue 3 the same as useRef in React?
No. Vue 3’s ref is closer to React’s useState. It is a reactive data container, not just a mutable reference box.
Q2: When should I use watch, and when should I use watchEffect?
Use watch when you need to observe a specific source precisely and access new and old values. Use watchEffect when you want automatic dependency collection and an immediately executed side effect.
Q3: Do I still need Pinia after migrating to Vue 3?
Not in every scenario. Prefer ref/reactive for local state. Use Pinia only for business state shared across pages or modules.
AI Readability Summary: A fast-reference Vue 3 migration guide for React developers that focuses on reactivity principles, Composition API mappings, lifecycle differences, and Pinia state management. It distills 10 high-frequency pitfalls and their correct implementations to help you quickly build a Vue 3 mental model.