The same todo list in React, Vue and Angular
One feature list, one stylesheet, one component split. Everything below is lifted from the
three working apps in compareFrameworks/, so each excerpt is real code you can open,
run and edit. Read it concept by concept to see where the frameworks agree and where they differ.
What every version does. Add a todo, toggle it done, double-click to edit inline
(Enter saves, Escape cancels), delete it, filter by All / Active / Completed, clear completed, show a
remaining count, and persist to localStorage. Each app has a root component, one item
component, and a shared types.ts. The stylesheet is byte-for-byte identical.
The numbers
Measured on the finished apps. Line counts cover the application source only, not config.
| React | Vue | Angular | |
|---|---|---|---|
| Framework version | 19.3.0 | 3.5.42 | 20.3.31 |
| Build tool | Vite 8 | Vite 8 | Angular CLI (esbuild) |
| Source files | 4 | 4 | 7 |
| Lines of app source | 213 | 197 | 217 |
| Runtime dependencies | 2 | 1 | 9 |
| Dev dependencies | 7 | 6 | 11 |
| node_modules on disk | 90 MB | 80 MB | 269 MB |
| Production JS, gzipped | 69.6 kB | 26.4 kB | 70.9 kB |
| Production build time | 0.2 s | 0.4 s | 4.9 s |
| Dev server | npm run dev · :5173 | npm run dev · :5174 | npm start · :4200 |
Angular's gzipped figure includes its 11 kB polyfills chunk. Angular 20 was used because the current CLI refuses Node 24.13.
What a component is
The shell of the item component, with everything but the declaration removed. React exports a function that returns JSX. Vue is a single-file component with a script block and a template block. Angular is a class with a decorator and a separate template file.
import { useEffect, useRef, useState } from 'react'
import type { Todo } from './types'
interface Props {
todo: Todo
onToggle: (id: number) => void
onDelete: (id: number) => void
onRename: (id: number, title: string) => void
}
export default function TodoItem({ todo, onToggle, onDelete, onRename }: Props) {
// ...state and handlers...
return (
<li className={`todo-item${todo.done ? ' done' : ''}`}>
...
</li>
)
}
<script setup lang="ts">
import { nextTick, ref } from 'vue'
import type { Todo } from '../types'
const props = defineProps<{ todo: Todo }>()
const emit = defineEmits<{
toggle: [id: number]
delete: [id: number]
rename: [id: number, title: string]
}>()
// ...state and handlers...
</script>
<template>
<li class="todo-item" :class="{ done: todo.done }">
...
</li>
</template>
<script setup> runs once per instance; anything declared at its top level is available in the template automatically.import { Component, input, output, signal } from '@angular/core';
import { Todo } from './types';
@Component({
selector: 'app-todo-item',
templateUrl: './todo-item.html',
host: { class: 'todo-item', '[class.done]': 'todo().done' },
})
export class TodoItem {
readonly todo = input.required<Todo>();
readonly toggle = output<number>();
readonly delete = output<number>();
readonly rename = output<{ id: number; title: string }>();
// ...state and handlers...
}
todo-item.html can only see the class's members.Holding and changing state
The todo list itself, plus the handlers that add and toggle. This is the sharpest philosophical split: React and Angular treat state as immutable and replace it, Vue lets you mutate the object in place.
const [todos, setTodos] = useState<Todo[]>(loadTodos)
const [newTitle, setNewTitle] = useState('')
function addTodo(e: React.FormEvent) {
e.preventDefault()
const title = newTitle.trim()
if (!title) return
setTodos((prev) => [...prev, { id: Date.now(), title, done: false }])
setNewTitle('')
}
function toggle(id: number) {
setTodos((prev) =>
prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t)),
)
}
todos; you hand setTodos a brand-new array. The functional form (prev => ...) avoids stale closures.const todos = ref<Todo[]>(loadTodos())
const newTitle = ref('')
function addTodo() {
const title = newTitle.value.trim()
if (!title) return
todos.value.push({ id: Date.now(), title, done: false })
newTitle.value = ''
}
function toggle(id: number) {
const todo = todos.value.find((t) => t.id === id)
if (todo) todo.done = !todo.done
}
ref wraps the array in a deep proxy, so push and todo.done = ... are tracked. The .value suffix is needed in script, not in the template.protected readonly todos = signal<Todo[]>(loadTodos());
protected readonly newTitle = signal('');
addTodo() {
const title = this.newTitle().trim();
if (!title) return;
this.todos.update((list) => [...list, { id: Date.now(), title, done: false }]);
this.newTitle.set('');
}
toggle(id: number) {
this.todos.update((list) =>
list.map((t) => (t.id === id ? { ...t, done: !t.done } : t)),
);
}
todos(), write with set or update. Shape is the same as React's, but it lives on a class and change detection is fine-grained.Derived values
The remaining count and the filtered list. Vue and Angular both have a memoised computed.
React just recalculates during render, which is fine at this size and is what most React code does.
const remaining = todos.filter((t) => !t.done).length
const completed = todos.length - remaining
const visible = todos.filter((t) =>
filter === 'active' ? !t.done
: filter === 'completed' ? t.done
: true,
)
useMemo only when profiling says so.const remaining = computed(() => todos.value.filter((t) => !t.done).length)
const completed = computed(() => todos.value.length - remaining.value)
const visible = computed(() =>
todos.value.filter((t) =>
filter.value === 'active' ? !t.done
: filter.value === 'completed' ? t.done
: true,
),
)
computed only re-runs when something it read changes, and it tracks those dependencies itself.protected readonly remaining = computed(() => this.todos().filter((t) => !t.done).length);
protected readonly completed = computed(() => this.todos().length - this.remaining());
protected readonly visible = computed(() => {
const f = this.filter();
return this.todos().filter((t) =>
f === 'active' ? !t.done : f === 'completed' ? t.done : true,
);
});
computed is nearly identical in behaviour. The visible difference is this. and the call parentheses.Side effects: saving to localStorage
Whenever the list changes, write it out. Three different primitives, and three different answers to "how does it know what to watch".
useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(todos))
}, [todos])
watch(
todos,
(value) => localStorage.setItem(STORAGE_KEY, JSON.stringify(value)),
{ deep: true },
)
deep: true is needed because toggling mutates an object inside the array rather than replacing the array.constructor() {
effect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.todos()));
});
}
Binding a text input
The "What needs to be done?" box. This is where Vue's sugar shows. Angular has an equivalent
([(ngModel)]) but it needs FormsModule; the app stays signals-only to keep the comparison honest.
<form className="todo-form" onSubmit={addTodo}>
<input
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
placeholder="What needs to be done?"
autoFocus
/>
<button type="submit" className="btn-primary" disabled={!newTitle.trim()}>
Add
</button>
</form>
className, autoFocus and onChange instead of HTML names.<form class="todo-form" @submit.prevent="addTodo">
<input
v-model="newTitle"
placeholder="What needs to be done?"
autofocus
/>
<button type="submit" class="btn-primary" :disabled="!newTitle.trim()">
Add
</button>
</form>
v-model does both directions. The .prevent modifier replaces a manual preventDefault() call.<form class="todo-form" (submit)="$event.preventDefault(); addTodo()">
<input
#newInput
[value]="newTitle()"
(input)="newTitle.set(newInput.value)"
placeholder="What needs to be done?"
autofocus
/>
<button type="submit" class="btn-primary" [disabled]="!newTitle().trim()">
Add
</button>
</form>
[value] is a property binding, (input) an event. #newInput is a template reference used to read the element.Conditionals and lists
Show an empty message or the list, and render one item per todo. Every framework needs a stable key so it can reuse DOM nodes; note where each one asks for it.
{visible.length === 0 ? (
<p className="empty">
{todos.length === 0 ? 'Nothing to do yet.' : `No ${filter} todos.`}
</p>
) : (
<ul className="todo-list">
{visible.map((todo) => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={toggle}
onDelete={remove}
onRename={rename}
/>
))}
</ul>
)}
Array.map for the loop. No template syntax to learn, but nesting gets noisy fast.<p v-if="visible.length === 0" class="empty">
{{ todos.length === 0 ? 'Nothing to do yet.' : `No ${filter} todos.` }}
</p>
<ul v-else class="todo-list">
<TodoItem
v-for="todo in visible"
:key="todo.id"
:todo="todo"
@toggle="toggle"
@delete="remove"
@rename="rename"
/>
</ul>
v-if / v-else and v-for sit on the tag itself. Text uses mustaches, attributes use a leading :.@if (visible().length === 0) {
<p class="empty">
{{ todos().length === 0 ? 'Nothing to do yet.' : 'No ' + filter() + ' todos.' }}
</p>
} @else {
<ul class="todo-list">
@for (todo of visible(); track todo.id) {
<app-todo-item
[todo]="todo"
(toggle)="toggle($event)"
(delete)="remove($event)"
(rename)="rename($event)"
/>
}
</ul>
}
@if / @for blocks that wrap markup. track is mandatory, not optional. Template literals aren't allowed in expressions, so it concatenates.Child to parent: telling the list to delete
The ✕ button lives in the item component, but the list lives in the parent. How the child asks the parent to act is one of the clearest style differences.
// Parent passed a function as a prop:
// <TodoItem onDelete={remove} ... />
<button
type="button"
className="btn-delete"
onClick={() => onDelete(todo.id)}
aria-label={`Delete "${todo.title}"`}
>
✕
</button>
on* naming is a convention, not a rule.<!-- Parent listens: <TodoItem @delete="remove" /> -->
<button
type="button"
class="btn-delete"
@click="emit('delete', todo.id)"
:aria-label="`Delete "${todo.title}"`"
>
✕
</button>
defineEmits declares the event names and payloads (see the component section). The parent subscribes with @delete.<!-- Parent listens: <app-todo-item (delete)="remove($event)" /> -->
<button
type="button"
class="btn-delete"
(click)="delete.emit(todo().id)"
[attr.aria-label]="'Delete "' + todo().title + '"'"
>
✕
</button>
delete = output<number>() on the class becomes a (delete) event on the tag. The payload arrives as $event. Static attributes need the attr. prefix.Touching the DOM: focusing the edit box
When you double-click a todo, the text becomes an input that should receive focus. That input doesn't exist until the framework renders it, so each one needs a way to say "after you've drawn this, focus it".
const [editing, setEditing] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (editing) inputRef.current?.focus()
}, [editing])
// in JSX:
<input ref={inputRef} className="edit-input" ... />
const editing = ref(false)
const inputRef = ref<HTMLInputElement | null>(null)
async function startEdit() {
draft.value = props.todo.title
editing.value = true
await nextTick()
inputRef.value?.focus()
}
// in template:
// <input v-if="editing" ref="inputRef" class="edit-input" ... />
await nextTick() waits for the flush. The same ref() function holds both state and elements.protected readonly editing = signal(false);
private readonly editInput =
viewChild<ElementRef<HTMLInputElement>>('editInput');
constructor() {
effect(() => {
if (this.editing()) this.editInput()?.nativeElement.focus();
});
}
// in template:
// <input #editInput class="edit-input" ... />
viewChild returns a signal that updates when the element appears, and the effect re-runs on it. Note the extra .nativeElement hop.What it was like to build them
Notes from writing the three apps back to back. The app is small, so these are observations about ceremony and tooling, not about what each framework can do at scale.
Vue: least to think about
Fewest lines, smallest bundle, and the code says exactly what it means. v-model, computed and a deep watch covered every need with no ceremony.
Mutating a found todo in place made toggle and rename one-liners. The only gotcha was remembering .value in script.
React: familiar, slightly verbose
Nothing surprising, but every feature costs a little more: immutable updates for each change, a ref plus an effect for focus, and hand-wired controlled inputs.
Still the fastest build and the most widely used of the three, which matters for a class heading toward jobs.
Angular: most structure, most friction
The latest CLI rejected Node 24.13, so version 20 was pinned. The scaffold ships a 20 kB placeholder page and a router that went unused. Builds take seconds, not milliseconds.
In return: strict template type-checking (a typo fails the build), a clean signals API, and @if / @for that read far better than older Angular.
Where to find everything
The three projects sit side by side in compareFrameworks/. Line counts are for the application source. The stylesheet is identical in all three.
- src/App.tsx125
- src/TodoItem.tsx71
- src/types.ts7
- src/main.tsx10
- src/index.cssshared
- src/App.vue121
- src/components/TodoItem.vue64
- src/types.ts7
- src/main.ts5
- src/style.cssshared
- src/app/app.ts68
- src/app/app.html53
- src/app/todo-item.ts42
- src/app/todo-item.html29
- src/app/types.ts7
- src/app/app.config.ts12
- src/main.ts6
- src/styles.cssshared