Porting the Same Portfolio Landing to Vue 3 — 41% Smaller Gzip Than React

typescript dev.to

Porting the Same Portfolio Landing to Vue 3 — 41% Smaller Gzip Than React

Same spec, same data, same CSS as my React landing page, but ported to Vue 3 + Composition API. The ground rule for the series: the component layer is the only thing allowed to change. Shared code stays byte-identical. Result: gzip drops from 49.00 kB to 28.76 kB.

This is entry #2 in my framework comparison series. Rules:

  1. types.ts, filter.ts, data.ts, style.css, and tests/filter.test.ts must be byte-identical across all ports
  2. Only the component layer changes
  3. All features must exist in every port — no "I simplified it"

Vue 3 delivered a gzip of 28.76 kB against React's 49.00 kB. A 41% reduction from swapping nothing but the component framework. Here's what the port actually looks like, and why the number isn't surprising once you trace where the bytes go.

🔗 Live demo: https://sen.ltd/portfolio/portfolio-app-vue/
📦 GitHub: https://github.com/sen-ltd/portfolio-app-vue

Feature parity with the React version: filter, search, sort, URL query sync, JA/EN, dark UI, responsive. Vue 3 + TypeScript + Vite + Composition API.

<script setup> is remarkably flat

Composition API with <script setup> gives you a genuinely thin component syntax:

<script setup lang="ts">
import { ref, computed, watchEffect } from 'vue'
import type { PortfolioData, Entry, Lang } from './types'
import { loadPortfolioData } from './data'
import { filterAndSort, type FilterState, type SortKey } from './filter'
import { MESSAGES, detectDefaultLang } from './i18n'

const status = ref<'loading' | 'error' | 'ready'>('loading')
const errorMsg = ref('')
const data = ref<PortfolioData | null>(null)
const lang = ref<Lang>(detectDefaultLang())
const filter = ref<FilterState>({ query: '', category: 'all', stack: 'all', stage: 'all', sort: 'number' })

loadPortfolioData()
  .then((d) => { data.value = d; status.value = 'ready' })
  .catch((e) => { errorMsg.value = String(e); status.value = 'error' })

const visible = computed(() =>
  data.value ? filterAndSort(data.value.entries, filter.value, lang.value) : []
)
</script>
Enter fullscreen mode Exit fullscreen mode

In React, the equivalent is five useState calls, a useEffect for loading, and a useMemo for the filtered list — plus the dependency-array bookkeeping that comes with them. In Vue, computed tracks its dependencies automatically. There's no dep array to forget, no stale closure to debug.

v-model + watchEffect for the URL sync

Two-way binding happens declaratively:

<script setup lang="ts">
watchEffect(() => {
  const q = new URLSearchParams()
  if (filter.value.query) q.set('q', filter.value.query)
  if (filter.value.category !== 'all') q.set('category', filter.value.category)
  q.set('lang', lang.value)
  window.history.replaceState(null, '', `${window.location.pathname}?${q.toString()}`)
})
</script>

<template>
  <input type="text" v-model="filter.query" :placeholder="m.searchPlaceholder" />
  <select v-model="filter.category">
    <option value="all">{{ m.allLabel }}</option>
    <option v-for="c in data.categories" :key="c.id" :value="c.id">
      {{ c.name[lang] }}
    </option>
  </select>
</template>
Enter fullscreen mode Exit fullscreen mode

v-model eliminates the value + onChange double-declaration every React form field needs. watchEffect auto-detects its dependencies, so you physically cannot forget to include one. Two classes of bug — missing dep arrays and handler/value mismatches — go away.

Why the bytes differ

Vite's build output tells the story:

dist/assets/index-<hash>.js   104.94 kB │ gzip: 28.76 kB
Enter fullscreen mode Exit fullscreen mode

Versus React's 49.00 kB. Where did 20+ kB go?

  • react-dom is heavy. The virtual DOM reconciler, fiber scheduler, and event system all live there. It's roughly 40 kB before your app code starts.
  • Vue's reactivity is proxy-based and compile-time-optimized. When filter.query changes, Vue knows — at compile time — exactly which DOM updates it affects. React's default mental model is "re-run the component function; diff the result."
  • Vue's templates compile to render functions. The compiled output is denser than JSX-to-React-element because it can inline hoisted static parts and skip diffing branches that provably can't change.

For an app that does what this one does — small tree, frequent filter changes, read-only data — Vue's model aligns better with what's actually needed, and the bundle reflects that.

Component split uses .vue files

<!-- EntryCard.vue -->
<script setup lang="ts">
import type { Entry, Lang } from './types'
import { MESSAGES } from './i18n'

const props = defineProps<{
  entry: Entry
  lang: Lang
  stackMap: Map<string, { id: string; name: string; color: string }>
  stageMap: Map<string, { id: string; icon: string; name: Record<Lang, string> }>
  categoryMap: Map<string, { id: string; name: Record<Lang, string> }>
}>()
</script>

<template>
  <article class="card">
    <div class="card-head">
      <span class="entry-number">#{{ String(entry.number).padStart(3, '0') }}</span>
      <span v-if="stage" class="stage-badge">{{ stage.icon }} {{ stage.name[lang] }}</span>
      <span v-if="entry.source === 'closed'" class="source-badge">🔒 Closed source</span>
    </div>
    <!-- ... -->
  </article>
</template>
Enter fullscreen mode Exit fullscreen mode

defineProps<T>() is fully type-inferred, so TypeScript experience is comparable to React's functional components. Single File Components keep template, script, and optional scoped styles colocated in one .vue file without any ambient imports.

Byte-identical shared code

The shared files verifiably don't change:

$ diff repos/portfolio-app-react/src/filter.ts repos/portfolio-app-vue/src/filter.ts
# no output — byte-identical
Enter fullscreen mode Exit fullscreen mode

Same for types.ts, data.ts, style.css, and tests/filter.test.ts. This is the constraint that makes the bundle comparison meaningful — what changes between ports is strictly the framework's rendering layer, so the bundle delta is a clean measurement of framework overhead for this workload.

Tests

14 Vitest cases, run identically on every port in the series:

test('filters by category', () => {
  const r = filterAndSort(entries, { ...defaults, category: 'dev-tool' }, 'en')
  assert.ok(r.every((e) => e.category === 'dev-tool'))
})
Enter fullscreen mode Exit fullscreen mode

No Vue in the test setup. Logic lives in pure functions; swapping framework doesn't change what passes or fails.

Series

This is entry #22 in my 100+ public portfolio series, and entry #2 in the framework comparison series.

Next up: Svelte 5 + Runes at 18.92 kB (another −23%).

Source: dev.to

arrow_back Back to Tutorials