why i refactored 1200 lines of vanilla js to vue
03 Oct 2025i spent the last few hours refactoring my productivity app from vanilla javascript to vue 3. deleted 1200 lines of manual DOM manipulation and replaced it with 680 lines of reactive components. here’s why it was worth it and what i learned.
the app: get stuff done
quick context - this is an ai-powered goal-setting app that:
- generates SMART goals using ai
- breaks them into daily/monthly/yearly tasks
- syncs to the cloud with clerk auth + stripe billing
- handles subtasks, dark mode, pdf export, etc.
the original version was vanilla js + alpine.js (barely used) + a lot of innerHTML. it worked fine. but was kind of messy
the breaking point
here’s what finally pushed me to refactor:
// Before: updating a task checkbox
function toggleTaskComplete(category, index) {
const goalSet = goalSets[activeGoalSetId];
goalSet[category][index].completed = !goalSet[category][index].completed;
// Now update localStorage
localStorage.setItem('productivityGoalSets', JSON.stringify(goalSets));
// Don't forget to update the DOM!
renderTasks(goalSet[category], category);
// And the stats section
updateTaskStats();
// Oh and save to the cloud
saveToCloud();
}
four different places to update for one checkbox. miss any of them? bugs. guaranteed.
what vue fixes
1. reactivity eliminates manual dom updates
before:
function renderTasks(tasks, category) {
let html = '<div class="task-list">';
tasks.forEach((task, index) => {
html += `
<div class="task-item ${task.completed ? 'completed' : ''}">
<input type="checkbox"
onchange="toggleTaskComplete('${category}', ${index})"
${task.completed ? 'checked' : ''}>
<span>${escapeHtml(task.text)}</span>
</div>
`;
});
html += '</div>';
document.getElementById(`${category}-tasks`).innerHTML = html;
}
after:
<!-- TaskItem.vue -->
<template>
<div class="task-item" :class="{ completed: task.completed }">
<input
type="checkbox"
:checked="task.completed"
@change="$emit('toggle-complete')">
<span></span>
</div>
</template>
<script setup>
defineProps({
task: { type: Object, required: true }
});
defineEmits(['toggle-complete']);
</script>
no string concatenation. no manual xss protection. no inline event handlers. just declare what it should look like and vue handles the updates.
2. composables solve state management
the vanilla version had state everywhere:
- global variables (
goalSets,activeGoalSetId) - localStorage (primary source of truth)
- dom state (checkbox values, input text)
- cloud database (async sync)
vue’s composables pattern fixed this:
// composables/useGoals.js
const goalSets = ref({});
const activeGoalSetId = ref(null);
export function useGoals() {
const activeGoalSet = computed(() => {
return activeGoalSetId.value ? goalSets.value[activeGoalSetId.value] : null;
});
const taskStats = computed(() => {
const allTasks = [
...(activeGoalSet.value?.today || []),
...(activeGoalSet.value?.month || []),
...(activeGoalSet.value?.year || [])
];
return {
total: allTasks.length,
completed: allTasks.filter(t => t.completed).length,
important: allTasks.filter(t => t.important).length
};
});
const toggleTaskComplete = (category, index) => {
const task = activeGoalSet.value[category][index];
task.completed = !task.completed;
saveGoalSets(); // handles localStorage + cloud sync
};
return {
goalSets,
activeGoalSet,
taskStats,
toggleTaskComplete
};
}
single source of truth. computed properties auto-update the ui. no manual synchronization.
every component that needs goal state just calls useGoals() and gets the same reactive data:
<!-- AppInterface.vue -->
<script setup>
import { useGoals } from '@/composables/useGoals';
const { activeGoalSet, taskStats, toggleTaskComplete } = useGoals();
</script>
<template>
<div>Total: </div>
<div>Completed: </div>
</template>
change activeGoalSet anywhere in the app, and everything updates automatically.
3. components make code reusable
before, i had three copies of task rendering logic (one for each timeframe: today/month/year). different enough that extracting a function was awkward, similar enough that bugs appeared in all three.
after:
<!-- TaskList.vue - used for all three timeframes -->
<template>
<div class="task-list">
<TaskItem
v-for="(task, index) in tasks"
:key="index"
:task="task"
:category="category"
:index="index"
@toggle-complete="onToggleComplete(index)"
@toggle-important="onToggleImportant(index)"
@delete="onDelete(index)" />
</div>
</template>
<script setup>
import { useGoals } from '@/composables/useGoals';
const props = defineProps({
tasks: Array,
category: String
});
const { toggleTaskComplete, toggleTaskImportant, deleteTask } = useGoals();
function onToggleComplete(index) {
toggleTaskComplete(props.category, index);
}
// etc...
</script>
one component, three usages. fix a bug once, it’s fixed everywhere.
the migration process
day 1: infrastructure
started with the basics:
npm install vue@latest vue-router@latest @clerk/vue@latest
npm install --save-dev vite @vitejs/plugin-vue
created a minimal vite config:
// vite.config.js
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
server: { port: 5173 }
});
backed up the old files (index.html → index-old.html) and created a new minimal entry point:
<!-- index.html -->
<!DOCTYPE html>
<html>
<head>
<title>Get Stuff Done</title>
<link href="./css/output.css" rel="stylesheet">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
day 2: composables first
extracted state management before building ui. this was key - having the composables working meant i could test each piece independently.
created useAuth() first (authentication is the foundation):
// composables/useAuth.js
import { ref, computed, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useClerk, useUser } from '@clerk/vue';
const userUsage = ref({ goalGenerations: 0, isSubscribed: false });
export function useAuth() {
const router = useRouter();
const clerk = useClerk();
const { user, isSignedIn } = useUser();
const currentUser = computed(() => user.value);
const isAuthenticated = computed(() => isSignedIn.value);
watch(isSignedIn, async (signedIn) => {
if (signedIn && user.value) {
router.push('/app');
loadUserData();
} else {
router.push('/');
}
});
return {
currentUser,
isAuthenticated,
userUsage,
signIn,
signOut
};
}
then useGoals() for the core app logic. tested both in isolation before touching any ui code.
day 3: components
built components bottom-up (leaf components first):
TaskItem.vue- single task with checkboxTaskList.vue- container for tasksAuthSection.vue- sign in/out buttonsProfileModal.vue/PaywallModal.vue- modalsLandingPage.vue/AppInterface.vue- top-level views
each component was small and focused. made debugging easy.
the results
code metrics
- vanilla js: ~1,200 lines across 3 files
- vue: ~680 lines across 15 files
- 43% reduction in code
- 100% reduction in manual dom manipulation
before/after: adding a feature
before (vanilla js):
to add a “priority” field to tasks:
- update task object when creating (3 places)
- update rendering logic (3 timeframes × 2 views = 6 places)
- add ui controls (3 timeframes)
- add event handlers (global functions)
- update stats calculation
- update cloud sync schema
- update localStorage schema
estimated: 2-3 hours, high chance of missing something
after (vue):
- add
priorityto task object inuseGoals() - add ui in
TaskItem.vuecomponent - add handler that emits event
- update computed stats in
useGoals()
estimated: 30 minutes, low chance of bugs
performance
bundle size went up (added vue framework):
- before: 45 kb
- after: 87 kb (vue included), 32 kb gzipped
time to interactive actually got faster because vue’s virtual dom is more efficient than my string concatenation.
one weird trick: the shared state pattern
this was my favorite vue discovery. you can create shared state by defining refs outside the composable function:
// composables/useAuth.js
// State OUTSIDE the function = shared across all components
const currentUser = ref(null);
const isAuthenticated = ref(false);
export function useAuth() {
// Every component that calls useAuth() gets the same refs
return { currentUser, isAuthenticated };
}
now any component can get the auth state:
<!-- Header.vue -->
<script setup>
import { useAuth } from '@/composables/useAuth';
const { currentUser } = useAuth();
</script>
<template>
<div></div>
</template>
<!-- Dashboard.vue -->
<script setup>
import { useAuth } from '@/composables/useAuth';
const { currentUser } = useAuth(); // Same user ref as Header!
</script>
it’s like a global store but type-safe and composable. no need for vuex/pinia for simple apps.
conclusion
if you’re maintaining a vanilla js app and:
- you dread adding features
- you’re debugging state sync issues
- you’re copying code between components
…give vue a shot. the reactive primitives alone are worth it.
app: actuallydostuff.com
appendix: vue 3 primer
if you want to dive deeper into vue concepts, here’s a practical primer covering everything you need to know.
for react developers
if you’re coming from react, here’s the quick translation guide:
| React | Vue 3 | Key Difference |
|---|---|---|
useState(0) |
ref(0) |
Access with .value in script, auto-unwrap in template |
useMemo() |
computed() |
Same concept, different syntax |
useEffect() |
watch() |
More explicit dependencies |
| JSX | Templates | HTML-like syntax, no curly braces for text |
| Custom Hooks | Composables | Very similar pattern |
| Props + Callbacks | Props + Events | Events instead of callback props |
useContext() |
Shared Composable | Define refs outside function |
| React Router | Vue Router | Similar API, useRouter() / useRoute() |
quick example comparison:
react:
function Counter() {
const [count, setCount] = useState(0);
const doubled = useMemo(() => count * 2, [count]);
useEffect(() => {
console.log('Count changed:', count);
}, [count]);
return (
<div>
<div>{count}</div>
<div>Doubled: {doubled}</div>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
vue:
<script setup>
import { ref, computed, watch } from 'vue';
const count = ref(0);
const doubled = computed(() => count.value * 2);
watch(count, (newVal) => {
console.log('Count changed:', newVal);
});
</script>
<template>
<div>
<div>8</div>
<div>Doubled: </div>
<button @click="count++">Increment</button>
</div>
</template>
key differences:
- vue uses
ref()instead ofuseState(), access with.valuein script - templates use `` instead of jsx’s
{ }, and no.valueneeded - vue’s
@clickvs react’sonClick - can mutate state directly in vue (
count++), no setter needed
reactivity system
vue 3’s reactivity is powered by javascript proxies.
ref() - reactive primitives:
import { ref } from 'vue';
const count = ref(0); // number
const message = ref('Hello'); // string
const isActive = ref(true); // boolean
// access/modify with .value
console.log(count.value); // 0
count.value++; // 1
// in templates, .value is automatic:
// <div>8</div> ← no .value needed!
computed() - derived state:
import { ref, computed } from 'vue';
const tasks = ref([
{ text: 'Buy milk', completed: true },
{ text: 'Walk dog', completed: false }
]);
// recalculates when tasks changes
const completedCount = computed(() => {
return tasks.value.filter(t => t.completed).length;
});
watch() - side effects:
import { ref, watch } from 'vue';
const username = ref('');
watch(username, (newValue, oldValue) => {
console.log(`Changed from ${oldValue} to ${newValue}`);
// save to localStorage, call api, etc.
});
template syntax
text interpolation:
<div></div>
<div>8</div>
attribute binding:
<img :src="imageUrl" :alt="imageAlt">
<div :class="{ active: isActive }">
event handling:
<button @click="handleClick">Click</button>
<button @click="count++">Increment</button>
<form @submit.prevent="onSubmit"> <!-- preventDefault() -->
conditional rendering:
<div v-if="isLoggedIn">Welcome back!</div>
<div v-else>Please log in</div>
list rendering:
<ul>
<li v-for="task in tasks" :key="task.id">
</li>
</ul>
two-way binding:
<input v-model="message">
<!-- for text inputs, equivalent to: -->
<input :value="message" @input="message = $event.target.value">
composables pattern
composables are reusable functions that encapsulate reactive state and logic.
// composables/useCounter.js
import { ref, computed } from 'vue';
export function useCounter(initialValue = 0) {
const count = ref(initialValue);
const doubleCount = computed(() => count.value * 2);
function increment() {
count.value++;
}
return { count, doubleCount, increment };
}
using it:
<script setup>
import { useCounter } from './composables/useCounter';
const { count, doubleCount, increment } = useCounter(10);
</script>
<template>
<div>Count: 8</div>
<div>Double: </div>
<button @click="increment">+</button>
</template>
shared state pattern:
define refs outside the function to share across all components:
// composables/useAuth.js
import { ref } from 'vue';
// state outside = shared across all components
const currentUser = ref(null);
const isAuthenticated = ref(false);
export function useAuth() {
function signIn(credentials) {
currentUser.value = userData;
isAuthenticated.value = true;
}
return { currentUser, isAuthenticated, signIn };
}
now every component that calls useAuth() gets the same currentUser and isAuthenticated.
props & events
parent passes data down:
<TaskItem :task="myTask" :index="0" />
child receives props:
<!-- TaskItem.vue -->
<script setup>
const props = defineProps({
task: { type: Object, required: true },
index: Number
});
</script>
child emits events to parent:
<script setup>
const emit = defineEmits(['toggle-complete', 'delete']);
function handleCheckbox() {
emit('toggle-complete');
}
</script>
<template>
<input @change="handleCheckbox">
</template>
parent listens:
<TaskItem @toggle-complete="onToggleComplete" />
lifecycle hooks
import { onMounted, onUnmounted } from 'vue';
onMounted(() => {
console.log('Component mounted');
// fetch data, add event listeners
});
onUnmounted(() => {
console.log('Cleaning up');
// remove event listeners, cancel timers
});
common gotchas
1. don’t mutate props:
<!-- ❌ don't do this -->
<script setup>
const props = defineProps({ task: Object });
props.task.completed = true; // mutating prop!
</script>
<!-- ✅ do this instead -->
<script setup>
const emit = defineEmits(['update']);
emit('update', { ...props.task, completed: true });
</script>
2. v-for needs :key:
<!-- ❌ missing key -->
<div v-for="task in tasks"></div>
<!-- ✅ with key -->
<div v-for="task in tasks" :key="task.id"></div>
3. remember .value in script:
const count = ref(0);
// ❌ won't work
console.log(count); // RefImpl object
count++; // NaN
// ✅ correct
console.log(count.value);
count.value++;
templates don’t need .value, but scripts do.
quick reference
reactivity:
import { ref, reactive, computed, watch } from 'vue';
const count = ref(0);
const state = reactive({ name: 'Alice' });
const doubled = computed(() => count.value * 2);
watch(count, (newVal, oldVal) => {
console.log(`Changed from ${oldVal} to ${newVal}`);
});
component communication:
<!-- parent -->
<Child :msg="message" @update="handleUpdate" />
<!-- child -->
<script setup>
defineProps({ msg: String });
const emit = defineEmits(['update']);
emit('update', newValue);
</script>
composable pattern:
// outside = shared
const state = ref({});
export function useFeature() {
function method() { /* ... */ }
return { state, method };
}
lifecycle:
import { onMounted, onUnmounted } from 'vue';
onMounted(() => console.log('Component mounted'));
onUnmounted(() => console.log('Cleanup'));
resources:
- vue 3 docs
- vue router docs
- vueuse - collection of useful composables