How to Migrate a jQuery Codebase to Vue 3

Type: Software Reference Confidence: 0.90 Sources: 8 Verified: 2026-02-22 Freshness: quarterly

TL;DR

Constraints

Quick Reference

jQuery PatternVue 3 EquivalentExample
$('#el').html(val)ref() + template interpolationconst text = ref('')<p>{{ text }}</p>
$('#el').on('click', fn)v-on / @click directive<button @click="handleClick">
$('#el').toggle()v-if / v-show directive<div v-show="isVisible">
$('#el').addClass('active'):class binding<div :class="{ active: isActive }">
$('#el').css({color: 'red'}):style binding<div :style="{ color: textColor }">
$('#el').val()v-model two-way binding<input v-model="searchQuery" />
$.ajax({url, success})fetch + onMounted / composableonMounted(async () => { data.value = await fetch(url).then(r => r.json()) })
$('#el').animate()<Transition> / <TransitionGroup><Transition name="fade"><div v-if="show">...</div></Transition>
$(document).ready(fn)onMounted(() => {})Runs after component is inserted into DOM
$.each(arr, fn)v-for directive<li v-for="item in items" :key="item.id">{{ item.name }}</li>
$('#el').find('.child')Template refs + useTemplateRef()const el = useTemplateRef('container') (Vue 3.5+) [src6]
$('#form').serialize()v-model + reactive objectconst form = reactive({ name: '', email: '' })
$('#el').show() / .hide()v-show (CSS toggle)<div v-show="isVisible"> (keeps DOM, toggles display)
$.Deferred()Native Promise / async/awaitconst data = await fetch(url).then(r => r.json())
$('#plugin').pluginName()Custom directive or composableapp.directive('tooltip', { mounted(el) { ... } })

Decision Tree

START
├── Is the jQuery codebase < 500 lines with simple event handling?
│   ├── YES → Skip Vue, replace jQuery calls with vanilla JS (see jquery-to-vanilla-js)
│   └── NO ↓
├── Is this a full rewrite or incremental migration?
│   ├── FULL REWRITE → Scaffold new Vue 3 project (npm create vue@latest), rebuild from scratch
│   └── INCREMENTAL ↓
├── Does the page have isolated UI widgets (modals, tabs, forms)?
│   ├── YES → Start with "Vue Islands": mount Vue apps into existing page via createApp().mount()
│   └── NO ↓
├── Does the jQuery code heavily mutate shared global state (window.appState, data-* attrs)?
│   ├── YES → First extract state into a Pinia store or provide/inject, then migrate UI
│   └── NO ↓
├── Are there jQuery plugins with no Vue equivalent?
│   ├── YES → Wrap them in a custom directive using mounted/unmounted hooks (see Step 6)
│   └── NO ↓
├── Is the app server-rendered (PHP, Rails, Django templates)?
│   ├── YES → Use Vue's in-DOM template mode: createApp with existing HTML as template
│   └── NO ↓
└── DEFAULT → Migrate page-by-page: replace jQuery selectors with Vue components, use v-model for forms, replace $.ajax with fetch/composables

Step-by-Step Guide

1. Audit the jQuery surface area

Grep your codebase to quantify the migration scope. Count AJAX calls, event bindings, DOM manipulations, and plugin usages. This converts a vague rewrite into discrete, estimable tasks. [src3]

# Count jQuery usage patterns across the codebase
grep -rn '\$\.' --include='*.js' --include='*.html' | wc -l
grep -rn '\$\.ajax\|\.get(\|\.post(' --include='*.js' | wc -l
grep -rn '\.on(\|\.click(\|\.submit(' --include='*.js' | wc -l
grep -rn '\.html(\|\.text(\|\.val(\|\.append(' --include='*.js' | wc -l

Verify: Review the counts — a typical medium app has 50–200 jQuery call sites. Prioritize pages by traffic and complexity.

2. Add Vue 3 alongside jQuery

Install Vue 3 into your existing project without removing jQuery. Both libraries can coexist because Vue only manages DOM inside its mount container. [src1]

# Option A: npm install (for bundler-based projects)
npm install vue@3

# Option B: CDN (for no-build-step projects)
# <script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>

Verify: import { createApp } from 'vue' compiles without errors, or window.Vue is available in the browser console.

3. Create mount points and mount Vue apps

Add container elements where Vue will take over. jQuery continues managing everything else. Vue 3 supports multiple independent app instances on one page. [src1, src4]

import { createApp, ref } from 'vue';

