Frontend Framework Landscape
What this guide is: A single-file orientation covering how modern frontend frameworks work, why they exist, and how they compare — including against vanilla JavaScript. Not a deep tutorial for any one framework; the layer you read before going deep on one.
Target reader: A developer who knows at least one framework and wants to map the full ecosystem, or someone moving from one framework to another.
How to use it:
- Choosing a framework: "Which Framework Should You Learn?"
- Why frameworks at all: "The Baseline — Vanilla JS"
- Syntax lookup: jump to any numbered concept section (1–12) and compare frameworks side by side
- Next steps: "Going Deeper" at the end — or jump directly to roadmap.sh/react, roadmap.sh/angular, roadmap.sh/vue
Out of scope (deliberately): testing tooling, CSS-in-JS depth, native/mobile, and SSG internals. Pointers are given where they matter; depth lives in the per-framework roadmaps.
The Seven Problems Every Framework Solves
Learn the concepts once. Learn the syntax many times.
Most frontend frameworks solve the same problems:
- Display data
- Render lists
- Render conditionally
- Handle user input
- Navigate between pages
- Load data from a server
- React to state changes
The biggest difference is usually where the framework places control flow.
| Framework | Control Flow Lives In |
|---|---|
| React | JavaScript |
| Angular | Template DSL |
| Vue | Template DSL |
| Svelte | Template DSL |
| Solid | JavaScript |
The Baseline — Vanilla JS, and Why Frameworks Exist
Before frameworks: the DOM API and manual wiring. Building a todo app without any framework is instructive — every line of pain here is a line a framework removes.
The running example
The whole guide uses one minimal Todo app:
Todos Todo #1
------------------
Buy milk Title: Buy milk
Learn Angular Completed: No
Build app
[Back]
(list page) (details page)
Every data-fetching page in it follows the same shape, regardless of framework:
Load data → Loading? → Error? → Render dataKeep that pattern in mind — it shows up in vanilla below, in every framework's fetch example, and in the Fetching Data section.
Pure HTML + JavaScript (no build step)
<!-- index.html -->
<ul id="todo-list"></ul>
<script type="module" src="main.js"></script>// main.js
async function loadTodos() {
const res = await fetch(
"https://jsonplaceholder.typicode.com/todos?_limit=5",
);
return res.json(); // returns a Promise<Todo[]>
}
function renderTodos(todos) {
// todos is the array passed in — either from .then() or called manually
const list = document.getElementById("todo-list");
list.innerHTML = ""; // nuke and re-render every time — no diffing
todos.forEach((todo) => {
const li = document.createElement("li"); // creates a real <li> HTML element
li.textContent = todo.title;
list.appendChild(li); // attaches it to the <ul> in the DOM
});
}
// .then(renderTodos) is shorthand for .then(todos => renderTodos(todos))
// the resolved array is passed as the first argument automatically
loadTodos().then(renderTodos);Loading and error state are not handled above — the happy path only. In vanilla, you'd add them manually:
async function init() {
document.getElementById("loading").style.display = "block"; // show manually
try {
const todos = await loadTodos();
renderTodos(todos);
} catch (err) {
document.getElementById("error").textContent = "Failed to load"; // set manually
} finally {
document.getElementById("loading").style.display = "none"; // hide manually
}
}Every UI state transition — loading, error, empty, success — requires explicit
DOM manipulation. With a framework, isPending and isError handle all of
this; you only describe what to render for each case.
Adding interactivity — the pain becomes visible
let todos = [];
let search = "";
loadTodos().then((result) => {
todos = result;
renderTodos(todos);
});
document.getElementById("search").addEventListener("input", (e) => {
search = e.target.value;
const filtered = todos.filter((t) => t.title.includes(search));
renderTodos(filtered); // you must remember to call this after every state change
});Every state change requires you to manually decide what to re-render. As the app grows — shared state, navigation, nested components — this coordination becomes the whole job.
What's missing
| Problem | Vanilla | Framework |
|---|---|---|
| State → UI sync | Manual: call render functions after each change | Automatic: declare dependencies |
| DOM updates | Imperative: createElement, appendChild |
Declarative: describe output, framework diffs |
| Event cleanup | Manual removeEventListener on teardown |
Framework handles lifecycle |
| Routing | hashchange listeners, show/hide divs |
Router component |
| Loading/error state | Manual flags + manual DOM updates | isPending / isError from query |
| TypeScript | Needs a compiler (tsc or Vite — see below) | Included in framework starter |
Reactive state — what replaces the manual wiring
Every framework in this guide is built around one idea: reactive state. You declare a value as reactive, and any UI that reads it updates automatically when it changes. The syntax differs; the concept is identical.
This is exactly what the vanilla version above does not give you. A plain
variable is just a variable — nothing subscribes to it, nothing watches it,
nothing re-renders when it changes. When you write count = count + 1, the
browser has no idea any part of the DOM depends on count. You have to find the
relevant DOM nodes and update them yourself. That manual wiring — calling
renderTodos(), setting element.textContent, toggling classes — is the job
frameworks replace with reactive state.
| Framework | Declaration | Reading the value |
|---|---|---|
| Vanilla JS | let count = 0 |
count — changing it updates nothing automatically |
| React | const [count, setCount] = useState(0) |
count |
| Angular | count = signal(0) |
count() (it's a getter) |
| Vue | const count = ref(0) |
count.value in JS, count in template |
| Svelte | let count = 0 (compiler-tracked) |
count |
| Solid | const [count, setCount] = createSignal(0) |
count() (it's a getter) |
The mental model is identical everywhere: declare state, describe UI that depends on it, let the framework keep them in sync. How each framework tracks those changes under the hood genuinely differs — that's covered at the end in What Actually Changes Between Frameworks?.
Same list — the top three frameworks
Here's the same fetch-loading-error-render list in React, Angular, and Vue. The
point isn't to compare them — it's to show that three frameworks with wildly
different shapes solve the identical vanilla pain the same way: declare the
UI, let the framework keep it in sync. No createElement, no manual
renderTodos(), no event-listener wiring in any of them.
React — control flow in JavaScript, JSX for markup:
function TodoList() {
const { data, isPending, error } = useQuery({
queryKey: ["todos"],
queryFn: () =>
fetch("https://jsonplaceholder.typicode.com/todos").then((r) => r.json()),
});
if (isPending) return <p>Loading</p>;
if (error) return <p>Error</p>;
return (
<ul>
{data.map((t) => (
<li key={t.id}>{t.title}</li>
))}
</ul>
);
}Angular — a class with a template DSL, control flow in the template:
@Component({
selector: "app-todos",
standalone: true,
template: `
@if (todos.isPending()) {
<p>Loading</p>
} @else if (todos.isError()) {
<p>Error</p>
} @else {
<ul>
@for (t of todos.data()!; track t.id) {
<li>{{ t.title }}</li>
}
</ul>
}
`,
})
export class TodosComponent {
todos = injectQuery(() => ({
queryKey: ["todos"],
queryFn: () =>
fetch("https://jsonplaceholder.typicode.com/todos").then((r) => r.json()),
}));
}Vue — a single-file component, script + template DSL:
<script setup lang="ts">
const { data, isPending, error } = useQuery({
queryKey: ["todos"],
queryFn: () =>
fetch("https://jsonplaceholder.typicode.com/todos").then((r) => r.json()),
});
</script>
<template>
<p v-if="isPending">Loading</p>
<p v-else-if="error">Error</p>
<ul v-else>
<li v-for="t in data" :key="t.id">{{ t.title }}</li>
</ul>
</template>A class with a decorator, a function returning JSX, a .vue file with two
blocks — about as different as three tools can look. Yet each one makes the same
guarantee the vanilla version couldn't: declare your UI, the framework keeps
it in sync with state. That guarantee is the thing you're actually learning;
the syntax is just the local dialect.
A note on neutrality. From here on, most single-example sections use React as the stand-in. That's a practical choice, not a verdict: React has the largest ecosystem, the biggest hiring pool, and — bluntly — LLMs are trained on far more React than anything else, which makes it the path of least resistance for a learner today. None of that means React is better at the concept — as the three examples above show, the concept is identical everywhere. Read the React snippets as "the most widely-spoken dialect," and translate using the per-concept sections (1–12) when your framework differs.
The core shift — imperative vs declarative
This is the one idea underneath everything above.
Vanilla JS is imperative — you tell the browser how to change the DOM, step by step. Find this element, set this value, add this class. You're in full control, but you're also responsible for keeping the UI in sync with your data at every point in time.
Frameworks are declarative — you describe what the UI should look like for a given state, and the framework figures out the minimal DOM changes needed. State changes trigger re-renders automatically.
The clearest illustration is shared state. In vanilla, every piece of UI that depends on a value must be updated by hand:
let currentUser = null;
function login(user) {
currentUser = user;
updateNavbar(user); // remember all the places that depend on this
updateSidebar(user);
updateWelcome(user);
}In a framework, state is the single source of truth and changes propagate automatically:
function App() {
const [user, setUser] = useState(null);
// update user once → all consumers re-render automatically
return (
<>
<Navbar user={user} />
<Sidebar user={user} />
<Welcome user={user} />
</>
);
}When vanilla is still the right call
Frameworks add overhead: a bundle, a build step, a mental model to learn. Vanilla is the right call when:
- The interaction is small and isolated (a dropdown, a tooltip, an accordion)
- You're adding behavior to a server-rendered page (Laravel, Rails, Django)
- You need zero JS overhead — a static page with one or two event listeners
- You're learning — building in vanilla first is the best way to understand what frameworks actually solve
Use a framework when:
- Multiple components share and react to the same state
- The UI has meaningful async states (loading, error, refetching)
- You're building a single-page app with client-side navigation
- The team and codebase size justify the structure a framework provides
Vite — The Tooling Layer
The vanilla example above runs in a browser directly. Framework code does not — it needs to be transformed first. Browsers understand HTML, CSS, and vanilla JS; nothing else. That's what Vite is for, and it's why every framework starter ships with it.
What browsers can't run natively
| File type | Problem |
|---|---|
.ts |
TypeScript — browsers have no type system |
.tsx / .jsx |
JSX — <div> inside JS is not valid JavaScript |
.vue / .svelte |
Single-file component format — browsers don't know this |
import from 'react' |
Node modules — browsers can't access node_modules |
What Vite does
Your code (.tsx, .vue, .svelte, .ts)
↓ Vite (esbuild under the hood — very fast)
Browser (plain JS + CSS the browser can actually run)
| Feature | What it means for you |
|---|---|
| Dev server | npm run dev → instant server, no bundling on every save |
| HMR | Edit a file → only that module updates in the browser, no full reload |
| TypeScript | Types stripped at build time (fast; run tsc separately for type errors) |
| Production build | Bundles + minifies + tree-shakes via Rollup → dist/ folder |
Vite is framework-agnostic — a build tool, not a UI library. React, Vue, Svelte, and Solid starters all use it by default. Angular uses esbuild via the Angular CLI (a Vite-based builder is available as opt-in); Next.js ships its own bundler (Turbopack); Nuxt uses Vite internally.
The workflow
npm create vite@latest my-app -- --template react-ts
cd my-app && npm install
npm run dev # dev server → localhost:5173, HMR active
npm run build # outputs dist/ → deploy to Cloudflare Pages, Vercel, etc.dist/ is just HTML + CSS + JS — no Node.js needed at runtime. The server just
serves static files.
1. Component
A component is:
State + UIReact
function TodoList() {
return <div>Todos</div>;
}Angular
@Component({
selector: "todo-list",
standalone: true,
template: `<div>Todos</div>`,
})
export class TodoListComponent {}Used as <todo-list></todo-list>. Angular components are classes with metadata
attached — the only framework here that uses classes rather than functions.
Vue
<template>
<div>Todos</div>
</template>Svelte
<div>Todos</div>Svelte components are just files — no wrapper needed.
Solid
function TodoList() {
return <div>Todos</div>;
}Looks identical to React. The difference is under the hood (fine-grained reactivity, no VDOM).
2. Props (Parent → Child)
Props pass data from a parent component down to a child. The child reads them; it cannot mutate them directly.
React
function TodoItem({ title, completed }: { title: string; completed: boolean }) {
return (
<li style={{ textDecoration: completed ? "line-through" : "none" }}>
{title}
</li>
);
}
// usage
<TodoItem title="Buy milk" completed={false} />;Angular
@Component({
selector: "app-todo-item",
standalone: true,
template: `<li [style.text-decoration]="completed ? 'line-through' : 'none'">
{{ title }}
</li>`,
})
export class TodoItemComponent {
@Input() title = "";
@Input() completed = false;
}<!-- usage: [brackets] pass a JS expression, not a string literal -->
<app-todo-item [title]="todo.title" [completed]="todo.completed" />Vue
<script setup lang="ts">
defineProps<{ title: string; completed: boolean }>();
</script>
<template>
<li :style="{ textDecoration: completed ? 'line-through' : 'none' }">
{{ title }}
</li>
</template><!-- usage: :colon prefix passes a JS expression -->
<TodoItem :title="todo.title" :completed="todo.completed" />Svelte
<script lang="ts">
export let title: string
export let completed: boolean
</script>
<li style:text-decoration={completed ? 'line-through' : 'none'}>{title}</li><!-- usage -->
<TodoItem {title} {completed} />Solid
function TodoItem(props: { title: string; completed: boolean }) {
return (
<li
style={{ "text-decoration": props.completed ? "line-through" : "none" }}
>
{props.title}
</li>
);
}
// usage
<TodoItem title="Buy milk" completed={false} />;Solid gotcha: never destructure props —
const { title } = propsbreaks reactivity. Always access asprops.title.
3. Events (Child → Parent)
Props flow down; events flow up. When a child needs to tell the parent something happened (button clicked, item deleted, form submitted), each framework has its own mechanism.
React
Callback props — events are just functions passed as props. No special API.
function TodoItem({
title,
onDelete,
}: {
title: string;
onDelete: () => void;
}) {
return (
<li>
{title}
<button onClick={onDelete}>Delete</button>
</li>
);
}
// usage
<TodoItem title="Buy milk" onDelete={() => removeTodo(id)} />;Angular
@Output() + EventEmitter. The parent listens with (eventName) binding.
@Component({
selector: "app-todo-item",
standalone: true,
template: `
<li>
{{ title }}
<button (click)="deleted.emit()">Delete</button>
</li>
`,
})
export class TodoItemComponent {
@Input() title = "";
@Output() deleted = new EventEmitter<void>();
}<!-- usage -->
<app-todo-item [title]="todo.title" (deleted)="removeTodo(todo.id)" />Vue
defineEmits declares what events the component can fire. Parent listens with
@eventName.
<script setup lang="ts">
defineProps<{ title: string }>();
const emit = defineEmits<{ delete: [] }>();
</script>
<template>
<li>
{{ title }}
<button @click="emit('delete')">Delete</button>
</li>
</template><!-- usage -->
<TodoItem :title="todo.title" @delete="removeTodo(todo.id)" />Svelte
createEventDispatcher creates a typed dispatch function.
<script lang="ts">
import { createEventDispatcher } from 'svelte'
export let title: string
const dispatch = createEventDispatcher()
</script>
<li>
{title}
<button on:click={() => dispatch('delete')}>Delete</button>
</li><!-- usage -->
<TodoItem {title} on:delete={() => removeTodo(id)} />Solid
Same as React — callback props, no special API.
function TodoItem(props: { title: string; onDelete: () => void }) {
return (
<li>
{props.title}
<button onClick={props.onDelete}>Delete</button>
</li>
);
}
// usage
<TodoItem title="Buy milk" onDelete={() => removeTodo(id)} />;Pattern summary: React and Solid treat events as callback props — nothing
special. Angular and Vue have dedicated emit APIs with explicit event
declarations. Svelte uses a dispatcher. Angular's (event) binding syntax from
Section 8 (User Input) is exactly what wires an @Output() to the parent.
4. Lifecycle & Side Effects
How to run code after mount, react to value changes, and clean up on unmount.
Common use cases: sync document title, start/stop timers, subscribe to a WebSocket, read from localStorage.
React — useEffect
useEffect(() => {
document.title = `Todo #${id}`;
return () => {
document.title = "App"; // cleanup: runs on unmount and before the next effect
};
}, [id]); // re-runs when id changes; [] = mount only; omit = every renderThe dependency array controls when the effect re-runs. No array = every render.
[] = mount once. [id] = when id changes.
Angular
@Component({ ... })
export class TodoDetailsComponent implements OnInit, OnDestroy {
@Input() id = ''
ngOnInit() {
document.title = `Todo #${this.id}`
}
ngOnDestroy() {
document.title = 'App'
}
}ngOnChanges runs when an @Input value changes. ngAfterViewInit runs after
the template renders — useful for integrating third-party DOM libraries.
Vue
import { onMounted, onUnmounted, watch } from "vue";
const props = defineProps<{ id: string }>();
onMounted(() => {
document.title = `Todo #${props.id}`;
});
onUnmounted(() => {
document.title = "App";
});
watch(
() => props.id,
(newId) => {
document.title = `Todo #${newId}`; // re-runs when props.id changes
},
);Svelte
<script lang="ts">
import { onMount, onDestroy } from 'svelte'
export let id: string
onMount(() => {
document.title = `Todo #${id}`
return () => { document.title = 'App' } // optional cleanup return
})
$: document.title = `Todo #${id}` // reactive label — re-runs when id changes
</script>$: is Svelte's reactive label: any statement prefixed with it re-runs whenever
the variables it reads change. It's Svelte's shorthand for watch + onMounted
combined.
Solid
import { createEffect, onCleanup } from "solid-js";
function TodoDetails(props: { id: string }) {
createEffect(() => {
document.title = `Todo #${props.id}`; // props.id is tracked automatically
onCleanup(() => {
document.title = "App";
});
});
}No dependency array — createEffect automatically tracks any signals or props
it reads and re-runs when they change.
5. Computed / Derived State
Derived state is a value computed from other state. Instead of storing it separately and manually keeping it in sync, you declare how it's computed and let the framework re-run it when dependencies change.
React — useMemo
const [todos, setTodos] = useState<Todo[]>([]);
const [filter, setFilter] = useState<"all" | "done" | "pending">("all");
const filtered = useMemo(
() =>
todos.filter((t) =>
filter === "all" ? true : filter === "done" ? t.completed : !t.completed,
),
[todos, filter],
);For cheap derivations, skip useMemo and compute inline — the memoization
overhead isn't worth it. Only use it when the computation is expensive or
referential stability matters (e.g. the result is a dependency of another
useEffect).
Angular — computed()
todos = signal<Todo[]>([]);
filter = signal<"all" | "done" | "pending">("all");
filtered = computed(() =>
this.todos().filter((t) =>
this.filter() === "all"
? true
: this.filter() === "done"
? t.completed
: !t.completed,
),
);
// template: {{ filtered() }}computed() is lazy and cached — it only re-runs when a signal it reads has
changed.
Vue — computed()
const todos = ref<Todo[]>([]);
const filter = ref<"all" | "done" | "pending">("all");
const filtered = computed(() =>
todos.value.filter((t) =>
filter.value === "all"
? true
: filter.value === "done"
? t.completed
: !t.completed,
),
);
// template: {{ filtered }} — no .value needed in templateSvelte — $: reactive declaration
<script lang="ts">
let todos: Todo[] = []
let filter: 'all' | 'done' | 'pending' = 'all'
$: filtered = todos.filter(t =>
filter === 'all' ? true :
filter === 'done' ? t.completed :
!t.completed
)
</script>Same $: label from Lifecycle — Svelte uses it for both side effects and
derived values.
Solid — createMemo
const [todos, setTodos] = createSignal<Todo[]>([]);
const [filter, setFilter] = createSignal<"all" | "done" | "pending">("all");
const filtered = createMemo(() =>
todos().filter((t) =>
filter() === "all"
? true
: filter() === "done"
? t.completed
: !t.completed,
),
);
// usage: filtered() — it's a signal| Framework | API | Dependency tracking |
|---|---|---|
| React | useMemo | Manual (array) |
| Angular | computed() | Automatic |
| Vue | computed() | Automatic |
| Svelte | $: decl. | Automatic |
| Solid | createMemo() | Automatic |
React is the only framework where you declare dependencies manually. Every other framework tracks them automatically by observing what reactive values are read during the computation.
6. Rendering Lists
Concept:
todos.map(todo => ...)React
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>Angular
<ul>
@for (todo of todos; track todo.id) {
<li>{{ todo.title }}</li>
}
</ul>Angular 17+ uses block control flow (@for, @if). The track expression is
required — equivalent to React's key. You'll still see *ngFor in pre-17
codebases.
Vue
<ul>
<li v-for="todo in todos" :key="todo.id">
{{ todo.title }}
</li>
</ul>Svelte
<ul>
{#each todos as todo}
<li>{todo.title}</li>
{/each}
</ul>Solid
<ul>
<For each={todos}>{(todo) => <li>{todo.title}</li>}</For>
</ul>Solid uses a <For> component instead of .map() — this is intentional for
fine-grained reactivity (avoids re-rendering the whole list on change).
7. Conditional Rendering
Concept:
if (loading) ...
if (error) ...React
if (loading) return <p>Loading...</p>;
if (error) return <p>Error</p>;Pure JavaScript — early returns work because components are functions.
Angular
@if (loading) {
<p>Loading...</p>
} @else if (error) {
<p>Error</p>
}Vue
<p v-if="loading">Loading...</p>
<p v-else-if="error">Error</p>Svelte
{#if loading}
Loading...
{:else if error}
Error
{/if}Solid
<Show when={loading}>Loading...</Show>8. User Input
Concept:
Read value → Update state → Re-render UIReact
Explicit: you wire value and onChange yourself. One-directional data flow by design.
const [search, setSearch] = useState('')
<input
value={search}
onChange={e => setSearch(e.target.value)}
/>Angular
Angular's binding syntax:
[property] = data down (one-way binding)
(event) = events up (event listener)
[(ngModel)] = two-way binding (sugar for both)<!-- Explicit -->
<input [value]="search" (input)="search = $any($event.target).value" />
<!-- Two-way sugar (requires FormsModule) -->
<input [(ngModel)]="search" />Vue
v-model is Vue's two-way binding sugar:
<input v-model="search" />Which expands to:
<input :value="search" @input="search = $event.target.value" />Svelte
<input bind:value={search} />Solid
const [search, setSearch] = createSignal('')
<input
value={search()}
onInput={e => setSearch(e.target.value)}
/>Note: search() — signals are functions in Solid, not plain values.
9. Routing
Concept: map URL paths to components.
/todos → TodoList component
/todos/123 → TodoDetails componentSPA ≠ one page. A Single Page Application means the browser loads one HTML shell once and JavaScript handles all navigation client-side — no full server round-trips on route changes. You can (and should) have dozens of routes in a SPA. The "single page" refers to the HTML document, not the number of screens or features. Putting everything on
/with no router is not a SPA pattern — it's just a missing router.
Two routing styles exist: file-based (folder structure = routes, recommended in modern React) and code-based (route config in JS/TS). Vue and Solid are code-based; SvelteKit is file-based by design; Angular supports both.
React — File-Based (Recommended)
TanStack Router uses file-based routing — the folder structure becomes the route definition, no separate config needed. Route params are fully type-safe, inferred from the filename.
src/routes/
├─ __root.tsx → root layout
├─ index.tsx → /
└─ todos/
├─ index.tsx → /todos
└─ $todoId.tsx → /todos/123
React — Code-Based (React Router v6)
Still widely used, especially in existing projects and SPAs that don't use Next.js:
// App.tsx
<Route path="/todos" element={<TodoList />} />
<Route path="/todos/:id" element={<TodoDetails />} />Angular Router
Standard Angular Router is code-based:
// app.routes.ts
export const routes: Routes = [
{ path: "todos", component: TodosComponent },
{ path: "todos/:id", component: TodoDetailsComponent },
];File-based routing is available via Analog (the Angular meta-framework, equivalent to Next.js for React).
Vue Router
// router/index.ts
const routes = [
{ path: "/todos", component: TodoList },
{ path: "/todos/:id", component: TodoDetails },
];Semi-official — always Vue Router, no meaningful alternative in the ecosystem.
SvelteKit — File-Based Only
src/routes/
├─ +page.svelte → /
└─ todos/
├─ +page.svelte → /todos
└─ [id]/
└─ +page.svelte → /todos/123
File-based only — the folder structure is the route definition.
Solid Router
Code-based:
<Route path="/todos/:id" component={TodoDetails} />SolidStart (the meta-framework) adds file-based routing using the same convention as SvelteKit.
10. Fetching Data
Concept:
Request → Loading → Data → RenderTanStack Query (React Query) works across all frameworks. Angular uses the
inject pattern instead of hooks.
React
const { data, isPending, error } = useQuery(...)Angular
todos = injectQuery(...)injectQuery instead of useQuery — Angular's dependency injection pattern
replaces hook conventions.
Vue
const { data, isPending, error } = useQuery(...)Svelte
const todos = createQuery(...)Solid
const todos = createQuery(...)The full component body — query, loading check, error check, render — is exactly the "Same list" example from The Baseline section, mapped onto the syntax in sections 1–9.
One-time setup — the provider
The one piece the per-concept sections don't cover is the one-time provider setup at the app root. You wire it once and forget it — and it's the one part that genuinely differs per framework:
// React — main.tsx (wrapper component)
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient();
createRoot(document.getElementById("root")!).render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>,
);// Angular — app.config.ts (DI provider, not a wrapper component)
import {
provideTanStackQuery,
QueryClient,
} from "@tanstack/angular-query-experimental";
export const appConfig = {
providers: [provideTanStackQuery(new QueryClient())],
};// Vue — main.ts (registered as a plugin on the app instance)
import { VueQueryPlugin } from "@tanstack/vue-query";
createApp(App).use(VueQueryPlugin).mount("#app");Same idea three ways — a wrapper component (React), a DI provider (Angular), a
plugin (Vue). Svelte and Solid follow React's shape with a QueryClientProvider
equivalent near the root.
11. Client State
TanStack Query handles server state — data fetched from a backend. Client state is UI state that lives only in the browser: selected tab, modal open/closed, sidebar collapsed, filter selection.
For state inside a single component, useState / ref / signal is enough.
When multiple unrelated components need the same state, reach for a dedicated
store.
React — Zustand
import { create } from "zustand";
type FilterStore = {
filter: "all" | "done" | "pending";
setFilter: (f: FilterStore["filter"]) => void;
};
export const useFilterStore = create<FilterStore>((set) => ({
filter: "all",
setFilter: (filter) => set({ filter }),
}));// in any component — no Provider wrapper needed
const filter = useFilterStore((s) => s.filter);
const setFilter = useFilterStore((s) => s.setFilter);Only the slice you select causes a re-render. Zustand is a module-level
singleton — no Provider required, unlike Redux or Context.
Angular — Services + Signals
Angular's answer is a singleton service exposing signals. No third-party library needed.
@Injectable({ providedIn: 'root' })
export class FilterService {
filter = signal<'all' | 'done' | 'pending'>('all')
setFilter(f: typeof this.filter()) { this.filter.set(f) }
}// in any component
private filterService = inject(FilterService)
// template: {{ filterService.filter() }}providedIn: 'root' makes it a singleton — one instance shared across the whole
app.
Vue — Pinia
import { defineStore } from "pinia";
export const useFilterStore = defineStore("filter", {
state: () => ({ filter: "all" as "all" | "done" | "pending" }),
actions: {
setFilter(f: "all" | "done" | "pending") {
this.filter = f;
},
},
});// in any component
const store = useFilterStore();
store.filter; // reactive — reads in template auto-update
store.setFilter("done");Pinia is Vue's semi-official state library — it replaced Vuex. For Svelte, built-in writable stores cover most client state needs without a third-party lib.
12. Reading URL Params (Details Page)
Read ID from URL → Fetch single todo → Render detailsThe fetch-and-render logic is identical to the list page. The only thing that changes per framework is how you read the route param. That one line:
| Framework | Read the param |
|---|---|
| React (TanStack Router) | const { todoId } = Route.useParams() — type-safe, inferred from $todoId.tsx |
| React (React Router) | const { id } = useParams() |
| Angular | inject(ActivatedRoute).snapshot.paramMap.get("id") |
| Vue | useRoute().params.id |
| Svelte | $page.params.id (from $app/stores) |
| Solid | useParams().id |
Everything else — useQuery(['todo', id], …), the loading/error/render branches
— is exactly the list page from Fetching Data with the id threaded into the
query key and URL. React + TanStack Router is the only one giving you fully
type-safe params with no runtime casting, inferred straight from the filename.
Folder Structures
How each framework typically organizes a real project. (Meta-framework layouts — Nuxt, SvelteKit, SolidStart — follow the file-based routing trees already shown in the Routing section.)
React + Vite (SPA, code-based routing)
No opinion enforced — this is a common convention:
src/
├─ components/ ← shared UI components
│ └─ TodoItem.tsx
├─ pages/ ← one file per route (by convention only)
│ ├─ TodoList.tsx
│ └─ TodoDetails.tsx
├─ App.tsx ← route config (React Router) lives here
└─ main.tsx
React + TanStack Router (file-based routing)
src/
├─ routes/
│ ├─ __root.tsx ← root layout
│ ├─ index.tsx → /
│ └─ todos/
│ ├─ index.tsx → /todos
│ └─ $todoId.tsx → /todos/123
├─ components/
└─ main.tsx
TanStack Router generates a routeTree.gen.ts file automatically — route types
are inferred from the file structure.
Angular
Slightly more files per feature due to class + template separation:
src/
├─ app/
│ ├─ pages/
│ │ ├─ todos/
│ │ │ ├─ todos.component.ts ← class + template (standalone)
│ │ │ └─ todos.component.html ← can be inline or separate
│ │ └─ todo-details/
│ │ └─ todo-details.component.ts
│ ├─ app.routes.ts ← route config
│ ├─ app.config.ts ← providers (QueryClient, HttpClient)
│ └─ app.component.ts ← root component
└─ main.ts
The @Component decorator ties everything together. Template can be inline
(template: \...`) or in a separate.htmlfile (templateUrl`).
Vue + Vite (with Vue Router)
src/
├─ components/ ← shared components
│ └─ TodoItem.vue
├─ views/ ← page-level components (convention: views/ not pages/)
│ ├─ TodoList.vue
│ └─ TodoDetails.vue
├─ router/
│ └─ index.ts ← route config
├─ stores/ ← Pinia stores (state management)
│ └─ todos.ts
└─ main.ts
What Actually Changes Between Frameworks?
Much less than it seems.
| Concept | React | Angular | Vue | Svelte | Solid |
|---|---|---|---|---|---|
| Component | function | class | template | file | function |
| Props | destructured | @Input() | defineProps<>() | export let | props.x |
| Lifecycle | useEffect | ngOnInit/OnDestroy | onMounted/watch | onMount/$: | createEffect |
| Lists | map | @for (track req.) | v-for | each | For |
| Conditions | if | @if / @else if | v-if | if | Show |
| State | useState | signal/property | ref | variable | signal |
| Routing | Router | Router (built-in) | Router | File System | Router |
| Fetching | Query | injectQuery | Query | Query | Query |
The syntax changes. The architecture rarely does.
Under the hood — where they genuinely differ
The table above is the surface. The one place architecture does differ is how each framework tracks a change and updates the DOM:
- React — re-runs the whole component function, then diffs a virtual DOM to
find what changed. Without
memo/useMemo, parent re-renders can cascade to children. - Vue — runtime dependency tracking via JavaScript Proxies. When a reactive value changes, Vue knows exactly which components accessed it and re-renders only those. Within each re-render, a virtual DOM diff patches only the changed nodes. More surgical than React at the component level, no manual memoization needed.
- Svelte — compile-time analysis. The compiler reads your
letvariables and generates JavaScript that directly targets the affected DOM nodes. No virtual DOM, no runtime tracking overhead. - Angular signals / Solid — runtime signals with no virtual DOM at all. When a signal changes, only the specific DOM expressions that read it update — no component re-run, no diffing.
Same mental model on the surface; different machinery underneath.
What a senior engineer actually thinks in
A senior frontend engineer primarily thinks in terms of:
- State
- Data flow
- Rendering
- Side effects
- Routing
- Async data
The framework is mostly syntax around those ideas — which is exactly why the real differences between frameworks live in the layers below, not the syntax above.
Library vs Framework — The Ecosystem Dimension
Now that you've seen the pieces — routing, client state, fetching, forms — here's the dimension that actually separates these tools: how complete each one is out of the box. This is where "which framework?" stops being about syntax.
The spectrum
Angular = batteries included ("kitchen sink" framework). You pick Angular, you pick the whole stack — routing, forms, HTTP, DI system, CLI, testing utilities are all official, all one team, all versioned together. No assembly required.
Vue = curated middle ground. The core library only handles rendering. Vue Router and Pinia are semi-official and used on almost every project — the ecosystem is guided but not locked in.
React = technically just a UI library. It renders components — nothing else. Everything else is your choice.
Solid / Svelte = closer to React's end — UI libraries with their own growing ecosystems (SolidStart, SvelteKit).
Ecosystem comparison
Same concerns, different answers depending on where on the spectrum you land:
| Concern | Angular | Vue | React |
|---|---|---|---|
| Routing | @angular/router (built-in) |
Vue Router (semi-official) | TanStack Router, React Router |
| Client state | Services + Signals (built-in) | Pinia (semi-official) | Zustand, Redux Toolkit |
| Async / server data | HttpClient (built-in), TanStack Query |
TanStack Query | TanStack Query |
| Forms | ReactiveFormsModule (built-in) |
VeeValidate, FormKit | React Hook Form |
| Testing | Karma/Jasmine or Vitest, official utilities | Vitest + Vue Test Utils | Vitest/Jest + Testing Library |
| CSS | Component styles (ViewEncapsulation), Tailwind | <style scoped>, Tailwind |
Tailwind, CSS Modules, styled-components |
| Full-stack | Analog | Nuxt | Next.js, TanStack Start |
| Tooling | Angular CLI (official) | npm create vue@latest + Vite |
Vite (npm create vite@latest) |
Built-in = part of the Angular package, no extra install. Semi-official = separate package but maintained/recommended by the Vue core team. React = community choice, no official recommendation. (Playwright/Cypress cover end-to-end testing across all of them — framework-agnostic.)
CSS notes: Tailwind works in all three frameworks — it's just utility
classes in HTML, framework-agnostic. styled-components is primarily a React
ecosystem pattern; Vue has a vue-styled-components port but it's rarely used
(Vue's <style scoped> already solves the scoping problem natively).
styled-components is not a common pattern in Angular at all.
Why this matters
Angular developers coming to React are often confused not by JSX or hooks — but by the absence of official answers to questions Angular already answered. "Which router?" "Which state lib?" "How do I do HTTP?" These aren't React problems; they're ecosystem-assembly problems.
Vue sits closer to Angular's comfort zone: answers are recommended but not locked in. Coming from Angular, Vue is the gentler transition.
Which Framework Should You Learn?
This guide stays agnostic on syntax, but the "which one?" question is real and deserves an honest answer. None of these are wrong choices — but they aren't interchangeable for a career either.
| Framework | Job market | Learning curve | Picks itself when… |
|---|---|---|---|
| React | Largest by far; most listings, most startups | Moderate — JS-heavy, you assemble the stack yourself | You want maximum optionality, the biggest hiring pool, and React Native for mobile |
| Angular | Strong in enterprise / DACH / banking / gov | Steepest — DI, RxJS, classes, many concepts, but one official way | A large org standardizes and wants consistency over flexibility (very common in Austrian enterprise) |
| Vue | Solid in EU SMBs and Asia | Gentlest — readable, batteries-ish without Angular's weight | You're solo or a small team and want good DX fast; also the softest jump from Angular |
| Svelte | Smaller but well-loved | Easiest to read; least boilerplate | Personal projects, content sites, or when bundle size and simplicity matter most |
| Solid | Smallest / niche | Easy if you know React (near-identical syntax) | You like React's model but want fine-grained reactivity and raw performance |
For Austrian/DACH enterprise specifically: Angular dominates, React is a strong second. For self-employment / solo products, React or Vue give the fastest path to shipping. The honest meta-point: team familiarity usually outweighs framework characteristics — the best framework is the one the people around you already know.
Two things worth knowing beyond the table. Angular's real advantage is enforced uniformity, not features — because it ships the whole stack, 100 Angular codebases look alike, which is exactly what a large org optimizing for onboarding and multi-year maintenance wants. And "technically best" rarely wins: Solid is arguably the cleanest design of the bunch (fine-grained reactivity, no VDOM, JSX without React's re-render footguns), but knowledge rate, ecosystem maturity, and tooling/LLM support compound in React's favor — so the framework that ships the product usually beats the one that benchmarks best.
Meta-Frameworks
Each framework has a meta-framework layer that adds routing, SSR/SSG, and server-side data loading on top of the base library:
| Meta-Framework | Base | Main use case |
|---|---|---|
| Next.js | React | SSR, SSG, RSC, full-stack React apps |
| TanStack Start | React | SSR + file-based routing (TanStack ecosystem) |
| Nuxt | Vue | SSR, SSG, Vue full-stack apps |
| SvelteKit | Svelte | File-based routing + SSR (built-in to Svelte) |
| Analog | Angular | SSR + file-based routing for Angular |
| SolidStart | Solid | SSR + routing for Solid |
Meta-frameworks answer: where does rendering happen — browser, server, or at build time? (CSR vs SSR vs SSG vs RSC.)
TanStack — A Cross-Framework Ecosystem
TanStack (by Tanner Linsley) stands out because most of its tools work across multiple frameworks with the same API shape. Learning TanStack Query in React transfers directly to Vue, Svelte, Solid, and Angular.
| Tool | Frameworks supported | Purpose |
|---|---|---|
| TanStack Query | React, Vue, Solid, Svelte, Angular | Async state / server data |
| TanStack Router | React (Solid in progress) | Type-safe routing, file-based |
| TanStack Table | React, Vue, Solid, Svelte, Angular | Headless data tables |
| TanStack Form | React, Vue, Solid, Svelte, Angular | Form state management |
| TanStack Start | React | Full-stack framework |
The pattern you learn in React (useQuery, createRoute) transfers directly to
Vue (useQuery), Svelte (createQuery), Solid (createQuery) — same mental
model, same API shape, different import path.
This is also a genuine step toward reducing framework lock-in. The hard, high-value problems — server-state caching, type-safe routing, headless tables, form state — get solved once and shared across React, Vue, Angular, Svelte, and Solid (Query supports all five; Router covers React with Solid in progress). The more of your stack lives in TanStack rather than your framework, the cheaper it is to switch frameworks later — and the more "which framework?" shrinks to "which rendering dialect?"
Styling
How each framework handles the TypeScript + CSS + HTML combination. Two common approaches dominate: scoped CSS (framework-native) and utility-first (Tailwind).
Scoped CSS (framework-native)
Each framework offers component-scoped styles so class names don't leak.
React — CSS Modules:
// TodoItem.module.css
.title { font-weight: bold; color: #333; }
// TodoItem.tsx
import styles from './TodoItem.module.css'
export function TodoItem({ title }: { title: string }) {
return <li className={styles.title}>{title}</li>
}Angular — component styles (scoped by default):
@Component({
selector: "app-todo-item",
standalone: true,
template: `<li class="title">{{ title }}</li>`,
styles: [
`
.title {
font-weight: bold;
color: #333;
}
`,
],
})
export class TodoItemComponent {
@Input() title = "";
}Angular uses ViewEncapsulation to scope styles — no class name collisions by default.
Vue — <style scoped>:
<template>
<li class="title">{{ title }}</li>
</template>
<script setup lang="ts">
defineProps<{ title: string }>();
</script>
<style scoped>
.title {
font-weight: bold;
color: #333;
}
</style>scoped adds a data attribute to elements at compile time — styles only apply
to this component.
Svelte — <style> (scoped by default):
<script lang="ts">
export let title: string
</script>
<li class="title">{title}</li>
<style>
.title { font-weight: bold; color: #333; }
</style>Svelte scopes styles automatically — no scoped keyword needed.
Tailwind CSS (utility-first)
Tailwind works identically across all frameworks — add utility classes to markup. No framework-specific API.
// React
<li className="font-bold text-gray-800 hover:text-blue-600">{title}</li><!-- Angular / Vue -->
<li class="font-bold text-gray-800 hover:text-blue-600">{{ title }}</li>Tailwind is configured once in tailwind.config.ts — then it's just HTML
classes everywhere. No scoping needed since utilities are global by design.
Component Libraries
Most production apps use a component library for accessible UI primitives (modals, dropdowns, date pickers, data tables) that would take weeks to build correctly from scratch. They fall on a spectrum from unstyled to fully opinionated:
Radix / Reka / Kobalte / CDK → shadcn & ports (React/Vue/Svelte; Solid, Angular community) → Material UI / Angular Material / Vuetify
no styles minimal, owned opinionated, locked
max flexibility great base layer fastest to start
| Library | Framework | Style model |
|---|---|---|
| shadcn/ui | React | Copy-paste source (Radix / Base UI + Tailwind) |
| Material UI (MUI) | React | Styled, Material Design |
| Mantine / Chakra / Ant | React | Styled, opinionated |
| Angular Material | Angular | Styled, Material Design (official Google lib) |
| Angular CDK | Angular | Headless primitives you style yourself |
| spartan/ui | Angular | shadcn-style copy-paste (Angular CDK + Tailwind) |
| shadcn-vue | Vue | Copy-paste source (Reka UI + Tailwind) |
| Vuetify / PrimeVue | Vue | Styled, opinionated |
| shadcn-svelte | Svelte | Copy-paste source (Bits UI + Tailwind) |
| solid-ui | Solid | Copy-paste source (Kobalte + Tailwind) |
The shadcn difference — you own the code. Most libraries here are npm
packages: you install them and customize only through the surface the authors
expose (props, theme tokens, overrides). shadcn inverts this — its CLI copies
the component source straight into your repo (npx shadcn@latest add dialog
drops dialog.tsx into your codebase, now your file). Accessibility comes from
the headless primitive underneath (Radix / Base UI), styling is plain Tailwind
in a file you own, and there's no package boundary to fight — at the cost of
owning maintenance yourself (no npm update upgrades). The model has spread
beyond React: shadcn-vue and shadcn-svelte are official; solid-ui and spartan/ui
(Angular) are community ports wrapping each framework's headless primitives. In
Angular the mainstream pick is still Angular Material; spartan is the niche
shadcn-style alternative.
Rule of thumb: reach for headless + Tailwind (shadcn / CDK) when you need a design system that looks like yours and you want full control of the source. Reach for fully styled npm libraries (Material UI, Vuetify) when Material Design is close enough and you want to move fast — accepting that customization stops at their props and you'll hit friction when your design diverges from their assumptions.
Knowing Tailwind pays off either way — shadcn and most modern kits use it under the hood, and it's the utility framework LLMs are best trained on, which makes a Tailwind + shadcn stack the fastest path when building with AI assistance. Angular Material is the main exception: it uses its own theming system, though Tailwind can coexist alongside it.
Starter Projects
Official starters and example repos for each framework. Use these to bootstrap or as reference for idiomatic patterns.
| Framework | Starter command / repo |
|---|---|
| React + Vite | npm create vite@latest my-app -- --template react-ts |
| React + TanStack | TanStack/router on GitHub → examples/react/ (basic, file-based, etc) |
| Angular | npm create @angular/cli my-app (official Angular CLI) |
| Vue + Vite | npm create vue@latest (create-vue, the official scaffolding) |
| Nuxt | npx nuxi@latest init my-app |
| SvelteKit | npx sv create my-app |
| SolidStart | npm create solid@latest → select SolidStart |
| TanStack Start | TanStack/start on GitHub → examples/ directory |
All React + TanStack examples use jsonplaceholder.typicode.com as a free
public API — useful for the todo app exercise with no backend needed.
Going Deeper
This guide is orientation, not a complete reference. Once you know your framework, go deep with its dedicated roadmap.
Roadmaps for the three most used
| Framework | Roadmap |
|---|---|
| React | roadmap.sh/react |
| Angular | roadmap.sh/angular |
| Vue | roadmap.sh/vue |
| Frontend (general) | roadmap.sh/frontend — JS/TS/CSS fundamentals, start here if gaps are broad |
Svelte and Solid don't have dedicated roadmap.sh entries yet — the official docs are the best next step (svelte.dev, solidjs.com).
Suggested paths by goal
"I'm learning FE from scratch or filling fundamentals gaps" Go through
roadmap.sh/frontend, mark what you know vs. don't. Build the vanilla todo app
(The Baseline section). Pick one framework and go deep with its roadmap.
"I know Angular and need to learn Vue" Read Library vs Framework → skim the
concept sections (1–12) for the Angular→Vue mapping → roadmap.sh/vue for
depth. The Composition API and Pinia are the biggest adjustments.
"I need to pick a framework for a new project" Read "Which Framework Should You Learn?" → Library vs Framework → weigh team familiarity heavily, it usually outweighs framework characteristics.
"I want to understand the full ecosystem, not just my framework" Read this whole guide → Meta-Frameworks section for SSR/SSG options.