Skip to main content
Migrating from Vue to Svelte offers benefits like smaller bundle sizes, simpler reactivity, and less framework overhead. This guide will help you understand the differences and successfully migrate your Vue applications to Svelte.

Why Migrate to Svelte?

  • True Compilation: Svelte compiles to vanilla JavaScript, no runtime framework
  • Simpler Reactivity: No need for .value or reactivity APIs
  • Smaller Bundles: Significantly smaller production builds
  • Less Magic: More explicit, easier to understand
  • Better Performance: Faster runtime with surgical DOM updates

Key Concept Mapping

Vue ConceptSvelte EquivalentNotes
ref()$stateSimilar reactive state
reactive()$stateUnified state primitive
computed()$derivedAutomatic reactivity
watch()$effectSide effects
watchEffect()$effectAuto-tracking effects
props$props()Destructured props
emit()Callback propsNo event emitter needed
slotsSnippetsMore powerful composition
v-modelbind:valueTwo-way binding
v-if{#if}Conditional rendering
v-for{#each}List rendering
v-onEvent attributesSimpler syntax
ComposablesRunesMore powerful primitives

Component Basics

Component Definition

<template>
  <div>
    <h1>Hello, {{ name }}!</h1>
  </div>
</template>

<script setup>
const props = defineProps({
  name: String
});
</script>

Reactive State

<template>
  <button @click="count++">
    Count: {{ count }}
  </button>
</template>

<script setup>
import { ref } from 'vue';

const count = ref(0);
</script>

Computed Properties

<template>
  <div>Total: ${{ total }}</div>
</template>

<script setup>
import { ref, computed } from 'vue';

const props = defineProps({
  items: Array
});

const total = computed(() => {
  return props.items.reduce((sum, item) => sum + item.price, 0);
});
</script>

Reactivity Patterns

Watchers and Effects

<script setup>
import { ref, watch, watchEffect } from 'vue';

const count = ref(0);

// Watch specific value
watch(count, (newValue, oldValue) => {
  console.log(`Count changed from ${oldValue} to ${newValue}`);
});

// Watch effect (auto-tracking)
watchEffect(() => {
  console.log(`Count is ${count.value}`);
});
</script>

Reactive Objects

<template>
  <div>
    <p>{{ user.name }} - {{ user.email }}</p>
    <button @click="updateUser">Update</button>
  </div>
</template>

<script setup>
import { reactive } from 'vue';

const user = reactive({
  name: 'Alice',
  email: 'alice@example.com'
});

function updateUser() {
  user.name = 'Bob';
}
</script>

Arrays and Lists

<template>
  <div>
    <button @click="addItem">Add Item</button>
    <ul>
      <li v-for="item in items" :key="item.id">
        {{ item.text }}
      </li>
    </ul>
  </div>
</template>

<script setup>
import { ref } from 'vue';

const items = ref([]);

function addItem() {
  items.value.push({ id: Date.now(), text: 'New item' });
}
</script>

Props and Events

Props Definition

<template>
  <div>
    <h2>{{ title }}</h2>
    <p>Age: {{ age }}</p>
  </div>
</template>

<script setup>
const props = defineProps({
  title: {
    type: String,
    required: true
  },
  age: {
    type: Number,
    default: 0
  }
});
</script>

Event Emission

<!-- Child.vue -->
<template>
  <button @click="handleClick">
    Click me
  </button>
</template>

<script setup>
const emit = defineEmits(['click']);

function handleClick() {
  emit('click', { timestamp: Date.now() });
}
</script>

<!-- Parent.vue -->
<template>
  <Child @click="onChildClick" />
</template>

<script setup>
function onChildClick(data) {
  console.log('Child clicked:', data);
}
</script>

Two-Way Binding (v-model)

<!-- Child.vue -->
<template>
  <input :value="modelValue" @input="$emit('update:modelValue', $event.target.value)" />
</template>

<script setup>
defineProps(['modelValue']);
defineEmits(['update:modelValue']);
</script>

<!-- Parent.vue -->
<template>
  <Child v-model="text" />
  <p>{{ text }}</p>
</template>

<script setup>
import { ref } from 'vue';
const text = ref('');
</script>

Template Directives

Conditional Rendering

<template>
  <div v-if="isLoggedIn">
    Welcome back!
  </div>
  <div v-else-if="isLoading">
    Loading...
  </div>
  <div v-else>
    Please log in
  </div>
</template>

List Rendering

<template>
  <ul>
    <li v-for="(item, index) in items" :key="item.id">
      {{ index }}: {{ item.name }}
    </li>
  </ul>
</template>

Class and Style Binding

<template>
  <!-- Class binding -->
  <div :class="{ active: isActive, 'text-danger': hasError }">
    Content
  </div>
  
  <!-- Style binding -->
  <div :style="{ color: textColor, fontSize: fontSize + 'px' }">
    Styled
  </div>
</template>

Event Handling

<template>
  <button @click="count++">{{ count }}</button>
  <button @click="handleClick($event)">Click</button>
  <form @submit.prevent="handleSubmit">
    <input v-model="text" @keyup.enter="search" />
  </form>
</template>

Lifecycle Hooks

<script setup>
import { onMounted, onUnmounted, onBeforeUpdate, onUpdated } from 'vue';

onMounted(() => {
  console.log('Component mounted');
});

onUnmounted(() => {
  console.log('Component unmounted');
});

onBeforeUpdate(() => {
  console.log('Before update');
});

onUpdated(() => {
  console.log('After update');
});
</script>

Slots and Content Projection

Default Slot

<!-- Card.vue -->
<template>
  <div class="card">
    <slot></slot>
  </div>
</template>

<!-- Usage -->
<template>
  <Card>
    <p>Card content</p>
  </Card>
</template>

Named Slots

<!-- Modal.vue -->
<template>
  <div class="modal">
    <div class="header">
      <slot name="header"></slot>
    </div>
    <div class="body">
      <slot></slot>
    </div>
    <div class="footer">
      <slot name="footer"></slot>
    </div>
  </div>
</template>

<!-- Usage -->
<template>
  <Modal>
    <template #header>
      <h2>Title</h2>
    </template>
    
    <p>Content</p>
    
    <template #footer>
      <button>Close</button>
    </template>
  </Modal>
</template>

Scoped Slots

<!-- List.vue -->
<template>
  <ul>
    <li v-for="item in items" :key="item.id">
      <slot :item="item" :index="items.indexOf(item)"></slot>
    </li>
  </ul>
</template>

<!-- Usage -->
<template>
  <List :items="users">
    <template #default="{ item, index }">
      <strong>{{ index }}: {{ item.name }}</strong>
    </template>
  </List>
</template>

Forms and Input Binding

Form Handling

<template>
  <form @submit.prevent="handleSubmit">
    <input v-model="formData.name" />
    <input v-model="formData.email" type="email" />
    <textarea v-model="formData.message"></textarea>
    <input v-model="formData.agree" type="checkbox" />
    <button type="submit">Submit</button>
  </form>
</template>

<script setup>
import { reactive } from 'vue';

const formData = reactive({
  name: '',
  email: '',
  message: '',
  agree: false
});

function handleSubmit() {
  console.log(formData);
}
</script>

Provide/Inject vs Context

<!-- Parent.vue -->
<script setup>
import { provide, ref } from 'vue';

const theme = ref('dark');
provide('theme', theme);
</script>

<!-- Child.vue -->
<script setup>
import { inject } from 'vue';

const theme = inject('theme');
</script>

<template>
  <div>Theme: {{ theme }}</div>
</template>

Composables vs Runes

// useCounter.js
import { ref, computed } from 'vue';

export function useCounter(initialValue = 0) {
  const count = ref(initialValue);
  const doubled = computed(() => count.value * 2);
  
  function increment() {
    count.value++;
  }
  
  function decrement() {
    count.value--;
  }
  
  return {
    count,
    doubled,
    increment,
    decrement
  };
}

// Usage
<script setup>
const { count, doubled, increment } = useCounter(10);
</script>

Styling

Scoped Styles

<template>
  <div class="container">
    <button class="btn">Click me</button>
  </div>
</template>

<style scoped>
.container {
  padding: 20px;
}

.btn {
  background: blue;
  color: white;
}
</style>

Dynamic Styles

<template>
  <div :style="{ color: textColor, fontSize: size + 'px' }">
    Styled text
  </div>
</template>

<script setup>
import { ref } from 'vue';

const textColor = ref('red');
const size = ref(16);
</script>

Transitions and Animations

<template>
  <Transition name="fade">
    <div v-if="show">Content</div>
  </Transition>
</template>

<style>
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.5s;
}

.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}
</style>

Migration Checklist

  • Understand Svelte’s compilation model
  • Convert Vue components to Svelte components
  • Replace ref() and reactive() with $state
  • Replace computed() with $derived
  • Replace watch() and watchEffect() with $effect
  • Convert defineProps() to $props()
  • Replace defineEmits() with callback props
  • Update template syntax (v-if{#if}, etc.)
  • Convert v-model to bind:value
  • Replace slots with snippets
  • Update event handlers (@clickonclick)
  • Convert provide/inject to context API
  • Update composables to use runes
  • Migrate scoped styles (already default in Svelte)
  • Update transitions and animations
  • Test all functionality thoroughly

Key Differences to Remember

  1. No .value: In Svelte, state is accessed directly without .value
  2. Scoped by Default: Styles are scoped without a scoped attribute
  3. No Setup Function: No need for <script setup> - it’s the default
  4. True Compilation: Svelte compiles to vanilla JavaScript
  5. Simpler Syntax: Less boilerplate, more intuitive
  6. Built-in Animations: Transitions and animations are built-in

Next Steps