Chapter 19
Vue and Angular Compared
Reactivity models, templates and tooling compared with React.
19. React, Vue, and Angular
React, Vue, and Angular can all build interactive applications, but they organize the work differently. React emphasizes JavaScript components and composable hooks. Vue combines a declarative template with a reactivity system that tracks which values each computation reads. Angular provides a fuller application framework, including templates, dependency injection, routing, forms, and a signal-based reactivity model. Knowing those differences helps you choose tools deliberately and move between ecosystems without treating one framework's conventions as universal.
This chapter compares the frameworks through one practical feature: a filterable people table. Each implementation uses the same data and behavior, making the differences in templates, state, and derived values visible.
Learning objectives
By the end of this chapter, you will be able to:
- Describe React's state-driven rendering, Vue 3's proxy-based reactivity, and Angular Signals.
- Recognize the role of templates, components, and dependency injection in each ecosystem.
- Implement the same accessible, filterable table in React, Vue 3, and Angular 18 or later.
- Select local state primitives and avoid unnecessary abstractions.
- Diagnose common rendering and reactivity mistakes.
Three approaches to declarative interfaces
All three frameworks let you describe a relationship between application state and a user interface. When the state changes, the framework updates the relevant view. The difference is how that relationship is expressed and how values become reactive.
React: render from state
React components are functions that produce a description of the interface. When props or state change, React calls the component again. A component should therefore be understood as a render function, not as a persistent procedure that runs once.
function Greeting({ name }) {
const [expanded, setExpanded] = useState(false);
return (
<section>
<h2>Hello, {name}</h2>
<button onClick={() => setExpanded((value) => !value)}>
{expanded ? "Show less" : "Show more"}
</button>
{expanded && <p>More details are available.</p>}
</section>
);
}useState provides a value for the current render and a setter that schedules another render. React uses identity comparisons to determine whether values changed. For objects and arrays, replace the value rather than mutating it in place:
setPeople((current) =>
current.map((person) =>
person.id === 2 ? { ...person, active: false } : person,
),
);Changing person.active on an existing object and passing the same array back can hide a change from React's identity-based update logic. Hooks must be called consistently at the top level of a component or custom hook; do not put a hook inside a loop, condition, or nested function.
Vue 3: dependency-tracked reactivity
Vue's Composition API uses ref() and reactive() to make values reactive. Vue wraps reactive objects in JavaScript proxies. A proxy can intercept property reads and writes: a read records that a render or computed value depends on a property, and a later write notifies dependent work.
const person = reactive({ name: "Mina" });
const greeting = computed(() => `Hello, ${person.name}`);When greeting reads person.name, Vue records the dependency. Updating person.name causes the computed value and the parts of the template that use it to update. A ref wraps a value and exposes it through .value in JavaScript; templates automatically unwrap refs in common template contexts. reactive() is convenient for object-shaped state. ref() works for primitives and objects and is often easier to replace as one unit.
Vue's tracking has boundaries. Destructuring a primitive property out of a reactive object produces an ordinary local value, not a live connection to the proxy. Use a ref, access the property through the reactive object, or use toRef() / toRefs() when destructuring must retain reactivity.
Angular: signals and framework structure
Angular Signals represent values with callable signal objects. Read with count() and update with count.set(...) or count.update(...). computed() derives a value and tracks signals read during its calculation. This makes dependencies explicit and supports fine-grained updates.
const count = signal(0);
const doubled = computed(() => count() * 2);
count.update((value) => value + 1);Angular also has a strong framework layer around components. Components declare metadata, templates, and imports. Services hold reusable behavior or application-level dependencies, and dependency injection supplies those services to consumers. A component can receive a service with inject(ServiceType). Signals can be used in components and services; they complement rather than replace Angular's templates, routing, or dependency-injection system.
Comparing the three
| Topic | React | Vue 3 | Angular 18+ |
|---|---|---|---|
| View declaration | JSX in JavaScript/TypeScript | Single-file component template or render function | Decorated component with template |
| Local state | useState |
ref, reactive |
signal |
| Derived value | Calculate during render; use useMemo when measured need warrants it |
computed |
computed |
| Update style | Call setter with replacement value | Mutate tracked proxy or ref | Call set / update |
| Application structure | Choose libraries for routing, data, and dependency patterns | Core plus official ecosystem libraries | Integrated framework features and DI |
These are tendencies, not rules. React applications can use external stores, Vue apps can use TypeScript heavily, and Angular applications can keep simple state local.
Templates, components, and dependency injection
In React, JSX is JavaScript syntax that describes UI. Expressions inside braces are JavaScript values; control flow uses JavaScript constructs or composition. A component may accept props, call hooks, and return JSX. React's flexibility means the application team chooses many architecture conventions.
Vue single-file components commonly separate <template>, <script setup>, and <style> sections in one .vue file. The template offers directives such as v-if, v-for, and v-model. <script setup> exposes locally declared variables and functions to the template. Vue also supports render functions, so a template is a common syntax rather than the only one.
Angular component metadata associates a class with its template, styles, and dependencies. Modern standalone components declare imported template features in the component's imports. Angular's template control flow uses blocks such as @if and @for. Dependency injection is built into the framework: an injectable service can be supplied at application, route, or component scope. Choose the narrowest lifetime matching the state. A component-scoped service is useful when each instance needs an isolated state; a root service is appropriate for shared application state.
Build the same filterable table
The table displays an immutable list of people. A search input filters by name or email, and a department select filters by department. The result count communicates the current result. Each example provides a visible label and uses a semantic table. The filtering is case-insensitive and composes both criteria.
React implementation
This component uses local state and computes the filtered list from that state. For a small table, recomputing on render is simple and appropriate. Memoization is not inherently better: add it only when profiling or data size demonstrates a cost.
import { useState } from "react";
const people = [
{ id: 1, name: "Ari Patel", email: "ari@example.com", department: "Design" },
{ id: 2, name: "Mina Chen", email: "mina@example.com", department: "Engineering" },
{ id: 3, name: "Noah Williams", email: "noah@example.com", department: "Engineering" },
{ id: 4, name: "Sam Rivera", email: "sam@example.com", department: "Support" },
];
const departments = ["All departments", "Design", "Engineering", "Support"];
export default function PeopleTable() {
const [query, setQuery] = useState("");
const [department, setDepartment] = useState("All departments");
const normalizedQuery = query.trim().toLowerCase();
const filteredPeople = people.filter((person) => {
const matchesQuery =
person.name.toLowerCase().includes(normalizedQuery) ||
person.email.toLowerCase().includes(normalizedQuery);
const matchesDepartment =
department === "All departments" || person.department === department;
return matchesQuery && matchesDepartment;
});
return (
<section aria-labelledby="people-heading">
<h1 id="people-heading">People</h1>
<div>
<label htmlFor="people-search">Search name or email</label>
<input
id="people-search"
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
<label htmlFor="people-department">Department</label>
<select
id="people-department"
value={department}
onChange={(event) => setDepartment(event.target.value)}
>
{departments.map((name) => (
<option key={name} value={name}>{name}</option>
))}
</select>
</div>
<p role="status">
{filteredPeople.length} {filteredPeople.length === 1 ? "person" : "people"} found
</p>
<table>
<caption>People matching the selected filters</caption>
<thead>
<tr><th scope="col">Name</th><th scope="col">Email</th><th scope="col">Department</th></tr>
</thead>
<tbody>
{filteredPeople.length > 0 ? filteredPeople.map((person) => (
<tr key={person.id}>
<th scope="row">{person.name}</th>
<td><a href={`mailto:${person.email}`}>{person.email}</a></td>
<td>{person.department}</td>
</tr>
)) : (
<tr><td colSpan={3}>No people match these filters.</td></tr>
)}
</tbody>
</table>
</section>
);
}The stable id is the React key. It lets React reconcile rows correctly if items are reordered or removed. The search and select are controlled inputs: their displayed values are the state, and each input event updates that state. The filtered list is derived, not stored separately. Keeping a second filteredPeople state would require synchronizing it whenever either filter changes and invite stale results.
Vue 3 implementation
Save the following as a Vue single-file component. ref holds the two control values, and computed expresses the dependency from filters to table rows. Vue unwraps refs in the template, while JavaScript reads .value.
<script setup>
import { computed, ref } from "vue";
const people = [
{ id: 1, name: "Ari Patel", email: "ari@example.com", department: "Design" },
{ id: 2, name: "Mina Chen", email: "mina@example.com", department: "Engineering" },
{ id: 3, name: "Noah Williams", email: "noah@example.com", department: "Engineering" },
{ id: 4, name: "Sam Rivera", email: "sam@example.com", department: "Support" },
];
const departments = ["All departments", "Design", "Engineering", "Support"];
const query = ref("");
const department = ref("All departments");
const filteredPeople = computed(() => {
const normalizedQuery = query.value.trim().toLowerCase();
return people.filter((person) => {
const matchesQuery =
person.name.toLowerCase().includes(normalizedQuery) ||
person.email.toLowerCase().includes(normalizedQuery);
const matchesDepartment =
department.value === "All departments" ||
person.department === department.value;
return matchesQuery && matchesDepartment;
});
});
</script>
<template>
<section aria-labelledby="people-heading">
<h1 id="people-heading">People</h1>
<div>
<label for="people-search">Search name or email</label>
<input id="people-search" v-model="query" type="search">
<label for="people-department">Department</label>
<select id="people-department" v-model="department">
<option v-for="name in departments" :key="name" :value="name">
{{ name }}
</option>
</select>
</div>
<p role="status">
{{ filteredPeople.length }} {{ filteredPeople.length === 1 ? "person" : "people" }} found
</p>
<table>
<caption>People matching the selected filters</caption>
<thead>
<tr><th scope="col">Name</th><th scope="col">Email</th><th scope="col">Department</th></tr>
</thead>
<tbody>
<tr v-for="person in filteredPeople" :key="person.id">
<th scope="row">{{ person.name }}</th>
<td><a :href="`mailto:${person.email}`">{{ person.email }}</a></td>
<td>{{ person.department }}</td>
</tr>
<tr v-if="filteredPeople.length === 0">
<td colspan="3">No people match these filters.</td>
</tr>
</tbody>
</table>
</section>
</template>v-model binds the native input and select to reactive state. :key provides a stable identity for rows and options. The proxy/ref mechanism handles dependency tracking, so the computed result is recalculated when a filter changes and cached while its dependencies remain unchanged. Since people is constant, it need not be made reactive.
Angular 18+ implementation
This standalone component uses Angular Signals, standalone component imports, and the modern template control-flow syntax. FormsModule supplies [(ngModel)]. A component class property is made writable as a signal by reading it with () and calling .set() in event handlers. The example deliberately uses explicit events rather than mixing several state-binding styles.
import { Component, computed, signal } from "@angular/core";
import { FormsModule } from "@angular/forms";
type Person = {
id: number;
name: string;
email: string;
department: string;
};
@Component({
selector: "app-people-table",
standalone: true,
imports: [FormsModule],
template: `
<section aria-labelledby="people-heading">
<h1 id="people-heading">People</h1>
<div>
<label for="people-search">Search name or email</label>
<input
id="people-search"
type="search"
[value]="query()"
(input)="query.set($any($event.target).value)"
/>
<label for="people-department">Department</label>
<select
id="people-department"
[value]="department()"
(change)="department.set($any($event.target).value)"
>
@for (name of departments; track name) {
<option [value]="name">{{ name }}</option>
}
</select>
</div>
<p role="status">
{{ filteredPeople().length }}
{{ filteredPeople().length === 1 ? "person" : "people" }} found
</p>
<table>
<caption>People matching the selected filters</caption>
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">Email</th>
<th scope="col">Department</th>
</tr>
</thead>
<tbody>
@for (person of filteredPeople(); track person.id) {
<tr>
<th scope="row">{{ person.name }}</th>
<td><a [href]="'mailto:' + person.email">{{ person.email }}</a></td>
<td>{{ person.department }}</td>
</tr>
} @empty {
<tr><td colspan="3">No people match these filters.</td></tr>
}
</tbody>
</table>
</section>
`,
})
export class PeopleTableComponent {
readonly people: Person[] = [
{ id: 1, name: "Ari Patel", email: "ari@example.com", department: "Design" },
{ id: 2, name: "Mina Chen", email: "mina@example.com", department: "Engineering" },
{ id: 3, name: "Noah Williams", email: "noah@example.com", department: "Engineering" },
{ id: 4, name: "Sam Rivera", email: "sam@example.com", department: "Support" },
];
readonly departments = ["All departments", "Design", "Engineering", "Support"];
readonly query = signal("");
readonly department = signal("All departments");
readonly filteredPeople = computed(() => {
const normalizedQuery = this.query().trim().toLowerCase();
const selectedDepartment = this.department();
return this.people.filter((person) => {
const matchesQuery =
person.name.toLowerCase().includes(normalizedQuery) ||
person.email.toLowerCase().includes(normalizedQuery);
const matchesDepartment =
selectedDepartment === "All departments" ||
person.department === selectedDepartment;
return matchesQuery && matchesDepartment;
});
});
}computed tracks query() and department() because it reads both. Calling the computed signal in the template reads its current value. @for requires an identity expression; track person.id preserves the relationship between rendered rows and records. $any is a template type escape for the DOM event target. In a larger application, a small typed event handler can avoid repeating that escape. To add validation or more elaborate form behavior, use Angular's reactive forms rather than growing an ad hoc event layer.
State placement and application services
Start state as close as possible to where it is used. In React, lift state to the nearest common parent when multiple components need it; use context for dependency-like values or a shared subtree, and use an external store when shared client state has real complexity. In Vue, local refs are often enough; composables package reusable stateful logic, and Pinia is a conventional store for larger shared state. In Angular, a component signal is appropriate for local UI; an injectable service can own state shared across components, while route-provided services can scope it to a feature.
Dependency injection and state management solve different problems. DI decides how a dependency is constructed and delivered. A service may contain state, but not every injectable service needs to be a global store. Conversely, React context passes a value down a tree but does not provide Angular-style constructor injection or automatic service lifetimes.
Separate durable domain data from ephemeral presentation state. A search field, expanded row, or dialog toggle is often local. A user's saved preferences may belong in persistent client storage. Server-owned records should normally be fetched and cached through a data layer that handles loading, errors, and invalidation. Avoid duplicating one value across local component state, a global store, and a query cache unless there is an explicit synchronization design.
Troubleshooting and best practices
A React view does not update after editing an array. Check whether the same array/object was mutated and supplied to the setter. Create a replacement with map, spread syntax, or another immutable operation. Confirm the setter belongs to the component instance displaying the data.
A React effect repeatedly runs or uses stale values. Effects synchronize with external systems; they are not a default place to derive display data. Review the dependency list and move pure calculations into render or a memoized calculation. Avoid suppressing dependency warnings instead of understanding the lifecycle.
Vue seems to lose a reactive value after destructuring. Read through the proxy, keep it as a ref, or derive a ref with toRef/toRefs. Remember .value in script and template unwrapping. Do not assume an arbitrary object imported from a file is reactive simply because it is used in a Vue app.
Angular computed state remains stale. Ensure reads use signal invocation, such as query(), not a stored function reference or a plain property copied once from the signal. A computed value only tracks signals actually read during its last evaluation. Use set or update, not assignment to the signal variable.
The table renders incorrect row state. Use stable IDs for keys/tracking, not array positions when rows can change order. Confirm that filters compose using && when both constraints must match. Check whitespace normalization and case conversion consistently.
The filter does not announce updates. A result count with role="status" can expose changes to assistive technology. Keep native labels, semantic table headers, and useful empty states. Framework templates do not make inaccessible markup accessible automatically.
The component has too many state layers. Draw the data flow: which values are canonical, which are derived, who owns each update, and when data becomes stale. Remove duplicate state first. Add a library only when it gives a concrete capability such as shared query caching, durable URL state, or well-defined cross-feature state.
Exercises
Exercise 1: Add an active-only filter
Extend each implementation with a checkbox labeled “Active only.” Add an active boolean to each person, keep the checkbox state alongside the current filters, and make the filtered list satisfy all three filters.
Solution
Add active: true or active: false to every person. The core predicate in each implementation becomes:
const matchesActive = !activeOnly || person.active;
return matchesQuery && matchesDepartment && matchesActive;In React, add const [activeOnly, setActiveOnly] = useState(false) and a labeled checkbox with checked={activeOnly} and onChange={(event) => setActiveOnly(event.target.checked)}. In Vue, declare const activeOnly = ref(false) and bind a labeled checkbox using v-model="activeOnly"; inside the computed callback read activeOnly.value. In Angular, declare readonly activeOnly = signal(false), bind the checkbox [checked]="activeOnly()" and (change)="activeOnly.set($any($event.target).checked)", then read this.activeOnly() inside computed. The filter predicate remains pure and the checkbox adds no separately maintained result list.
Exercise 2: Extract a reusable filter function
Move filtering into a pure function that accepts a people array, a query string, and a department string. Explain how each framework would call it and identify which state belongs in the component.
Solution
function filterPeople(people, query, department) {
const normalizedQuery = query.trim().toLowerCase();
return people.filter((person) => {
const matchesText =
person.name.toLowerCase().includes(normalizedQuery) ||
person.email.toLowerCase().includes(normalizedQuery);
const matchesDepartment =
department === "All departments" || person.department === department;
return matchesText && matchesDepartment;
});
}React calls filterPeople(people, query, department) during render. Vue calls it from computed, passing query.value and department.value. Angular calls it from computed, passing this.query() and this.department(). Keep the filter values local because they describe this table's controls. The helper is pure: it does not access framework state, mutate rows, or perform I/O, so it is straightforward to unit test. If several screens require identical filtering, share the function in a domain module; do not make it a global store merely because it is reusable.
Key takeaways
- React rerenders from state and props; Vue tracks proxy/ref dependencies; Angular Signals expose explicit signal reads and updates.
- Each framework provides a different template and application architecture; none removes the need for accessible HTML and clear data ownership.
- Compute filtered rows from canonical inputs instead of storing redundant results.
- Stable identities, explicit labels, semantic tables, and empty states matter in every framework.
- Choose stores, DI services, composables, and hooks because they solve a real scope or reuse problem—not by habit.