const SearchWidget = {
  setup() {
    const query = ref('');
    const results = ref([]);

    async function search() {
      const res = await fetch(`/api/search?q=${encodeURIComponent(query.value)}`);
      results.value = await res.json();
    }

    return { query, results, search };
  },
  template: `
    <div>
      <input v-model="query" @keyup.enter="search" placeholder="Search..." />
      <button @click="search">Search</button>
      <ul>
        <li v-for="item in results" :key="item.id">{{ item.title }}</li>
      </ul>
    </div>
  `
};

const container = document.getElementById('vue-search-widget');
if (container) {
  createApp(SearchWidget).mount(container);
}

Verify: The Vue component renders within the existing page layout without breaking jQuery functionality.

4. Replace jQuery DOM manipulation with Vue reactive state

Convert imperative DOM updates to declarative Vue state using ref() and reactive(). Stop reading from the DOM and let Vue's reactivity system drive UI updates. Vue 3.5 improved reactivity memory usage by 56%. [src2, src3, src6]

// BEFORE: jQuery
let count = 0;
$('#counter').text(count);
$('#increment').on('click', function() {
  count++;
  $('#counter').text(count);
});

// AFTER: Vue 3 Composition API
import { createApp, ref } from 'vue';

const Counter = {
  setup() {
    const count = ref(0);
    const increment = () => count.value++;
    return { count, increment };
  },
  template: `
    <div>
      <span>{{ count }}</span>
      <button @click="increment">Increment</button>
    </div>
  `
};

Verify: Remove the jQuery code for that widget, confirm the Vue version renders and behaves identically. Check Vue DevTools — state should be reactive.

5. Replace $.ajax with fetch or a Vue composable

Convert AJAX calls to the modern fetch API wrapped in a reusable composable. Composables are Vue's equivalent of React hooks. Use onWatcherCleanup() (Vue 3.5+) for automatic request cancellation inside watchers. [src3, src6]

// composables/useFetch.js
import { ref, onMounted, onUnmounted } from 'vue';

export function useFetch(url) {
  const data = ref(null);
  const error = ref(null);
  const loading = ref(true);
  let controller = null;

  async function fetchData() {
    controller = new AbortController();
    loading.value = true;
    error.value = null;
    try {
      const res = await fetch(url, { signal: controller.signal });
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      data.value = await res.json();
    } catch (err) {
      if (err.name !== 'AbortError') error.value = err.message;
    } finally {
      loading.value = false;
    }
  }

  onMounted(fetchData);
  onUnmounted(() => controller?.abort());

  return { data, error, loading, refetch: fetchData };
}

Verify: Network tab shows the same API requests. Unmounting the component should abort in-flight requests.

6. Wrap jQuery plugins using custom directives

For plugins without Vue alternatives, wrap them in a Vue custom directive using the mounted and unmounted lifecycle hooks. [src5, src8]

// directives/v-chosen.js
import $ from 'jquery';
import 'chosen-js';

export const vChosen = {
  mounted(el, binding) {
    const $el = $(el);
    $el.chosen({ width: '100%', ...binding.value });
    $el.on('change', () => {
      el.dispatchEvent(new Event('change'));
    });
  },
  updated(el) {
    $(el).trigger('chosen:updated');
  },
  unmounted(el) {
    $(el).chosen('destroy');
  }
};

// Register: app.directive('chosen', vChosen);
// Use: <select v-model="val" v-chosen="{ search_contains: true }">

Verify: Plugin initializes on mount, updates when Vue state changes, and destroys cleanly on unmount — no console errors or memory leaks.

7. Remove jQuery and clean up

Once all components are migrated, uninstall jQuery and remove all script tags. If migrating from jQuery 3.x, consider whether jQuery 4.0 compatibility changes affect your remaining code. [src3, src4, src7]

npm uninstall jquery
# Remove from HTML: <script src="jquery.min.js"></script>
grep -rn '\$(\|jQuery(' --include='*.js' --include='*.vue' --include='*.html'

Verify: grep -rn 'jquery' --include='*.json' --include='*.js' --include='*.vue' returns zero results. App runs without jQuery loaded. Bundle size should decrease by ~19.5–30 KB (gzipped).

Code Examples

JavaScript/Vue 3: Full page migration from jQuery to Vue SFC

Full script: javascript-vue-3-full-page-migration-from-jquery-t.js (46 lines)

// Input:  A jQuery page with search form, results list, and loading spinner
// Output: Equivalent Vue 3 Single File Component with Composition API

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

const query = ref('');
const results = ref([]);
const loading = ref(false);
const error = ref(null);

const hasResults = computed(() => results.value.length > 0);

async function handleSearch() {
  if (!query.value.trim()) return;
  loading.value = true;
  error.value = null;
  try {
    const res = await fetch(`/api/search?q=${encodeURIComponent(query.value)}`);
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    const data = await res.json();
    results.value = data.items;
  } catch (err) {
    error.value = err.message;
  } finally {
    loading.value = false;
  }
}
// </script>

