Converting React Components to Vue 3 — MUI/Chakra to Quasar Migration
Last updated: April 2026 · 14 min read
Template Syntax: JSX vs Vue Single-File Components
The most fundamental difference between React and Vue is how you express your UI. React uses JSX, a JavaScript extension that lets you write HTML-like syntax directly in your component functions. Vue uses Single-File Components (SFCs) with a <template> block for markup, a <script setup> block for logic, and an optional <style> block for scoped CSS.
Event handling. React uses camelCase synthetic events like onClick, onChange, and onSubmit. Vue uses the @ shorthand for v-on: @click, @input, @submit. Vue also provides event modifiers like @click.prevent and @submit.stop that eliminate the need for e.preventDefault() calls.
Two-way binding. React requires you to wire up value and onChange separately for every form input. Vue’s v-model directive handles both in a single attribute. This is especially powerful with Quasar components, where v-model works consistently across inputs, selects, toggles, date pickers, and dialogs.
Conditional rendering. React uses ternary operators and logical && for conditional rendering inline. Vue uses the v-if, v-else-if, and v-else directives, which read more like natural language and are easier to follow in complex conditional trees. For list rendering, React uses .map() while Vue uses v-for.
Component Mapping: MUI to Quasar
Quasar is the most comprehensive Vue 3 component framework, offering 70+ components that cover the same ground as Material UI. Below are the most common component mappings. Use the FrontFamily Converter to automate these conversions.
Button → q-btn
<Button variant="contained" color="primary"
onClick={handleSave}>
Save Changes
</Button><q-btn color="primary" label="Save Changes" @click="handleSave" />
TextField → q-input
<TextField
label="Email"
variant="outlined"
value={email}
onChange={(e) => setEmail(e.target.value)}
fullWidth
/><q-input label="Email" outlined v-model="email" class="full-width" />
Card → q-card
<Card elevation={2}>
<CardContent>
<Typography variant="h6">Title</Typography>
<Typography variant="body2">Content</Typography>
</CardContent>
</Card><q-card>
<q-card-section>
<div class="text-h6">Title</div>
<div class="text-body2">Content</div>
</q-card-section>
</q-card>Dialog → q-dialog
<Dialog open={isOpen} onClose={handleClose}>
<DialogTitle>Confirm</DialogTitle>
<DialogContent>
Are you sure?
</DialogContent>
<DialogActions>
<Button onClick={handleClose}>Cancel</Button>
</DialogActions>
</Dialog><q-dialog v-model="isOpen">
<q-card>
<q-card-section class="text-h6">
Confirm
</q-card-section>
<q-card-section>
Are you sure?
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Cancel" v-close-popup />
</q-card-actions>
</q-card>
</q-dialog>Select → q-select
<Select
value={role}
onChange={(e) => setRole(e.target.value)}
label="Role">
<MenuItem value="admin">Admin</MenuItem>
<MenuItem value="user">User</MenuItem>
</Select><q-select
v-model="role"
label="Role"
:options="[
{ label: 'Admin', value: 'admin' },
{ label: 'User', value: 'user' }
]"
/>Switch → q-toggle
<Switch
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
/><q-toggle v-model="enabled" />
State Management: React Hooks to Composition API
React and Vue 3 have converged on a similar mental model for state and side effects, but the APIs differ significantly. Understanding these mappings is critical for a successful migration.
useState → ref / reactive
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(c => c + 1)}>
Count: {count}
</button>
);
}<script setup>
import { ref } from 'vue';
const count = ref(0);
</script>
<template>
<button @click="count++">
Count: {{ count }}
</button>
</template>useEffect → onMounted / watch
useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId]);
useEffect(() => {
document.title = user?.name ?? '';
return () => {
document.title = 'App';
};
}, [user]);import { watch, onMounted, onUnmounted } from 'vue';
onMounted(() => {
fetchUser(userId.value).then(u => user.value = u);
});
watch(userId, (newId) => {
fetchUser(newId).then(u => user.value = u);
});
watch(user, (u) => {
document.title = u?.name ?? '';
});useMemo → computed
const fullName = useMemo(
() => `${firstName} ${lastName}`,
[firstName, lastName]
);const fullName = computed(
() => `${firstName.value} ${lastName.value}`
);
// No dependency array needed — Vue tracks
// reactive dependencies automatically.Key Differences to Watch For
Beyond component and API mappings, there are architectural differences between React and Vue that affect how you structure your migrated application:
| Concept | React | Vue 3 |
|---|---|---|
| Reactivity | Immutable state, re-renders entire tree | Proxy-based, fine-grained updates |
| Slots / Children | children prop | <slot> with named slots |
| Context / Provide | createContext + useContext | provide + inject |
| Refs (DOM) | useRef | ref + template ref |
| CSS scoping | CSS Modules or CSS-in-JS | <style scoped> built-in |
| Form binding | value + onChange | v-model (two-way) |
| Router | React Router / Next.js | Vue Router / Nuxt |
Interactive Component Reference
Search any React MUI component to find its Vue Quasar equivalent. Components marked with ⚠ have no direct replacement and require custom implementation.
| React / MUI | Vue / Quasar | Props / Notes | |
|---|---|---|---|
Buttonvariant, color, disabled, onClick, startIcon | q-btncolor, disable, @click, icon, label | No variant prop; use flat, outline, unelevated props instead. Label replaces children text | |
TextFieldlabel, variant, value, onChange, fullWidth | q-inputlabel, outlined/filled, v-model, class="full-width" | v-model replaces value+onChange pair; variant becomes boolean props (outlined, filled) | |
Cardelevation, CardContent, CardHeader, CardActions | q-cardq-card-section, q-card-actions | Uses q-card-section for content areas; elevation via CSS class or flat prop | |
Typographyvariant, gutterBottom, color | div / spanclass="text-h6", class="text-body2" | No Typography component; use Quasar CSS typography classes on native elements | |
Dialogopen, onClose, maxWidth | q-dialogv-model, persistent, maximized | v-model controls open state; uses q-card inside for content structure | |
Chiplabel, color, variant, onDelete | q-badge / q-chiplabel, color, outline, removable | q-chip for interactive chips; q-badge for simple labels | |
Avatarsrc, alt, sx | q-avataricon, color, text-color, size | Use q-img inside q-avatar for image avatars; supports icon prop directly | |
Switchchecked, onChange, color | q-togglev-model, color, label | v-model replaces checked+onChange; has built-in label prop | |
Alertseverity, variant, onClose | q-bannerclass, inline-actions, dense | q-banner is simpler; use with custom classes for severity colors | |
Selectvalue, onChange, label, MenuItem children | q-selectv-model, label, :options, emit-value | Uses options array instead of children; v-model for two-way binding | |
CircularProgresssize, color, variant | q-spinnersize, color | Multiple spinner variants available: q-spinner-dots, q-spinner-bars, etc. | |
LinearProgressvalue, variant, color | q-linear-progressvalue, color, stripe, indeterminate | stripe prop instead of variant; indeterminate is a boolean prop | |
Tooltiptitle, placement, arrow | q-tooltipanchor, self, offset | Uses anchor/self position strings instead of placement; wraps target element | |
Dividerorientation, variant | q-separatorvertical, inset, spaced | orientation="vertical" becomes vertical prop; supports inset and spacing | |
Tabsvalue, onChange, variant | q-tabsv-model, align, active-color | Uses q-tab children and q-tab-panels + q-tab-panel for content | |
DataGridrows, columns, pageSize, sortModel | q-table:rows, :columns, :pagination, :filter | q-table has built-in search, sorting, pagination, and selection | |
Autocompleteoptions, renderInput, onChange | q-select (with filter)v-model, :options, use-input, @filter | q-select with use-input and @filter event provides autocomplete behavior | |
Draweropen, onClose, anchor | q-drawerv-model, side, bordered | side="left"/"right" replaces anchor; typically used with q-layout | |
MenuanchorEl, open, onClose | q-menuv-model, anchor, self | Attach to parent element; no anchorEl ref needed. Uses v-model for open state | |
Snackbaropen, autoHideDuration, message | $q.notify()message, timeout, type, position | Called via Quasar plugin: $q.notify({ message, type }); no component needed | |
StepperactiveStep, orientation | q-stepperv-model, vertical, animated | Uses q-step children with name, title, icon props | |
UploadN/A (no MUI built-in) | q-uploaderurl, auto-upload, multiple, accept | Built-in file uploader with drag-and-drop, progress, and file list | |
TreeViewitems, onNodeSelect | q-tree:nodes, node-key, selected | Quasar tree has built-in selection, expansion, and lazy loading | |
DatePickervalue, onChange (@mui/x) | q-datev-model, mask, :options | Built-in rich date picker; supports range selection and custom disabled dates |
What Developers Actually Hit
Based on migration reports from engineering teams. These are the problems documentation doesn’t warn you about.
React re-renders the entire component on state change. Vue uses a proxy-based reactivity system that only updates what changed. Code that relies on React’s render-cycle behavior (useEffect cleanup, ref callbacks, render-phase side effects) has no Vue equivalent — it needs architectural rethinking. A React component that resets local variables on every render silently breaks when ported to Vue because Vue’s setup() only runs once.
React passes children as props. Vue uses named slots with <slot name="header" />. Components with complex children composition (render props, function-as-children patterns) need complete restructuring for Vue’s slot system. A React pattern like <DataTable renderRow={(row) => ...} /> becomes a scoped slot in Vue, which is a different mental model entirely.
React uses onChange, onClick, onSubmit. Vue uses @change, @click, @submit. But the event payloads differ — React’s onChange on an input gives you a SyntheticEvent (access value via e.target.value), while Vue’s @input gives you the raw value directly. Teams doing automated find-and-replace miss this payload difference, causing TypeError: Cannot read property 'target' of undefined at runtime.
React Context is used for theme, auth, and feature flag providers. Vue has provide/inject but it’s less commonly used for global state. Quasar has its own plugin system with $q globals. Teams that relied heavily on nested React Context providers need to redesign their state architecture — either adopting Pinia for global state or restructuring to use Vue’s provide/inject with reactive refs.
Convert React to Vue instantly
Skip the manual rewrite. Paste your React MUI code into the FrontFamily Converter and get production-ready Vue 3 Quasar output with correct template syntax, v-model bindings, and Composition API setup. A pre-loaded Card example is ready for you to try.
Open Converter with React → Vue ExampleMigration Strategy for Large Codebases
Micro-frontend approach. Unlike migrating between two React component libraries, switching from React to Vue requires running two entirely different frameworks. The most practical approach is a micro-frontend architecture where new features are built in Vue while existing React code continues to run alongside. Tools like Module Federation, single-spa, or even simple iframe embedding (as FrontFamily itself uses) allow both frameworks to coexist in production.
Start with leaf pages. Identify pages or features with minimal shared state. Settings pages, documentation views, and standalone dashboards make excellent migration targets because they have limited inter-component communication. Once your team builds confidence with Vue patterns, tackle the more interconnected features.
Shared state layer. If both React and Vue components need access to the same state, extract your state management into a framework-agnostic layer. A vanilla TypeScript store with event emitters, or a library like Zustand (which works outside React), can serve as a bridge. Both React hooks and Vue composables can subscribe to the same underlying store, ensuring data consistency during the migration period.
Import Changes at a Glance
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';
import { Card, CardContent } from '@mui/material';
import { Dialog } from '@mui/material';
import { Select, MenuItem } from '@mui/material';<!-- Quasar components are globally registered -->
<!-- No imports needed in <template> -->
<script setup>
// Only import composables and utilities
import { useQuasar } from 'quasar';
import { ref, computed } from 'vue';
</script>