Vue 3 Composition API for .NET Engineers
For .NET engineers who know: C#, Blazor or Razor Pages, WPF data binding, MVVM You’ll learn: How Vue 3’s Composition API maps to the reactive, component-driven patterns you already use in Blazor and WPF, and how to write typed, testable Vue components with TypeScript Time: 15-20 min read
The .NET Way (What You Already Know)
In WPF and Blazor, the reactive UI story is built around two core ideas: observable state and declarative markup. In WPF, you implement INotifyPropertyChanged to make a property observable, bind it to a XAML element, and the framework updates the view when the property changes. In Blazor, you call StateHasChanged() or let Blazor’s component lifecycle manage re-renders automatically.
A Blazor component binds logic and markup in the same file with @code {} blocks:
<!-- Blazor: Counter.razor -->
@page "/counter"
<h1>Count: @count</h1>
<button @onclick="Increment">Increment</button>
@code {
private int count = 0;
private void Increment()
{
count++;
// Blazor triggers re-render automatically after event handlers
}
}
In WPF with MVVM, you separate the ViewModel from the View, but the pattern is conceptually the same: a property notifies the UI when it changes, the UI re-renders that specific region:
// WPF ViewModel
public class CounterViewModel : INotifyPropertyChanged
{
private int _count;
public int Count
{
get => _count;
set { _count = value; OnPropertyChanged(); }
}
public ICommand IncrementCommand => new RelayCommand(() => Count++);
public event PropertyChangedEventHandler PropertyChanged;
protected void OnPropertyChanged([CallerMemberName] string name = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
The key patterns you already know:
- Observable state — a property that triggers UI updates when it changes
- Computed/derived values — properties derived from other state, like
FullName = FirstName + " " + LastName - Event handlers — methods that respond to user input
- Lifecycle hooks —
OnInitialized,OnParametersSet,Dispose - Declarative markup — XAML or Razor syntax that describes what to render, not how
Vue 3’s Composition API maps directly onto every one of these patterns.
The Vue 3 Way
Single File Components (SFC)
Vue’s equivalent of a .razor file is a .vue Single File Component. It combines template (markup), script (logic), and styles in one file — just like Blazor combines Razor markup and @code {} in one file.
Counter.vue
├── <template> ← like @Page + HTML markup in .razor
├── <script setup> ← like @code {} in Blazor
└── <style scoped> ← like component-scoped CSS
The modern way to write a Vue SFC uses <script setup> — a compile-time macro that eliminates boilerplate. Here is the exact counter from Blazor, written in Vue:
<!-- Counter.vue -->
<script setup lang="ts">
import { ref } from 'vue'
const count = ref(0)
function increment() {
count.value++
}
</script>
<template>
<h1>Count: {{ count }}</h1>
<button @click="increment">Increment</button>
</template>
<style scoped>
button { padding: 0.5rem 1rem; }
</style>
This is a complete, runnable component. Notice:
ref(0)creates a reactive integer — equivalent to a Blazor field withStateHasChanged()wired up- In the
<template>, you accesscountdirectly (Vue unwraps the ref). In<script>, you usecount.value @clickis Vue’s event binding — the same as Blazor’s@onclick{{ count }}is interpolation — the same as@countin Razor
ref() and reactive(): Observable State
Vue has two primitives for reactive state: ref() and reactive().
ref() wraps a single value (primitive or object). Think of it as a box with a .value property that Vue watches for changes:
import { ref } from 'vue'
// Primitives
const name = ref<string>('') // like: private string _name = "";
const age = ref<number>(0) // like: private int _age = 0;
const isActive = ref<boolean>(false) // like: private bool _isActive = false;
// Objects — ref wraps the whole object
const user = ref<{ id: number; name: string } | null>(null)
// Reading: use .value in <script>
console.log(name.value) // ''
// Writing: assign to .value
name.value = 'Alice'
// Vue automatically re-renders any template that uses {{ name }}
reactive() works on objects only and makes every property individually observable — closer to how WPF’s INotifyPropertyChanged works on a class:
import { reactive } from 'vue'
const form = reactive({
firstName: '',
lastName: '',
email: ''
})
// Access directly — no .value needed
form.firstName = 'Alice'
console.log(form.firstName) // 'Alice'
The practical rule: use ref() for most things (primitives, API results, flags). Use reactive() when you have a cohesive object like a form model and want to avoid .value everywhere.
Concept mapping:
| WPF / Blazor | Vue 3 |
|---|---|
INotifyPropertyChanged property | ref() |
| Observable class (Fody, MVVM Toolkit) | reactive() |
StateHasChanged() | Called automatically — no manual trigger |
@bind-Value (Blazor) | v-model |
computed(): Derived/Calculated Properties
In C#, you write calculated properties with a getter:
public string FullName => $"{FirstName} {LastName}";
public bool IsFormValid => !string.IsNullOrWhiteSpace(Email) && Email.Contains('@');
In Vue, computed() does exactly the same thing. It re-evaluates only when its dependencies change (Vue tracks which refs you read inside the function):
import { ref, computed } from 'vue'
const firstName = ref('Alice')
const lastName = ref('Smith')
// Cached. Only re-evaluates when firstName or lastName changes.
const fullName = computed(() => `${firstName.value} ${lastName.value}`)
// In template: {{ fullName }} — no .value needed for computed in templates
Writable computed (like a C# property with a set):
const _count = ref(0)
const count = computed({
get: () => _count.value,
set: (val: number) => { _count.value = Math.max(0, val) } // enforce minimum
})
// Now you can do: count.value = -5 → clamped to 0
watch() and watchEffect(): Reacting to State Changes
In Blazor, OnParametersSet and OnAfterRender let you react to state changes. In WPF, PropertyChanged fires callbacks. Vue’s equivalents are watch() and watchEffect().
watch() watches a specific ref and fires a callback when it changes — equivalent to PropertyChanged on a specific property:
import { ref, watch } from 'vue'
const searchQuery = ref('')
const results = ref<string[]>([])
// Fires when searchQuery changes. Receives new and old values.
watch(searchQuery, async (newQuery, oldQuery) => {
if (newQuery.length < 2) {
results.value = []
return
}
results.value = await fetchResults(newQuery)
})
// Watch with options — like debouncing in .NET
watch(searchQuery, handler, {
immediate: true, // run immediately on mount, like OnInitialized
deep: true, // watch nested object properties
})
// Watch multiple sources at once
watch([firstName, lastName], ([newFirst, newLast]) => {
console.log(`Name changed to ${newFirst} ${newLast}`)
})
watchEffect() is more automatic — it runs immediately and re-runs whenever any reactive value it reads changes. You do not declare what to watch; Vue infers it:
import { ref, watchEffect } from 'vue'
const userId = ref(1)
const userData = ref<User | null>(null)
// Runs on mount and whenever userId.value changes
watchEffect(async () => {
userData.value = await fetchUser(userId.value) // Vue sees userId.value being read
})
Think of watchEffect as a self-registering observer: it subscribes itself to every reactive value it touches during execution.
Lifecycle Hooks
Blazor’s component lifecycle maps cleanly to Vue’s hooks:
import { onMounted, onUpdated, onUnmounted, onBeforeMount } from 'vue'
// onMounted = Blazor's OnAfterRenderAsync(firstRender: true) = WPF's Loaded
onMounted(async () => {
userData.value = await fetchUser(userId.value)
})
// onUnmounted = IDisposable.Dispose() / OnDetachedFromVisualTree
onUnmounted(() => {
clearInterval(timer)
subscription.unsubscribe()
})
// onUpdated — runs after every DOM update (rare, use with caution)
onUpdated(() => {
// Equivalent to OnAfterRenderAsync(firstRender: false)
})
All lifecycle hooks must be called during <script setup> execution (not inside a callback or conditional), just as Blazor lifecycle methods are defined at the component class level.
Lifecycle mapping:
| Blazor | WPF | Vue 3 |
|---|---|---|
OnInitializedAsync | Constructor | setup (the <script setup> block itself) |
OnAfterRenderAsync(true) | Loaded | onMounted |
OnParametersSet | OnPropertyChanged | watch on props |
OnAfterRenderAsync(false) | LayoutUpdated | onUpdated |
IDisposable.Dispose | Unloaded | onUnmounted |
Template Syntax: Directives
Vue’s template directives map directly to Razor tag helpers and XAML attributes:
<script setup lang="ts">
import { ref, computed } from 'vue'
const isLoggedIn = ref(true)
const userRole = ref<'admin' | 'user'>('user')
const items = ref(['Alpha', 'Beta', 'Gamma'])
const inputValue = ref('')
const cssClass = ref('active')
const imageUrl = ref('/logo.png')
</script>
<template>
<!-- v-if / v-else-if / v-else = @if / else in Razor, Visibility in WPF -->
<div v-if="isLoggedIn && userRole === 'admin'">Admin panel</div>
<div v-else-if="isLoggedIn">User dashboard</div>
<div v-else>Please log in</div>
<!-- v-for = @foreach in Razor, ItemsSource in WPF -->
<!-- :key is required — like React's key prop. Helps Vue track list items. -->
<ul>
<li v-for="(item, index) in items" :key="item">
{{ index + 1 }}. {{ item }}
</li>
</ul>
<!-- v-model = two-way binding. @bind-Value in Blazor, {Binding Mode=TwoWay} in WPF -->
<input v-model="inputValue" type="text" />
<p>You typed: {{ inputValue }}</p>
<!-- v-bind (shorthand: colon) = binding an attribute to an expression -->
<!-- : means "this is an expression, not a string literal" -->
<div :class="cssClass">Styled div</div>
<img :src="imageUrl" :alt="'Logo'" />
<!-- v-on (shorthand: @) = event binding. @onclick in Blazor -->
<button @click="() => items.push('Delta')">Add item</button>
<input @keyup.enter="() => console.log('Enter pressed')" />
<!-- v-show = visibility toggle. Does NOT remove from DOM (like WPF Visibility.Hidden) -->
<!-- v-if REMOVES the element. v-show just hides it with display:none -->
<p v-show="isLoggedIn">Visible but always rendered</p>
</template>
Props and Emits: Component Communication
In Blazor, a component receives data via [Parameter] attributes and communicates back via EventCallback<T>. In Vue, these are defineProps and defineEmits.
<!-- UserCard.vue — child component -->
<script setup lang="ts">
// defineProps: equivalent to [Parameter] in Blazor
const props = defineProps<{
userId: number
displayName: string
isEditable?: boolean // optional — like [Parameter] with a default
}>()
// defineEmits: equivalent to EventCallback<T> in Blazor
const emit = defineEmits<{
'user-deleted': [id: number]
'display-name-changed': [id: number, newName: string]
}>()
function handleDelete() {
// Equivalent to: await OnDeleted.InvokeAsync(props.userId)
emit('user-deleted', props.userId)
}
function handleRename(newName: string) {
emit('display-name-changed', props.userId, newName)
}
</script>
<template>
<div class="user-card">
<h3>{{ displayName }}</h3>
<button v-if="isEditable" @click="handleDelete">Delete</button>
</div>
</template>
Consuming that component from a parent:
<!-- ParentPage.vue -->
<script setup lang="ts">
import UserCard from './UserCard.vue'
import { ref } from 'vue'
const users = ref([
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
])
function onUserDeleted(id: number) {
users.value = users.value.filter(u => u.id !== id)
}
function onNameChanged(id: number, newName: string) {
const user = users.value.find(u => u.id === id)
if (user) user.name = newName
}
</script>
<template>
<UserCard
v-for="user in users"
:key="user.id"
:userId="user.id"
:displayName="user.name"
:isEditable="true"
@user-deleted="onUserDeleted"
@display-name-changed="onNameChanged"
/>
</template>
Props with defaults use withDefaults:
const props = withDefaults(defineProps<{
pageSize?: number
sortOrder?: 'asc' | 'desc'
}>(), {
pageSize: 20,
sortOrder: 'asc'
})
Side-by-Side: Vue 3 vs React
This table and example show the same component written in both frameworks. If you have already read the React article, this will orient you quickly.
// React — SearchBox.tsx
import { useState, useEffect, useMemo } from 'react'
interface Props {
placeholder?: string
onSearch: (query: string) => void
}
export function SearchBox({ placeholder = 'Search...', onSearch }: Props) {
const [query, setQuery] = useState('')
const [results, setResults] = useState<string[]>([])
const trimmedQuery = useMemo(() => query.trim(), [query])
useEffect(() => {
if (!trimmedQuery) { setResults([]); return }
fetchResults(trimmedQuery).then(setResults)
}, [trimmedQuery])
return (
<div>
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder={placeholder}
/>
<ul>
{results.map(r => <li key={r}>{r}</li>)}
</ul>
</div>
)
}
<!-- Vue 3 — SearchBox.vue -->
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
const props = withDefaults(defineProps<{
placeholder?: string
}>(), { placeholder: 'Search...' })
const emit = defineEmits<{ search: [query: string] }>()
const query = ref('')
const results = ref<string[]>([])
const trimmedQuery = computed(() => query.value.trim())
watch(trimmedQuery, async (newQuery) => {
if (!newQuery) { results.value = []; return }
results.value = await fetchResults(newQuery)
})
</script>
<template>
<div>
<input v-model="query" :placeholder="placeholder" />
<ul>
<li v-for="result in results" :key="result">{{ result }}</li>
</ul>
</div>
</template>
Key differences:
| Feature | React | Vue 3 |
|---|---|---|
| State | useState hook | ref() / reactive() |
| Derived state | useMemo | computed() |
| Side effects | useEffect with deps array | watch() / watchEffect() |
| Two-way binding | Controlled input (value + onChange) | v-model |
| Template | JSX (TypeScript in markup) | <template> (HTML-like) |
| Event syntax | onClick={handler} | @click="handler" |
| Prop binding | <Comp value={expr} /> | <Comp :value="expr" /> |
| Conditional | {condition && <El />} | v-if="condition" |
| Lists | .map() with JSX | v-for directive |
Vue’s template syntax feels closer to Razor. React’s JSX feels more like C# with embedded markup. Neither is strictly better — they reflect different priorities.
Key Differences
The <script setup> Compilation Model
<script setup> is not just syntax sugar. The Vue compiler transforms it at build time. Everything declared at the top level of <script setup> is automatically available in <template>. There is no explicit return {} or export default { setup() {} }. This is a compile-time feature, not runtime behavior.
Reactivity Is Proxy-Based
Vue 3 uses JavaScript Proxy objects under the hood. When you access reactive() object properties or .value on a ref(), Vue intercepts those reads and tracks which components depend on them. When you write, Vue knows exactly which components to re-render. You never call StateHasChanged() or invoke a command pattern — the tracking is automatic.
Two-Way Binding Is First-Class
React deliberately removed two-way binding because it creates implicit data flow that is hard to trace. Vue kept it because the ergonomics are good for form-heavy UIs. v-model on an input is equivalent to :value="x" @input="x = $event.target.value". On a component, v-model passes a modelValue prop and listens for an update:modelValue event.
Styles Are Scoped by Default (With :scoped)
Adding <style scoped> makes all CSS rules apply only to elements in that component’s template. Vue injects a unique data attribute (e.g., data-v-f3f3eg9) and rewrites CSS selectors to match. This is equivalent to CSS Modules but works without any configuration.
Gotchas for .NET Engineers
Gotcha 1: .value inside script, none in template
The single most common mistake when learning Vue. In <script setup>, every ref() variable requires .value to read or write:
const count = ref(0)
count.value++ // correct in <script>
count++ // wrong — you are incrementing the ref object, not the number
But in <template>, Vue automatically unwraps refs:
<!-- Correct in template -->
{{ count }}
<!-- Wrong — double unwrap, returns the number not a string -->
{{ count.value }}
If you see [object Object] in your template where you expect a number, you are probably printing count when count is a non-ref object, or you have an extra .value somewhere.
Gotcha 2: Destructuring a reactive() object breaks reactivity
This trips up C# engineers who think of var { firstName, lastName } = form as harmless:
const form = reactive({ firstName: 'Alice', lastName: 'Smith' })
// WRONG — these are plain strings now, not reactive
const { firstName, lastName } = form
// CORRECT — use toRefs() to preserve reactivity when destructuring
import { toRefs } from 'vue'
const { firstName, lastName } = toRefs(form)
// firstName.value and lastName.value are now reactive refs
The same problem does not occur with ref() objects because you read through .value, which Vue intercepts at the proxy level.
Gotcha 3: watch does not run immediately by default
In Blazor, OnParametersSet runs every time parameters change — including the first render. Vue’s watch does not run on initialization:
// This will NOT fetch data on component mount
watch(userId, async (id) => {
userData.value = await fetchUser(id)
})
// CORRECT — add { immediate: true } to replicate OnInitialized + OnParametersSet
watch(userId, async (id) => {
userData.value = await fetchUser(id)
}, { immediate: true })
// Alternatively, use onMounted for initial fetch and watch for changes
onMounted(() => fetchUser(userId.value))
watch(userId, (id) => fetchUser(id))
Gotcha 4: v-if vs v-show — DOM removal vs CSS hide
v-if="false" removes the element from the DOM entirely. Child components are destroyed; their lifecycle cleanup (onUnmounted) runs. v-show="false" sets display: none — the component stays alive.
<!-- v-if: like conditional rendering in Blazor — component is created/destroyed -->
<HeavyChart v-if="isChartVisible" />
<!-- v-show: like WPF Visibility.Hidden — always rendered, just hidden -->
<!-- Use for elements that toggle frequently and are expensive to mount -->
<TabPanel v-show="activeTab === 'chart'" />
If you toggle something frequently and it has expensive initialization (like a chart with a WebSocket connection), use v-show. Otherwise, use v-if.
Gotcha 5: TypeScript generics in .vue files need a workaround in some setups
When using defineProps<T>() with a generic type parameter imported from another file, you may hit compiler limitations. The type must be defined in the same file or be a simple inline type. This is a Vue/Volar tooling limitation, not a TypeScript limitation:
// This may fail in some tooling versions
import type { UserProps } from './types'
const props = defineProps<UserProps>() // can cause "type argument must be a literal type" error
// Workaround: define inline or re-declare in the same file
interface UserProps {
userId: number
displayName: string
}
const props = defineProps<UserProps>() // works
Check that @vue/language-tools (Volar) is up to date before fighting this.
Gotcha 6: reactive() loses reactivity when replaced wholesale
You cannot reassign a reactive() object. You can only mutate its properties:
const form = reactive({ name: '', email: '' })
// WRONG — breaks reactivity. `form` ref in template still points to old object.
form = { name: 'Alice', email: 'alice@example.com' }
// CORRECT — mutate properties individually
form.name = 'Alice'
form.email = 'alice@example.com'
// CORRECT — or use Object.assign for bulk update
Object.assign(form, { name: 'Alice', email: 'alice@example.com' })
Hands-On Exercise
Build a typed contact search component that demonstrates all the concepts in this article.
Requirements:
- A
ContactSearch.vuecomponent that accepts atitleprop (string, required) and amaxResultsprop (number, optional, default 10) - A search input with
v-modelbound to aref<string> - A
computed()property that filters a hardcoded list of contacts by name (case-insensitive, trimmed) - A
watchon the search query that logs to the console when the query exceeds 50 characters (a “query too long” warning) onMountedthat logs “ContactSearch mounted”onUnmountedthat logs “ContactSearch unmounted”- Display results with
v-forand:key - Show a “No results” message with
v-ifwhen the filtered list is empty - Emit a
contact-selectedevent (with the contact name as payload) when a result is clicked - Full TypeScript — no
any
Starter scaffold:
<!-- ContactSearch.vue -->
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
const CONTACTS = [
'Alice Martin', 'Bob Chen', 'Carol White', 'David Kim',
'Eve Johnson', 'Frank Lee', 'Grace Park', 'Henry Brown'
]
// TODO: defineProps with title (required string) and maxResults (optional number)
// TODO: defineEmits with 'contact-selected' event
// TODO: ref for search query
// TODO: computed for filtered contacts (respect maxResults)
// TODO: watch for query length > 50
// TODO: onMounted / onUnmounted lifecycle hooks
// TODO: selectContact function that emits the event
</script>
<template>
<!-- TODO: render title, input, results list, empty state -->
</template>
Expected output when complete:
A working search box that filters contacts as you type, shows “No results” when nothing matches, and fires a typed event when a result is clicked. Parent components can listen with @contact-selected="handleSelection".
Quick Reference
| Concept | Vue 3 Composition API | Blazor Equivalent | WPF Equivalent |
|---|---|---|---|
| Reactive primitive | const x = ref(0) | private int x = 0 (auto re-render) | INotifyPropertyChanged property |
| Read reactive value (script) | x.value | x | X (property getter) |
| Write reactive value | x.value = 1 | x = 1; StateHasChanged() | X = 1 (fires INPC) |
| Reactive object | reactive({ a: 1 }) | class with fields | Full ViewModel class |
| Computed/derived | computed(() => ...) | => expr (C# getter) | Computed property |
| Watch specific value | watch(x, (n, o) => ...) | OnParametersSet / PropertyChanged | DependencyProperty.Changed |
| Watch any dependency | watchEffect(() => ...) | — | MultiBinding |
| Mount hook | onMounted(() => ...) | OnAfterRenderAsync(true) | Loaded |
| Unmount/cleanup hook | onUnmounted(() => ...) | IDisposable.Dispose | Unloaded |
| Receive data | defineProps<T>() | [Parameter] | DependencyProperty |
| Emit event | defineEmits<E>() then emit(...) | EventCallback<T>.InvokeAsync | RoutedEvent / delegates |
| Two-way binding | v-model | @bind-Value | {Binding Mode=TwoWay} |
| Conditional render | v-if / v-else | @if / else | DataTrigger / Visibility |
| List render | v-for="x in list" :key="x.id" | @foreach | ItemsSource |
| Attribute binding | :attr="expr" | attr="@expr" | {Binding} |
| Event binding | @click="handler" | @onclick="handler" | Button.Click |
| Scoped CSS | <style scoped> | CSS Isolation (.razor.css) | Control template styles |
| Import component | import Comp from './Comp.vue' | @using namespace | xmlns declaration |
Further Reading
- Vue 3 Composition API FAQ — The official Vue team’s rationale for the Composition API over the Options API
- Vue 3 Reactivity in Depth — How the Proxy-based tracking system works under the hood
- Vue + TypeScript — Official guide for typed props, emits, refs, and template refs
- Volar Extension — The language server for Vue in VS Code. Required for full TypeScript support in
.vuefiles - Vue SFC Playground — In-browser editor for experimenting without a local install