JavaScript/Vue 3: Bridge store for jQuery-Vue coexistence

Full script: javascript-vue-3-bridge-store-for-jquery-vue-coexi.js (31 lines)

// Input:  A legacy jQuery page where Vue needs to read/write shared state
// Output: A reactive bridge that lets jQuery and Vue components share data

// bridge-store.js
import { reactive, watch } from 'vue';

export const bridgeStore = reactive({
  user: null,
  theme: 'light',
  notifications: []
});

// jQuery side: window.bridgeStore = bridgeStore;
// Vue side: already reactive — import and use directly

watch(
  () => bridgeStore.theme,
  (newTheme) => {
    document.body.setAttribute('data-theme', newTheme);
    $(document).trigger('theme:changed', [newTheme]);
  }
);

TypeScript/Vue 3: Type-safe composable replacing jQuery AJAX patterns

Full script: typescript-vue-3-type-safe-composable-replacing-jq.ts (51 lines)

// Input:  jQuery $.ajax calls with consistent error handling
// Output: Type-safe Vue 3 composable with automatic loading/error state

// composables/useApi.ts
import { ref, type Ref } from 'vue';

interface UseApiReturn<T> {
  data: Ref<T | null>;
  error: Ref<string | null>;
  loading: Ref<boolean>;
  execute: (...args: unknown[]) => Promise<void>;
}

export function useApi<T>(
  url: string | ((...args: unknown[]) => string),
  options: RequestInit = {}
): UseApiReturn<T> {
  const data = ref<T | null>(null) as Ref<T | null>;
  const error = ref<string | null>(null);
  const loading = ref(false);
  // ... (see full script for complete implementation)
}

Anti-Patterns

Wrong: Direct DOM manipulation inside Vue components

// ❌ BAD — jQuery DOM manipulation inside a Vue component
const Counter = {
  mounted() {
    $('#count').text(0);
    $('#increment').on('click', () => {
      const current = parseInt($('#count').text());
      $('#count').text(current + 1);
    });
  },
  template: `
    <div>
      <span id="count">0</span>
      <button id="increment">+1</button>
    </div>
  `
};

Correct: Use Vue reactive state for all UI updates

// ✅ GOOD — Vue reactivity drives the UI
const Counter = {
  setup() {
    const count = ref(0);
    return { count };
  },
  template: `
    <div>
      <span>{{ count }}</span>
      <button @click="count++">+1</button>
    </div>
  `
};

Wrong: jQuery event delegation inside Vue

// ❌ BAD — jQuery event delegation alongside Vue
const ListComponent = {
  mounted() {
    $(this.$el).on('click', '.list-item', function() {
      $(this).toggleClass('selected');
    });
  },
  template: `<ul><li class="list-item" v-for="item in items" :key="item.id">{{ item.name }}</li></ul>`
};

Correct: Handle events with Vue directives and reactive state

// ✅ GOOD — Vue handles events and state
const ListItem = {
  props: ['item'],
  setup() {
    const selected = ref(false);
    const toggle = () => { selected.value = !selected.value; };
    return { selected, toggle };
  },
  template: `
    <li :class="{ 'list-item': true, selected }" @click="toggle">
      {{ item.name }}
    </li>
  `
};

Wrong: $.ajax inside Vue without lifecycle cleanup

// ❌ BAD — No cancellation, state update after unmount
const UserProfile = {
  props: ['userId'],
  setup(props) {
    const user = ref(null);
    watch(() => props.userId, (id) => {
      $.ajax({
        url: `/api/users/${id}`,
        success: (data) => { user.value = data; }
      });
    }, { immediate: true });
    return { user };
  },
  template: `<div>{{ user?.name }}</div>`
};

Correct: fetch with AbortController in a composable

// ✅ GOOD — Proper cleanup prevents memory leaks and race conditions
import { ref, watch, onUnmounted } from 'vue';

const UserProfile = {
  props: ['userId'],
  setup(props) {
    const user = ref(null);
    let controller = null;

    watch(() => props.userId, async (id) => {
      controller?.abort();
      controller = new AbortController();
      try {
        const res = await fetch(`/api/users/${id}`, { signal: controller.signal });
        user.value = await res.json();
      } catch (err) {
        if (err.name !== 'AbortError') console.error(err);
      }
    }, { immediate: true });

    onUnmounted(() => controller?.abort());
    return { user };
  },
  template: `<div>{{ user?.name }}</div>`
};

Wrong: Replacing entire jQuery pages at once with Vue

