The Great Convergence: How Angular 21 Became the Best React Framework
For years, the choice between Angular and React was a choice between two different worlds. Angular was the “Enterprise Behemoth” full of boilerplate, while React was the “Lightweight Library” that focused on simplicity.
But in 2026, something has changed. After building the same Todo application in both frameworks, I’ve realized: The gap is gone.
The Proof: Two Apps, One Philosophy
I recently built and deployed two identical Todo apps to compare the modern developer experience (DX):
- Angular Todo: Built with Signals, Standalone Components, and TanStack Query.
- React Todo: Built with Hooks, TanStack Query, and Zustand.
🔗 Check out the code here: fredyang/angular-react-to-do
🚀 Live Demos:
1. The “Component Rosetta Stone”
With Standalone Components, Angular has finally killed the NgModule. My Angular components now look almost exactly like my React components. You import what you need, define your logic, and export the class. Let’s compare how similar they are:
React (TodoForm.tsx)
export interface TodoFormProps {
added: (text: string) => void;
}
export function TodoForm({ added }: TodoFormProps) {
const [text, setText] = useState("");
function handleSubmit(e: React.FormEvent): void {
e.preventDefault();
if (text.trim()) {
added(text.trim());
setText("");
}
}
return (
<form onSubmit={handleSubmit} className="flex gap-2 mt-6">
<input
className="flex-1 px-4 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-400"
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Add a new task..."
/>
<button
type="submit"
className="bg-blue-700 text-white px-4 py-2 rounded hover:bg-blue-800 transition"
>
Add
</button>
</form>
);
}
Angular (TodoForm.ts)
@Component({
selector: "app-todo-form",
standalone: true,
imports: [FormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<form (ngSubmit)="handleSubmit()" class="flex gap-2 mt-6">
<input
class="flex-1 px-4 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-400"
type="text"
[value]="text()"
(input)="text.set($any($event.target).value)"
placeholder="Add a new task..."
/>
<button
type="submit"
class="bg-blue-700 text-white px-4 py-2 rounded hover:bg-blue-800 transition"
>
Add
</button>
</form>
`,
})
export class TodoFormComponent {
text = signal("");
added = output<string>();
handleSubmit() {
const value = this.text().trim();
if (value) {
this.added.emit(value);
this.text.set("");
}
}
}
Same structure. Same mental model. The differences are purely syntactic:
| Concept | React | Angular |
|---|---|---|
| Component | Function | Class with @Component |
| Local State | useState() |
signal() |
| Input | Props | Class field with input() |
| Output | Callback prop | Class field with output() |
| Usage | <TodoForm added={addTodo} /> |
<app-todo-form (added)="addTodo($event)" /> |
The boilerplate is gone. The learning curve has flattened.
2. Signals vs. useState
From the examples above, you can see that the way we manage local state has also converged. In the
React version, I used useState. In Angular, I used Signals. While the “engine” is different,
the mental model is now identical.
- React:
const [text, setText] = useState(""); - Angular:
text = signal("");
However, once you dig deeper, the technical implementation shows where Angular has leapfrogged traditional React state management:
- No more Dependency Arrays: In React, if you want to derive state, you’re trapped in
useMemo(() => ..., [todos]). If you forget a dependency, you have a bug. Signals track dependencies automatically. - Fine-Grained Updates: When you update
useState, React re-runs the entire component function. When you update a Signal, Angular updates only the specific part of the DOM that changed. It bypasses the “Virtual DOM” diffing process entirely. - Predictable Reactivity:
useStateupdates are batched and asynchronous. Signals are synchronous, providing immediate predictability.
3. TanStack Query: The Great Unifier
Traditionally, Angular developers lived in a world of RxJS, NgRx, and the HttpClient. While powerful, managing “Server State” (loading, error, caching) required massive amounts of boilerplate.
By using TanStack Query for both versions, the data-fetching logic became framework-agnostic:
React:
// hooks: useTodosApi.ts
export function useTodosApi() {
const queryClient = useQueryClient();
const { data: todos = [], isLoading } = useQuery<Todo[]>({
queryKey: ["todos"],
queryFn: async () => {
const res = await fetch("/api/todos");
return res.json();
},
});
const addTodo = useMutation({
mutationFn: async (text: string) => {
const res = await fetch("/api/todos", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text }),
});
return res.json();
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["todos"] }),
});
const toggleTodo = useMutation({
mutationFn: async (id: string) => {
const res = await fetch(`/api/todos/${id}`, { method: "PATCH" });
return res.json();
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["todos"] }),
});
const deleteTodo = useMutation({
mutationFn: async (id: string) => {
await fetch(`/api/todos/${id}`, { method: "DELETE" });
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["todos"] }),
});
return {
todos,
isLoading,
addTodo: addTodo.mutate,
toggleTodo: toggleTodo.mutate,
deleteTodo: deleteTodo.mutate,
};
}
// to use it in TodoApp.tsx
export function TodoApp() {
const { todos, isLoading, addTodo, toggleTodo, deleteTodo } = useTodosApi();
return (
<div className="max-w-xl mx-auto mt-12 p-6 bg-white rounded shadow-lg">
<h2 className="text-3xl font-bold mb-6 text-blue-700">React To-Do List</h2>
<TodoForm added={addTodo} />
{isLoading ? (
<div className="text-gray-500 mt-6">Loading...</div>
) : (
<TodoList todos={todos} onToggle={toggleTodo} onDelete={deleteTodo} />
)}
</div>
);
}
Angular:
// TodoService.ts
@Injectable({ providedIn: "root" })
export class TodoService {
private http = inject(HttpClient);
private queryClient = inject(QueryClient);
todos = injectQuery(() => ({
queryKey: ["todos"],
queryFn: () => {
return lastValueFrom(this.http.get<Todo[]>("/api/todos"));
},
}));
addTodo = injectMutation(() => ({
mutationFn: (text: string) =>
lastValueFrom(this.http.post<Todo>("/api/todos", { text } as Todo)),
onSuccess: () => {
this.queryClient.invalidateQueries({ queryKey: ["todos"] });
},
})).mutate;
toggleTodo = injectMutation(() => ({
mutationFn: (id: string) => lastValueFrom(this.http.patch<Todo>(`/api/todos/${id}`, {})),
onSuccess: () => {
this.queryClient.invalidateQueries({ queryKey: ["todos"] });
},
})).mutate;
deleteTodo = injectMutation(() => ({
mutationFn: (id: string) => lastValueFrom(this.http.delete<Todo>(`/api/todos/${id}`)),
onSuccess: () => {
this.queryClient.invalidateQueries({ queryKey: ["todos"] });
},
})).mutate;
}
// to use it in TodoApp.ts
@Component({
imports: [TodoListComponent, TodoFormComponent],
selector: "app-todo-app",
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="max-w-xl mx-auto mt-12 p-6 bg-white rounded shadow-lg">
<h2 class="text-3xl font-bold mb-6 text-blue-700">Angular To-Do List</h2>
<app-todo-form (added)="addTodo($event)" />
@if (todos.isLoading()) {
<div className="text-gray-500 mt-6">Loading...</div>
} @else {
<app-todo-list
[todos]="todos.data() ?? []"
(toggleTodo)="toggleTodo($event)"
(deleteTodo)="deleteTodo($event)"
></app-todo-list>
}
</div>
`,
})
export class TodoApp {
private todoService = inject(TodoService);
todos = this.todoService.todos;
addTodo = this.todoService.addTodo;
toggleTodo = this.todoService.toggleTodo;
deleteTodo = this.todoService.deleteTodo;
}
The introduction of TanStack Query has completely simplified this. By moving away from purely stateless API calls, we gain:
- Built-in Caching: Query results are cached automatically.
- Self-Refreshing State: Queries background-refresh automatically when they become “stale.”
- Mutation-driven Invalidation: Adding a Todo automatically triggers a background fetch to sync the UI.
It effectively kills the need for complex global state managers like NgRx for 90% of use cases.
4. Zoneless: Removing the Last Piece of “Magic”
React has always been explicit: state changes → re-render. Angular, historically, relied on Zone.js—a clever patch that intercepted every async browser event (clicks, timers, HTTP calls) to know when to run change detection. It was magic. And like all magic, it occasionally produced bugs that were nearly impossible to debug.
Angular 21 is officially Zoneless by default. You don’t need to do anything.
No configuration change is required. Now Angular behaves exactly like React: the UI updates because a Signal changed, not because the framework is sniffing the browser. No more Zone.js in your bundle. No more unexplained double renders. No more NgZone.runOutsideAngular() hacks for performance-sensitive code.
This is the final piece of the convergence.
The Secret Sauce: Why Angular Still Feels “Enterprise”
While the UI logic has converged, Angular retains its superpower: Dependency Injection.
In React, sharing logic often leads to “Hook Soup”. Custom hooks are simple, but as soon as you need a true singleton or shared state, you have to lift the state up and pass it through props or wrap your entire app in complex Context Providers. This leads to deep tree nesting and the “Prop Drilling” headache.
In Angular, the inject(TodoService) pattern provides a clean, testable singleton architecture. By marking a service as providedIn: 'root', you have a globally available, lazily instantiated instance that lives outside the component lifecycle.
Key benefits of DI over pure Hooks:
- True Singletons: No risk of multiple instances when you only want one.
- Hierarchical DI: You can easily scope services to specific routes or component sub-trees without manual context providers.
- Decoupled Testing: Mocking services for unit tests is built into the framework, making it significantly easier to test components in isolation.
- Architecture at Scale: It provides architectural “guardrails” that ensure multi-team projects stay organized.
Conclusion
Angular isn’t copying React. It’s refining it.
Standalone Components killed NgModules. Signals replaced RxJS for local state. TanStack Query unified server-state management across both ecosystems. Zoneless rendering removed the last piece of “framework magic.”
What remains is a framework that takes React’s best ideas—component simplicity, explicit data flow, hooks-like reactivity—and adds what React has always lacked: a proper DI system, a standard CLI, and architectural guardrails that scale to large teams.
If you’re a React developer: Angular is no longer the scary enterprise framework you remember. Give it a week.
If you’ve been burned by Angular’s old complexity: welcome back. It finally got it right.