Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 hooksOnInitialized, 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 with StateHasChanged() wired up
  • In the <template>, you access count directly (Vue unwraps the ref). In <script>, you use count.value
  • @click is Vue’s event binding — the same as Blazor’s @onclick
  • {{ count }} is interpolation — the same as @count in 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 / BlazorVue 3
INotifyPropertyChanged propertyref()
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:

BlazorWPFVue 3
OnInitializedAsyncConstructorsetup (the <script setup> block itself)
OnAfterRenderAsync(true)LoadedonMounted
OnParametersSetOnPropertyChangedwatch on props
OnAfterRenderAsync(false)LayoutUpdatedonUpdated
IDisposable.DisposeUnloadedonUnmounted

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:

FeatureReactVue 3
StateuseState hookref() / reactive()
Derived stateuseMemocomputed()
Side effectsuseEffect with deps arraywatch() / watchEffect()
Two-way bindingControlled input (value + onChange)v-model
TemplateJSX (TypeScript in markup)<template> (HTML-like)
Event syntaxonClick={handler}@click="handler"
Prop binding<Comp value={expr} /><Comp :value="expr" />
Conditional{condition && <El />}v-if="condition"
Lists.map() with JSXv-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.vue component that accepts a title prop (string, required) and a maxResults prop (number, optional, default 10)
  • A search input with v-model bound to a ref<string>
  • A computed() property that filters a hardcoded list of contacts by name (case-insensitive, trimmed)
  • A watch on the search query that logs to the console when the query exceeds 50 characters (a “query too long” warning)
  • onMounted that logs “ContactSearch mounted”
  • onUnmounted that logs “ContactSearch unmounted”
  • Display results with v-for and :key
  • Show a “No results” message with v-if when the filtered list is empty
  • Emit a contact-selected event (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

ConceptVue 3 Composition APIBlazor EquivalentWPF Equivalent
Reactive primitiveconst x = ref(0)private int x = 0 (auto re-render)INotifyPropertyChanged property
Read reactive value (script)x.valuexX (property getter)
Write reactive valuex.value = 1x = 1; StateHasChanged()X = 1 (fires INPC)
Reactive objectreactive({ a: 1 })class with fieldsFull ViewModel class
Computed/derivedcomputed(() => ...)=> expr (C# getter)Computed property
Watch specific valuewatch(x, (n, o) => ...)OnParametersSet / PropertyChangedDependencyProperty.Changed
Watch any dependencywatchEffect(() => ...)MultiBinding
Mount hookonMounted(() => ...)OnAfterRenderAsync(true)Loaded
Unmount/cleanup hookonUnmounted(() => ...)IDisposable.DisposeUnloaded
Receive datadefineProps<T>()[Parameter]DependencyProperty
Emit eventdefineEmits<E>() then emit(...)EventCallback<T>.InvokeAsyncRoutedEvent / delegates
Two-way bindingv-model@bind-Value{Binding Mode=TwoWay}
Conditional renderv-if / v-else@if / elseDataTrigger / Visibility
List renderv-for="x in list" :key="x.id"@foreachItemsSource
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 componentimport Comp from './Comp.vue'@using namespacexmlns declaration

Further Reading