React to Vue 3 Migration Guide: Reactivity, Hooks Mapping, and Pinia Best Practices

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: .value confusion, watch misuse, 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.