, it performs three coordinated transformations:
- Component Wrapping: The functional component is wrapped with
forwardRef() to enable ref forwarding.
- Handle Binding: The exposed object is mapped to
useImperativeHandle(), which attaches the payload to the forwarded ref.
- Reactivity Shim: Internal state declarations are routed through a Vue-compatible reactive proxy (e.g.,
useVRef), preserving .value semantics while triggering React's update cycle.
Step-by-Step Implementation
1. Child Component Translation
Consider a Vue component that tracks engagement metrics and exposes a reset mechanism:
<script setup lang="ts">
import { ref, defineExpose } from 'vue';
defineProps<{ dashboardId: string }>();
const engagementScore = ref(0);
const resetMetrics = () => {
engagementScore.value = 0;
};
defineExpose({
engagementScore,
resetMetrics,
});
</script>
The compiler parses the defineExpose call, extracts the object keys, and generates a React equivalent:
import { forwardRef, useCallback, useImperativeHandle, memo } from 'react';
import { useVRef } from '@vureact/runtime-core';
type DashboardMetricsProps = { dashboardId: string };
export const DashboardMetrics = memo(
forwardRef<DashboardMetricsHandle, DashboardMetricsProps>((props, forwardedRef) => {
const engagementScore = useVRef(0);
const resetMetrics = useCallback(() => {
engagementScore.value = 0;
}, []);
useImperativeHandle(forwardedRef, () => ({
engagementScore,
resetMetrics,
}));
return (
<section data-testid="metrics-panel">
<p>Score: {engagementScore.value}</p>
</section>
);
}),
);
export interface DashboardMetricsHandle {
engagementScore: { value: number };
resetMetrics: () => void;
}
2. Parent Component Translation
In Vue, the parent attaches a template ref and accesses the exposed payload after mount:
<template>
<DashboardMetrics ref="metricsRef" :dashboard-id="activeId" />
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
const metricsRef = ref();
const activeId = 'dash-01';
onMounted(() => {
console.log(metricsRef.value?.engagementScore.value);
metricsRef.value?.resetMetrics();
});
</script>
The compiler translates this to React's useRef + ref.current pattern, while preserving the lifecycle timing:
import { useRef, useEffect } from 'react';
import { DashboardMetrics, DashboardMetricsHandle } from './DashboardMetrics';
export const AnalyticsView = () => {
const metricsRef = useRef<DashboardMetricsHandle>(null);
const activeId = 'dash-01';
useEffect(() => {
const handle = metricsRef.current;
if (handle) {
console.log(handle.engagementScore.value);
handle.resetMetrics();
}
}, []);
return <DashboardMetrics ref={metricsRef} dashboardId={activeId} />;
};
Architecture Decisions & Rationale
Why forwardRef? React requires explicit opt-in for ref forwarding. Without it, refs attach to the component function itself, not the underlying DOM or exposed handle. The compiler wraps every component using defineExpose with forwardRef to guarantee the ref reaches the correct attachment point.
Why useImperativeHandle? This hook intercepts the forwarded ref and replaces it with a custom object. It prevents the parent from accessing internal React state directly, enforcing a controlled surface area. The compiler maps the exact keys from defineExpose to this hook, preserving the original contract.
Why preserve .value? Vue developers expect reactive primitives to mutate via .value. Stripping this syntax would force manual refactoring across hundreds of parent components. The runtime shim (useVRef) wraps the value in a proxy that triggers React's scheduler on mutation, maintaining the familiar interaction model while operating under React's rendering rules.
Why useCallback with empty deps? Exposed methods must maintain stable references to prevent unnecessary re-renders in the parent. The compiler analyzes method dependencies and generates stable callbacks. If a method captures external state, the compiler injects the appropriate dependency array.
Pitfall Guide
1. Over-Exposing Reactive State
Explanation: Exposing entire reactive objects instead of specific methods or computed values breaks React's unidirectional flow. Parents may mutate state directly, bypassing React's reconciliation.
Fix: Limit exposure to methods and read-only getters. Use Object.freeze() or explicit interfaces to prevent accidental mutation.
2. Stale Closures in Exposed Methods
Explanation: Exposed functions that reference component state without proper dependency tracking will capture outdated values. React's closure model differs from Vue's proxy-based reactivity.
Fix: Always wrap exposed methods in useCallback with accurate dependency arrays. The compiler should analyze variable captures and generate stable references automatically.
3. Type Mismatches Between Ref<T> and MutableRefObject
Explanation: Vue's Ref<T> and React's useRef return different shapes. Direct type mapping causes TypeScript errors when accessing .value or assigning new values.
Fix: Generate explicit handle interfaces that define the exact shape of exposed properties. Use conditional types to bridge Vue's Ref<T> to { value: T } in the compiled output.
Explanation: If useImperativeHandle returns a new object on every render, parents that depend on the ref will re-render unnecessarily.
Fix: Memoize the handle object. The compiler should wrap the return value in a stable reference or use useMemo when the exposed payload contains multiple properties.
5. Forgetting Ref Forwarding in Higher-Order Components
Explanation: Wrapping compiled components with HOCs or context providers breaks ref forwarding unless explicitly passed through.
Fix: Ensure all wrapper components use forwardRef and pass the ref to the underlying compiled component. Add lint rules to catch missing ref propagation.
6. Mixing Vue Lifecycle Hooks with React Effects
Explanation: onMounted in Vue fires once after DOM insertion. Translating it to useEffect without an empty dependency array causes repeated execution.
Fix: Map Vue lifecycle macros to precise React equivalents (onMounted β useEffect([], []), onUpdated β useEffect with specific deps). The compiler should enforce dependency array generation.
7. Assuming .value Mutations Trigger React Updates Automatically
Explanation: Without the runtime shim, mutating .value on a standard React ref does not schedule a re-render. Developers may see stale UI after calling exposed methods.
Fix: Always route internal state through the framework's reactive proxy (useVRef or equivalent). Verify that mutations trigger setState or useSyncExternalStore under the hood.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Greenfield React project | Native forwardRef + useImperativeHandle | No legacy constraints, full control over architecture | Low (initial setup) |
| Large Vue codebase migration | VuReact compiler with gradual rollout | Preserves existing contracts, reduces refactoring surface area | Medium (toolchain integration) |
| Hybrid Vue/React microfrontends | Compiler translation + explicit handle interfaces | Ensures type safety across framework boundaries | High (cross-team coordination) |
| Performance-critical dashboard | Native React state lifting + callbacks | Eliminates imperative handle overhead, maximizes render efficiency | High (architecture rewrite) |
Configuration Template
// vite.config.ts
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import vureact from '@vureact/vite-plugin';
export default defineConfig({
plugins: [
vue(),
vureact({
transform: {
defineExpose: true,
preserveValueSyntax: true,
generateHandleTypes: true,
},
runtime: {
reactiveProxy: '@vureact/runtime-core/useVRef',
lifecycleMapper: '@vureact/runtime-core/lifecycle',
},
typescript: {
strictHandleInterfaces: true,
outputDir: './generated/handles',
},
}),
],
build: {
rollupOptions: {
external: ['react', 'react-dom'],
},
},
});
Quick Start Guide
- Initialize the toolchain: Install
@vureact/core and @vureact/vite-plugin. Add the plugin to your Vite configuration with defineExpose: true.
- Compile a target component: Run
npx vureact compile ./src/components/ChildWidget.vue. Verify the output contains forwardRef, useImperativeHandle, and a generated ChildWidgetHandle interface.
- Update the parent: Replace the template ref with
useRef<ChildWidgetHandle>(null). Attach it to the compiled component and access ref.current inside useEffect.
- Validate runtime behavior: Start the dev server, trigger parent access, and confirm that
.value mutations schedule React updates correctly. Check React DevTools for stable handle references.
- Scale incrementally: Add the compiler to your CI pipeline. Migrate components in batches, prioritizing leaf nodes before moving to complex parent-child trees.
Compiler-assisted translation removes the friction of paradigm shifts without forcing immediate architectural rewrites. By mapping Vue's declarative exposure to React's imperative handles, teams preserve developer velocity, maintain type safety, and defer performance optimizations until they're actually needed. The key is treating the compiler as a semantic adapter, not a magic bullet. Validate handles, stabilize references, and gradually replace imperative patterns with React-native data flow as the codebase matures.