Three Todos
Same app, three frameworks

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.

React19.3 · Vite 8 · TypeScript
Vue3.5 · Vite 8 · TypeScript
Angular20.3 · Angular CLI · TypeScript

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.

ReactVueAngular
Framework version19.3.03.5.4220.3.31
Build toolVite 8Vite 8Angular CLI (esbuild)
Source files447
Lines of app source213197217
Runtime dependencies219
Dev dependencies7611
node_modules on disk90 MB80 MB269 MB
Production JS, gzipped69.6 kB26.4 kB70.9 kB
Production build time0.2 s0.4 s4.9 s
Dev servernpm run dev · :5173npm run dev · :5174npm 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.

Reactreact-todo/src/TodoItem.tsx
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>
  )
}
One file, one function. Props are an ordinary typed argument. The markup is JSX inside the function body, so template and logic share one scope.
Vuevue-todo/src/components/TodoItem.vue
<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>
One file, two blocks. <script setup> runs once per instance; anything declared at its top level is available in the template automatically.
Angularangular-todo/src/app/todo-item.ts
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...
}
Two files, one class. The decorator wires selector, template and host bindings. The template in 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.

Reactreact-todo/src/App.tsx
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)),
  )
}
Immutable by convention. You never edit todos; you hand setTodos a brand-new array. The functional form (prev => ...) avoids stale closures.
Vuevue-todo/src/App.vue
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
}
Mutate freely. 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.
Angularangular-todo/src/app/app.ts
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)),
  );
}
Signals. Read with 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.

Reactreact-todo/src/App.tsx
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,
)
Plain variables. Recomputed on every render. Reach for useMemo only when profiling says so.
Vuevue-todo/src/App.vue
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,
  ),
)
Cached. A computed only re-runs when something it read changes, and it tracks those dependencies itself.
Angularangular-todo/src/app/app.ts
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,
  );
});
Same idea as Vue. Angular's 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".

Reactreact-todo/src/App.tsx
useEffect(() => {
  localStorage.setItem(STORAGE_KEY, JSON.stringify(todos))
}, [todos])
You list the dependencies. The array tells React when to re-run. Forget a value and it silently goes stale; a lint rule exists for exactly that.
Vuevue-todo/src/App.vue
watch(
  todos,
  (value) => localStorage.setItem(STORAGE_KEY, JSON.stringify(value)),
  { deep: true },
)
You name the source. deep: true is needed because toggling mutates an object inside the array rather than replacing the array.
Angularangular-todo/src/app/app.ts
constructor() {
  effect(() => {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(this.todos()));
  });
}
Automatic tracking. Any signal read inside the callback becomes a dependency. Must be created in an injection context, hence the constructor.

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.

Reactreact-todo/src/App.tsx
<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>
Controlled input. Value flows down, the change handler pushes it back up. Note className, autoFocus and onChange instead of HTML names.
Vuevue-todo/src/App.vue
<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>
Two-way binding built in. v-model does both directions. The .prevent modifier replaces a manual preventDefault() call.
Angularangular-todo/src/app/app.html
<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>
Square brackets in, round brackets out. [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.

Reactreact-todo/src/App.tsx
{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>
)}
Just JavaScript. A ternary for the branch, Array.map for the loop. No template syntax to learn, but nesting gets noisy fast.
Vuevue-todo/src/App.vue
<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>
Directives on elements. v-if / v-else and v-for sit on the tag itself. Text uses mustaches, attributes use a leading :.
Angularangular-todo/src/app/app.html
@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>
}
Block syntax. Angular 17+ uses @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.

Reactreact-todo/src/TodoItem.tsx
// 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>
Callbacks as props. There is no event system; the parent simply passes a function down and the child calls it. The on* naming is a convention, not a rule.
Vuevue-todo/src/components/TodoItem.vue
<!-- Parent listens: <TodoItem @delete="remove" /> -->

<button
  type="button"
  class="btn-delete"
  @click="emit('delete', todo.id)"
  :aria-label="`Delete &quot;${todo.title}&quot;`"
>
  ✕
</button>
Typed custom events. defineEmits declares the event names and payloads (see the component section). The parent subscribes with @delete.
Angularangular-todo/src/app/todo-item.html
<!-- Parent listens: <app-todo-item (delete)="remove($event)" /> -->

<button
  type="button"
  class="btn-delete"
  (click)="delete.emit(todo().id)"
  [attr.aria-label]="'Delete &quot;' + todo().title + '&quot;'"
>
  ✕
</button>
Outputs. 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".

Reactreact-todo/src/TodoItem.tsx
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" ... />
Ref plus effect. Effects run after the DOM is committed, so by the time this fires the input is real.
Vuevue-todo/src/components/TodoItem.vue
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" ... />
Ref plus nextTick. Vue batches DOM updates, so await nextTick() waits for the flush. The same ref() function holds both state and elements.
Angularangular-todo/src/app/todo-item.ts
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" ... />
Signal query plus effect. 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.

react-todo/
  • src/App.tsx125
  • src/TodoItem.tsx71
  • src/types.ts7
  • src/main.tsx10
  • src/index.cssshared
vue-todo/
  • src/App.vue121
  • src/components/TodoItem.vue64
  • src/types.ts7
  • src/main.ts5
  • src/style.cssshared
angular-todo/
  • 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