Why This Blog Exists
I've spent my frontend career in the React world — React, Next.js, TypeScript, TanStack Query, Zustand, the whole ecosystem. Recently I started migrating my projects to Vue and Nuxt, and I'm learning it the way most React developers do: constantly asking "okay, but what's the Vue equivalent of X?"
If you know React, Next.js, and TypeScript, you already know about 80% of Vue and Nuxt — you just know it by different names. This post is the dictionary.
Part 1 — The Two Stacks, Head to Head
First, the ecosystem map. Almost every tool you use in the React world has a first-class counterpart in the Vue world — and notably, most of the Vue counterparts are official, maintained by the core team, rather than community picks:
| Concern | React World | Vue World |
|---|---|---|
| UI library | React | Vue 3 |
| Meta-framework | Next.js | Nuxt 3/4 |
| Routing (SPA) | React Router / TanStack Router | Vue Router (official) |
| Global state | Redux / Zustand / Jotai | Pinia (official) |
| Server data fetching | TanStack Query / SWR / RSC | useFetch & useAsyncData (built into Nuxt) |
| Component syntax | JSX / TSX | Single File Components (.vue) |
| Local state | useState / useReducer | ref / reactive |
| Derived state | useMemo | computed |
| Side effects | useEffect | watch / watchEffect / lifecycle hooks |
| Reusable logic | Custom hooks | Composables |
| Dev tooling | Vite / Turbopack | Vite (default) |
The deeper difference isn't the tools — it's the philosophy. Two things separate these stacks at the core:
1. Re-render vs. fine-grained reactivity. React's model: state changed, so re-run the whole component function and diff the output. Your function body executes on every render — which is why you need useMemo, useCallback, and React.memo to opt out of work. Vue's model: your component's setup code runs once. Vue wraps your state in proxies, tracks exactly which piece of the DOM depends on which piece of state, and surgically updates only that. Optimization is the default, not something you add.
2. Library vs. framework, à la carte vs. batteries-included. React ships a rendering library and lets the community fight over everything else. Vue ships the router, the state manager, and the docs to glue them. Nuxt goes further than Next — auto-imports, built-in data fetching with SSR-aware caching, a server engine (Nitro) that deploys anywhere, and an official module ecosystem for images, fonts, SEO, auth, and content.
| React + Next.js | Vue + Nuxt | |
|---|---|---|
| Strengths | Biggest ecosystem and job market; RSC pushes rendering boundaries; enormous community, every problem already answered; JSX is just JavaScript — full language power in markup | Gentler learning curve; less boilerplate for the same result; performance by default (no memoization ceremony); official, cohesive tooling; SFCs keep template/logic/styles together; Nuxt auto-imports remove import noise |
| Weaknesses | You assemble (and maintain) the stack yourself; hooks rules are easy to violate; re-render debugging is a genre of its own; App Router / RSC split the ecosystem into two mental models | Smaller ecosystem and job market; fewer niche libraries; template DSL (v-if, v-for) must be learned; .value on refs trips everyone at first; less common in large enterprises (though growing) |
| Choose it when | Team already knows it; you need a niche library that's React-only; hiring pool matters more than DX | You want to ship fast with less code; solo projects and startups; you value convention over configuration |
Part 2 — Learning Vue & Nuxt as a React Developer
Everything below teaches Vue/Nuxt by mapping it onto what you already know. Each topic shows the React version first, then the Vue version, then the gotchas. Work through it in order — the concepts build on each other.
2.1 — Components: From TSX to Single File Components
In React, a component is a function that returns JSX. In Vue, a component is a .vue file with three blocks: <script setup> (your logic), <template> (your markup), and optional <style>. Same component, both worlds:
Things to notice, because they generalize:
| React habit | Vue equivalent | Note |
|---|---|---|
| {expression} | {{ expression }} | Double curlies in templates |
| onClick={fn} | @click="fn" | @ is shorthand for v-on: |
| setCount(count + 1) | count++ | Mutation is fine — state is a proxy, Vue sees the write |
| CSS Modules / styled-components | <style scoped> | Scoping is built into the SFC |
| export default function | <script setup> | No export, no function wrapper — the file IS the component |
count++, user.name = "new", list.push(item) — all perfectly idiomatic.2.2 — Reactivity: ref, reactive, and the .value Gotcha
This is the heart of Vue, so let's be precise. Vue gives you two primitives:
import { ref, reactive } from "vue";
// ref: for ANY value (primitives included).
// Access/write via .value in script code.
const count = ref(0);
count.value++; // write
console.log(count.value); // read
// reactive: for objects only. No .value — but you
// lose reactivity if you destructure or reassign it.
const state = reactive({ name: "Daksh", score: 9 });
state.score = 10; // just mutateWhy does ref need .value? JavaScript proxies can only intercept property access on objects — you can't proxy a bare number. So Vue boxes your value in an object with a .value property it can watch. In templates, refs are auto-unwrapped — you write {{ count }}, never {{ count.value }}.
.value in script code (or adding it in templates). My rule of thumb from the React side: a ref is like a useRef whose .current is reactive — same shape, but writes trigger updates. Start with ref for everything; reach for reactiveonly for grouped state you'll never reassign.Now the derived-state and side-effect mappings:
Read that again, because it's the single biggest quality-of-life difference: there are no dependency arrays in Vue.No exhaustive-deps lint rule, no stale closures, no "why is my effect firing twice." Vue records which reactive values a computed/watchEffect actually reads, and subscribes to exactly those. The entire class of bugs where you forgot a dependency — gone.
2.3 — Props, Events, and v-model
Props flow down in both frameworks. The difference is the upward direction: React passes callbacks down; Vue components emit events up. Fully typed, both directions:
<SearchBox
placeholder="Search blogs..."
@search="(q) => runSearch(q)"
/>Three template mechanics worth pausing on:
:placeholder — the colon binds an attribute to a JS expression (shorthand for v-bind:). Without the colon it's a literal string, like HTML.
v-model — two-way binding. In React, every input is a controlled-component ceremony: value={query} onChange={(e) => setQuery(e.target.value)}. In Vue, v-model="query" does both. It also works on custom components — defineModel() gives you a two-way-bound prop in one line.
@keyup.enter — event modifiers. .enter, .prevent (preventDefault), .stop (stopPropagation), .once. The e.preventDefault(); if (e.key === "Enter") boilerplate is just... gone.
2.4 — Template Syntax: v-if, v-for, and Losing JSX
This is the part React developers resist most, so let me map it directly. JSX uses JavaScript for control flow; Vue templates use directives:
| JSX pattern | Vue directive | Difference |
|---|---|---|
| cond && <X /> | v-if | v-if actually unmounts; v-show just toggles CSS display |
| ternary chains | v-if / v-else-if / v-else | Flat, reads top to bottom |
| array.map() | v-for="item in items" | :key is still required, same rules as React |
| Fragment <>...</> | not needed | Vue 3 templates allow multiple root nodes |
| className | class | It's real HTML — class, for, tabindex as-is |
| :class object | :class="{ active: isActive }" | Built-in conditional classes — no clsx/classnames dependency |
Two honest notes. First: yes, you're learning a DSL, and for the first week you'll miss "it's just JavaScript." Second: the DSL is whyVue can optimize so well — because templates are statically analyzable, the compiler pre-computes which parts of the DOM can ever change and skips the rest entirely. JSX's full flexibility is precisely what React's compiler struggles against. And if you truly need dynamic render logic, Vue supports JSX and render functions too — it's just rarely needed.
2.5 — Slots: children and Render Props, Solved Once
React's children prop is Vue's default slot. React's "multiple children sections" pattern (passing JSX via props) is named slots. Render props are scoped slots:
Same power as render props, but declarative and with less nesting. This pattern is everywhere in Vue UI libraries, so it's worth internalizing early.
2.6 — Lifecycle and Composables (Custom Hooks, Unshackled)
Lifecycle first — the mapping is nearly one-to-one:
| React | Vue | Runs |
|---|---|---|
| useEffect(fn, []) | onMounted(fn) | After the component is in the DOM |
| useEffect cleanup + [] | onUnmounted(fn) | When the component is removed |
| useEffect(fn, [dep]) | watch(dep, fn) | When specific state changes |
| useLayoutEffect | watchEffect w/ flush: 'post' | After DOM updates |
| (no equivalent) | onServerPrefetch | SSR-only data hook |
Custom hooks become composables — same idea (a function using reactive primitives, returning reactive state), one huge difference: the Rules of Hooks don't exist. Because setup runs once and reactivity lives in the values (not in call order), you can call composables conditionally, in loops, wherever:
2.7 — Global State: From Redux/Zustand to Pinia
Pinia is Vue's official store, and if you've used Zustand it will feel like coming home — minus the selector functions, because Vue's reactivity makes components auto-subscribe to exactly the fields they read:
Notice the store is literally a composable — the same ref/computed primitives you just learned, registered globally. No actions/reducers split, no immer, no useSelector memoization. Pinia also has devtools with time travel and full TypeScript inference out of the box.
2.8 — Nuxt for Next.js Developers
Now the meta-framework layer. Nuxt is to Vue what Next is to React, and the mapping is direct:
| Feature | Next.js (App Router) | Nuxt 3/4 |
|---|---|---|
| Pages | app/blog/[slug]/page.tsx | pages/blog/[slug].vue |
| Layouts | app/layout.tsx (nested) | layouts/default.vue + <NuxtLayout> |
| API routes | app/api/x/route.ts | server/api/x.ts (Nitro) |
| Middleware | middleware.ts (edge) | middleware/*.ts (route) + server middleware |
| Data fetching | RSC async components / fetch | useFetch / useAsyncData |
| Client-server boundary | "use client" / "use server" | No directive — .client/.server file suffixes when needed |
| Rendering modes | SSR / SSG / ISR / PPR | SSR / SSG / ISR / SPA — per-route via routeRules |
| Config | next.config.js | nuxt.config.ts |
| Env vars | process.env.NEXT_PUBLIC_* | runtimeConfig (server + public split) |
| Image optimization | next/image | @nuxt/image module |
The two ideas that genuinely differ and deserve explanation:
1. Data fetching: useFetch. Next App Router splits your world into server components (fetch directly) and client components (TanStack Query et al). Nuxt has one primitive that does both jobs:
It's SSR-aware, deduplicated, cached by key, typed end-to-end (the return type of your server/api handler flows into data automatically), with refresh(), lazy variants, and reactive keys — refetch when the route param changes, no dependency array.
2. The server side: Nitro. Nuxt's server engine is a standalone thing. Any file in server/api/ becomes an endpoint:
export default defineEventHandler(async (event) => {
const slug = getRouterParam(event, "slug");
const post = await db.post.findUnique({ where: { slug } });
if (!post) throw createError({ statusCode: 404 });
return post; // type flows into useFetch on the client
});And auto-imports, the most controversial Nuxt feature: components in components/, composables in composables/, and all of Vue's primitives (ref, computed, watch) are available without import statements. Feels wrong for a day, then you stop missing the 15-line import blocks at the top of every Next page. It's fully typed — TypeScript knows exactly what's in scope.
2.9 — The Migration Gotchas List
Everything that actually bit me, so it doesn't bite you:
| Gotcha | What happens | Fix |
|---|---|---|
| Forgetting .value in script | Comparisons/math silently wrong (ref object, not value) | ESLint vue rules catch most; templates never need .value |
| Destructuring reactive() | const { name } = state — name is a dead copy | Use toRefs(state), or just use ref everywhere |
| Destructuring props | const { label } = props loses reactivity | Access props.label, or use toRefs(props) |
| Expecting re-renders | console.log in setup runs ONCE, not per update | Setup is not a render function — put logs in watchEffect |
| key on v-for with index | Same pitfalls as React — state bleeds across items | Use stable ids, same discipline as React |
| v-if with v-for on one element | Precedence is confusing | Nest them, or filter in a computed first |
| SSR hydration mismatch | Same class of bug as Next | Wrap browser-only markup in <ClientOnly> |
Key Takeaway
If you know React and Next, you are two honest weekends away from productive Vue and Nuxt. The component model, one-way data flow, file-based routing, SSR mental model — it all transfers. What changes is the cost structure: Vue's fine-grained reactivity means the performance work React makes you do by hand (memoization, dependency arrays, selector tuning) simply doesn't exist, and Nuxt's conventions mean the stack decisions Next leaves to you (data fetching, state, imports) come pre-made and official.
Learn ref and .value, embrace mutation, let go of dependency arrays, and read your compiler errors — the rest is renaming things you already know.