// ❌ BAD — Wrapping an entire jQuery page in one Vue app
const app = createApp({
  mounted() {
    initDatePickers();
    initModals();
    initDataTables();
    initCharts();
  },
  template: `<div id="entire-page" v-html="legacyHtml"></div>`
});

Correct: Mount multiple small Vue apps into the existing page

// ✅ GOOD — Replace one widget at a time with its own Vue app
const searchApp = createApp(SearchWidget);
searchApp.mount('#search-mount');

const chartApp = createApp(ChartWidget);
chartApp.mount('#chart-mount');

// Rest of the page is still managed by jQuery
// Remove jQuery only after ALL widgets are migrated

Wrong: Using $refs like jQuery selectors

// ❌ BAD — Treating refs as jQuery-style DOM query shortcuts
const FormComponent = {
  mounted() {
    this.$refs.nameInput.style.border = '2px solid red';
    this.$refs.nameInput.value = 'default';
    this.$refs.submitBtn.disabled = true;
  },
  template: `
    <form>
      <input ref="nameInput" />
      <button ref="submitBtn">Submit</button>
    </form>
  `
};

Correct: Use reactive state and directive bindings

// ✅ GOOD — Let Vue's reactivity system manage everything
import { ref, computed } from 'vue';

const FormComponent = {
  setup() {
    const name = ref('default');
    const hasError = ref(true);
    const canSubmit = computed(() => name.value.length > 0 && !hasError.value);
    return { name, hasError, canSubmit };
  },
  template: `
    <form>
      <input v-model="name" :class="{ error: hasError }" />
      <button :disabled="!canSubmit">Submit</button>
    </form>
  `
};

Common Pitfalls

Diagnostic Commands

# Count remaining jQuery references in the codebase
grep -rn '\$(\|jQuery\|\.ajax\|\.on(' --include='*.js' --include='*.vue' --include='*.html' | wc -l

# Find jQuery script tags in HTML files
grep -rn 'jquery\|jQuery' --include='*.html' | grep -i 'script'

# Check if jQuery 4.0 removed APIs are in use
grep -rn 'jQuery\.isArray\|jQuery\.parseJSON\|jQuery\.trim\|jQuery\.type\|jQuery\.now' --include='*.js' | wc -l

# Check bundle size for jQuery (Vite)
npx vite build 2>&1 | grep -i jquery

# Verify Vue is rendering (browser console)
# document.querySelectorAll('[data-v-app]').length

# List all mounted Vue app instances (browser console)
# document.querySelectorAll('.__vue_app__')

# Check for memory leaks (Chrome DevTools)
# Performance tab -> Record -> Interact -> Check heap snapshots for detached DOM nodes

# Verify Vue 3.5 features are available
# In browser console: Vue.version

Version History & Compatibility

VersionStatusBreaking ChangesMigration Notes
Vue 3.5 (Sep 2024)CurrentReactive props destructure stable, useTemplateRef(), onWatcherCleanup()Recommended for new migrations. 56% less memory usage. [src6]
Vue 3.4 (Dec 2023)MaintaineddefineModel stableSimplifies v-model on custom components
Vue 3.3 (May 2023)MaintaineddefineOptions macro, generic componentsMinimum recommended version for new migrations
Vue 3.0–3.2 (2020–2022)MaintenanceComposition API, <script setup><script setup> stabilized in 3.2 — use it
Vue 2.7 (Jul 2022)EOL (Dec 2023)Backported Composition APIDo not target Vue 2 for new migrations
jQuery 4.0 (Jan 2026)CurrentRemoved $.isArray, $.parseJSON, $.trim, $.type, $.now; slim removes Deferreds; ES modulesCan coexist with Vue 3. Check upgrade guide. [src7]
jQuery 3.x (2016–2024)MaintainedRemoved deprecated APIsCan coexist with Vue 3 during migration
jQuery 1.x–2.xEOLIE-specific codeUpgrade to 3.x/4.x first, or remove directly

When to Use / When Not to Use

Use WhenDon't Use WhenUse Instead
Building component-driven SPAs with reactive dataStatic sites with 2–3 event handlersVanilla JS or Alpine.js
Team values single-file components and template syntaxTeam strongly prefers JSXReact (see jquery-to-react)
Codebase uses server-rendered HTML (PHP, Django, Rails)Project is already React/AngularStay with current framework
Need incremental migration without build step (CDN)jQuery codebase is <500 lines totalReplace jQuery calls with vanilla JS
App has complex forms requiring two-way bindingProject is a throwaway prototypeKeep jQuery
Team is small and needs gentle learning curveNeed SSR with streaming and React Server ComponentsReact/Next.js

Important Caveats

Related Units