VuReact is a compiler toolchain for migrating from Vue to React — and for writing React with Vue 3 syntax.
In this article, we will look at how Vue 3's defineModel macro is compiled into React.
Before We Start
To keep the examples easy to read, this article follows a few simple conventions:
- All Vue and React snippets focus on core logic only, with full component wrappers and unrelated configuration omitted.
- The discussion assumes you are already familiar with the API shape and core behavior of Vue 3's
defineModel. - VuReact only supports the
type,default,requiredoptions and a custom prop name. - Array destructuring of the return value is not supported.
Compilation Mapping
Vue defineModel → useVRef + useUpdated for auto-sync
defineModel is a Vue 3 <script setup> macro that simplifies v-model two-way binding declarations. Under the hood, Vue's compiler generates a ref along with a modelValue prop and an update:modelValue event. VuReact compiles this into useVRef (which turns a prop value into a reactive ref) paired with useUpdated (which triggers the parent's onUpdate:xxx callback when the value changes).
- Vue
// Declares a "state" prop consumed by the parent via v-model:state
const state = defineModel<string>('state');
// Declares a "modelValue" prop (default: 'xxx') consumed via v-model
const modelValue = defineModel({ default: 'xxx' });
- Compiled React
import { useVRef, useUpdated } from '@vureact/runtime-core';
type IChildProps = {
state?: string;
modelValue?: string;
} & {
onUpdateState?: (arg: string) => void;
onUpdateModelValue?: (arg: string) => void;
};
// "state" prop consumed by parent via onUpdateState
const state = useVRef<string>(props.state);
// "modelValue" prop (default: 'xxx') consumed via onUpdateModelValue
const modelValue = useVRef<string>(props.modelValue ?? 'xxx');
// Automatically notifies parent on value change
useUpdated(() => {
props.onUpdateState?.(state.value);
}, [state.value]);
useUpdated(() => {
props.onUpdateModelValue?.(modelValue.value);
}, [modelValue.value]);
As the example shows, Vue's defineModel is decomposed into three parts:
-
Prop type declaration — non-event fields in
IChildProps(state?,modelValue?); -
Event callback declaration —
onUpdateXxxfields inIChildProps; -
Runtime reactivity —
useVRefwraps the initial prop value into a reactive ref, anduseUpdatedwatches the ref and automatically invokes the parent-provided callback.
With VuReact, directly mutating the ref's .value inside the component triggers the parent's update — exactly like Vue's development experience.
defineModel(name, options) → typed prop declaration with default
defineModel accepts a name to specify the prop name and limited options (type, default, required). VuReact converts these options into corresponding TypeScript type constraints and default-value handling.
- Vue
const count = defineModel<number>('count', {
type: Number,
default: 0,
required: true,
});
- Compiled React
type IChildProps = {
count: number; // required: true → non-optional type
} & {
onUpdateCount?: (arg: number) => void;
};
const count = useVRef<number>(props.count ?? 0); // default: 0
Option mapping rules:
-
required: truemakescountrequired (count: number) instead of optional (count?: number). -
default: 0is implemented via the??nullish coalescing operator, falling back to0when the parent does not pass the prop. -
type: Numberinfluences the generic type parameter (<number>) in the generated TypeScript definition.
Direct .value assignment → auto-sync with parent
In Vue, defineModel returns a ref; you read and write its .value. VuReact preserves this .value access pattern in the compiled React code.
- Vue
const state = defineModel<string>('state');
const update = () => {
state.value = 'hello'; // direct assignment
};
- Compiled React
const state = useVRef<string>(props.state);
const update = useCallback(() => {
state.value = 'hello'; // triggers props.onUpdateState automatically
}, [state.value]);
Compilation rules:
-
useCallbackwrapping — functions that touchstate.valueare wrapped withuseCallback. -
Correct dependency tracking —
state.valueis added to the dependency array. -
Parent sync —
state.value = 'hello'simultaneously updates the component's local state and pushes the new value to the parent.
v-model in templates → React controlled components
Vue templates use v-model to bind a defineModel ref; VuReact compiles this into the standard React controlled-component pattern (value + onChange).
- Vue
<template>
<input v-model="modelValue" />
<div>Parent bound v-model is: {{ count }}</div>
<button @click="update">Increment</button>
</template>
- Compiled React
<input
value={modelValue}
onChange={(e) => {
modelValue = e.target.value;
}}
/>
<div>Parent bound v-model is:{count.value}</div>
<button onClick={update}>Increment</button>
v-model conversion rules:
-
valuebinding — the ref's value is bound to thevalueattribute. -
onChangehandler — the ref is reassigned directly in theonChangecallback. -
Automatic sync — the reassignment triggers
useUpdated, which pushes the new value to the parent.
Unsupported defineModel patterns
VuReact explicitly does not support the following defineModel usages and will skip them with an error.
1. Array destructuring of the return value
<script setup lang="ts">
// Not supported (Vue 3.4+ experimental feature)
const [arg1, arg2] = defineModel();
</script>
Vue 3.4+ supports destructuring the return value into [model, modifiers] to access modifier status. VuReact does not support this syntax yet. Use the standard form instead:
<script setup lang="ts">
// Supported form
const model = defineModel();
</script>
2. get / set / validator options
<script setup lang="ts">
// Not supported
const modelValue = defineModel({
get() {},
set() {},
validator() {},
});
</script>
Vue's defineModel supports custom get/set accessors and a validator function. VuReact does not support these options yet. Use useVRef directly to implement custom logic if needed.
Full example
Below is a complete single-file component using defineModel and its compiled React output.
- Vue (
input.vue):
<script setup lang="ts">
// @vr-name: Child
const state = defineModel<string>('state');
const modelValue = defineModel({ default: 'xxx' });
const count = defineModel<number>('count', {
type: Number,
default: 0,
required: true,
});
const update = () => {
state.value = 'hello';
count.value++;
};
</script>
<template>
<input v-model="modelValue" />
<div>Parent bound v-model is: {{ count }}</div>
<button @click="update">Increment</button>
</template>
- Compiled React (
output.tsx):
import { useCallback, memo } from 'react';
import { useVRef, useUpdated } from '@vureact/runtime-core';
export type IChildProps = {
state?: string;
modelValue?: string;
count: number;
} & {
onUpdateState?: (arg: string) => void;
onUpdateModelValue?: (arg: string) => void;
onUpdateCount?: (arg: number) => void;
};
const Child = memo((props: IChildProps) => {
const state = useVRef<string>(props.state);
const modelValue = useVRef<string>(props.modelValue ?? 'xxx');
const count = useVRef<number>(props.count ?? 0);
const update = useCallback(() => {
state.value = 'hello';
count.value++;
}, [state.value, count.value]);
useUpdated(() => {
props.onUpdateState?.(state.value);
}, [state.value]);
useUpdated(() => {
props.onUpdateModelValue?.(modelValue.value);
}, [modelValue.value]);
useUpdated(() => {
props.onUpdateCount?.(count.value);
}, [count.value]);
return (
<>
<input
value={modelValue}
onChange={(e) => {
modelValue = e.target.value;
}}
/>
<div>Parent bound v-model is:{count.value}</div>
<button onClick={update}>Increment</button>
</>
);
});
export default Child;
Top comments (0)