n
The transformation pipeline operates in three distinct phases: scope isolation, dependency extraction, and code generation. Each phase is designed to align with React's execution model while respecting Vue 3's syntactic conventions.
Step 1: AST Parsing and Scope Isolation
The compiler first parses the Vue 3 <script setup> block into an AST. It identifies all top-level arrow function declarations and separates them from nested or local functions. This distinction is critical because React's useCallback() only provides value when the function reference escapes the component scope (e.g., passed to children, used in effects, or assigned to object properties). Nested functions that remain internal to the handler do not benefit from memoization and would only add unnecessary Hook overhead.
For each top-level arrow function, the compiler traverses the function body to collect all external variable references. It filters out:
- Variables declared inside the function
- Vue-specific reactivity primitives that map to React state (e.g.,
.value access patterns)
- Global or imported constants that do not change across renders
The remaining variables form the dependency array. The compiler maps Vue's reactive access patterns (like state.bar.c or foo.value) to their React equivalents, ensuring the dependency array reflects the actual values React needs to track.
Step 3: Conditional Code Generation
The compiler applies a decision matrix before generating output:
- If the function has zero external dependencies β leave as a plain arrow function
- If the function has one or more external dependencies β wrap in
useCallback() with the extracted array
- If the function contains nested arrow functions β only wrap the top-level function; leave nested functions untouched
Vue 3 Source (<script setup>)
<script setup lang="ts">
import { ref, reactive } from 'vue'
const username = ref('')
const formData = reactive({ email: '', role: 'viewer' })
const isSubmitting = ref(false)
const submitForm = () => {
isSubmitting.value = true
console.log(`Sending ${username.value} with role ${formData.role}`)
}
const formatTimestamp = () => {
const now = new Date()
return now.toISOString()
}
const toggleRole = () => {
const currentRole = formData.role
const switchRole = () => {
formData.role = currentRole === 'viewer' ? 'editor' : 'viewer'
}
switchRole()
}
</script>
Compiled React Output
import { useState, useCallback } from 'react'
export function MigratedComponent() {
const [username, setUsername] = useState('')
const [formData, setFormData] = useState({ email: '', role: 'viewer' })
const [isSubmitting, setIsSubmitting] = useState(false)
const submitForm = useCallback(() => {
setIsSubmitting(true)
console.log(`Sending ${username} with role ${formData.role}`)
}, [username, formData.role])
const formatTimestamp = () => {
const now = new Date()
return now.toISOString()
}
const toggleRole = useCallback(() => {
const currentRole = formData.role
const switchRole = () => {
setFormData(prev => ({ ...prev, role: currentRole === 'viewer' ? 'editor' : 'viewer' }))
}
switchRole()
}, [formData.role])
return null
}
Architecture Decisions & Rationale
Why skip dependency-free functions?
Wrapping a function with no external references in useCallback() adds a Hook call overhead without providing stability benefits. The compiler explicitly checks for external variable usage and leaves pure utility functions untouched. This keeps the compiled output lean and avoids violating React's rules of Hooks unnecessarily.
Why ignore nested functions?
Nested arrow functions are recreated on every invocation of their parent. They rarely escape the component scope, meaning React's reconciliation process never compares their references. Forcing them into useCallback() would require lifting them out of scope or creating complex closure tracking, which introduces more bugs than it solves. The compiler respects this boundary and only optimizes functions that actually impact render stability.
Why map .value access to direct state references?
Vue 3's ref and reactive require .value or property access to read state. React's useState returns the value directly. The compiler strips .value during transformation and adjusts dependency arrays to reference the base state variable or specific nested properties. This ensures the dependency array matches React's expectation: track the actual value, not the wrapper object.
Pitfall Guide
1. Over-Memoization Assumption
Explanation: Developers often assume every function should be wrapped in useCallback(). This leads to unnecessary Hook calls and can actually degrade performance due to memory allocation for cached functions.
Fix: Rely on the compiler's dependency analysis. Only functions that reference external state or props benefit from memoization. Pure functions should remain plain.
2. Stale Closure from Missing Dependencies
Explanation: When manually migrating, developers sometimes omit indirect dependencies (e.g., a nested object property or a derived value). This causes the callback to capture outdated state.
Fix: Let the compiler trace all external references. If manual intervention is required, use ESLint's react-hooks/exhaustive-deps rule to catch missing dependencies before runtime.
3. Nested Function Escaping Scope
Explanation: If a nested function is returned or passed to a child component, the compiler's default behavior (leaving it plain) will cause reference instability.
Fix: Explicitly hoist nested functions that escape scope to the top level before compilation, or manually wrap them post-compilation. The compiler only optimizes functions that remain internal.
4. React Strict Mode Double Invocation
Explanation: React 18's Strict Mode intentionally invokes components twice in development. Developers sometimes mistake this for a bug in the compiled output, thinking the compiler generated duplicate logic.
Fix: Understand that double invocation is a development-only safety check. The compiled useCallback() behavior remains consistent. Use production builds to verify actual performance characteristics.
5. Mixing Vue Reactivity Patterns with React State
Explanation: Vue's reactive objects are proxied, while React's useState requires immutable updates. Directly copying mutation logic (e.g., formData.role = 'admin') breaks React's change detection.
Fix: The compiler automatically transforms direct mutations into functional state updates (setFormData(prev => ({ ...prev, role: 'admin' }))). Verify that all state modifications follow React's immutable pattern in the output.
6. Dependency Array Bloat
Explanation: Compilers sometimes include entire objects in dependency arrays when only a specific property is used. This causes unnecessary re-renders when unrelated properties change.
Fix: The compiler extracts granular dependencies (e.g., formData.role instead of formData). If manual adjustment is needed, destructure the required properties at the top of the component and reference them directly.
7. Ignoring Post-Compilation Verification
Explanation: Teams assume the compiler output is production-ready without profiling. This can miss edge cases where reference stability doesn't align with actual render patterns.
Fix: Run React DevTools Profiler on migrated components. Compare render counts before and after compilation. Use React.memo() on child components to validate that callback stability actually reduces re-renders.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Small utility functions (no external refs) | Leave as plain arrow functions | Zero Hook overhead, no stability benefit | Negligible |
| Event handlers referencing state/props | Compile to useCallback() | Prevents child re-renders, ensures stable references | Low (Hook call) |
| Complex business logic with multiple deps | Compile to useCallback() with granular deps | Maintains performance while preserving logic structure | Low |
Legacy React code with manual useCallback | Replace with compiler output | Reduces maintenance burden, eliminates stale deps | Medium (refactor time) |
| Migration of large Vue 3 codebase | Full compiler pipeline | Automated dependency resolution, zero manual annotation | High initial, low long-term |
Configuration Template
// vureact.config.ts
import { defineConfig } from 'vureact/compiler'
export default defineConfig({
target: 'react-18',
scriptSetup: {
optimizeTopLevelArrows: true,
dependencyGranularity: 'property-level',
skipDependencyFreeFunctions: true,
preserveNestedFunctions: true,
},
stateMapping: {
vueRefToReactState: 'useState',
vueReactiveToReactState: 'useState',
mutationStrategy: 'functional-update',
},
output: {
format: 'typescript',
strictMode: true,
sourceMap: true,
},
})
Quick Start Guide
- Install the compiler package: Run
npm install vureact @vureact/compiler in your project root.
- Create the configuration file: Copy the template above into
vureact.config.ts and adjust target/framework versions to match your stack.
- Run the transformation: Execute
npx vureact compile ./src/vue-components --out ./src/react-components to process your Vue 3 files.
- Verify the output: Open a compiled component and confirm that top-level functions with external references are wrapped in
useCallback() with accurate dependency arrays.
- Integrate into CI: Add a compilation step to your pipeline to ensure all migrated code passes TypeScript and React Hook linting before deployment.