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?" below
- Why frameworks at all: "The Baseline — Vanilla JS" (Section 0)
- Syntax lookup: Jump to any numbered section, 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
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 |
Library vs Framework — The Ecosystem Dimension
A critical distinction before touching syntax: these tools are not equally "complete."
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 |
| 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.
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.
Reactive state — the shared concept
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 vanilla JS does not give you. In vanilla JS, a 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 that any part of the DOM depends on count. You have to manually 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 mechanics differ under the hood:
- 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 underneath — declare state, describe UI that depends on it, let the framework keep them in sync.
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.
Angular's real moat is enforced uniformity, not features. Because it ships the whole stack, 100 Angular codebases look the same — routing, DI, forms, and state all done the one official way. React's "common stack" is convention, so it drifts: one team Zustand, another Redux; React Router here, TanStack there. For a large org optimizing onboarding and multi-year maintenance over raw velocity, that sameness is the feature — the same bet Go makes, and a real reason it sticks in enterprise. Note the signals upgrade closes Angular's old performance gap mostly against Solid and Vue, not React; it doesn't add an edge on the axes React already wins (velocity, hiring pool, LLM assistance).
"Best framework" ≠ "right pick." Technically, Solid is arguably the cleanest of the bunch — fine-grained reactivity, no VDOM, JSX without React's re-render footguns. But "technically best" loses to three things that compound: knowledge rate (how many devs already know it — React's lead here would take years to close), ecosystem maturity, and LLM training bias (models are far more fluent in React, which now actively shapes which stack is fastest to build in). Solid becomes a rational pick when all three stop mattering to you: LLM assistance is good enough for your needs, you don't care how many other devs know it, and its ecosystem already covers what you build. Until then, "the best framework" and "the framework that gets the product shipped" are different questions — and most of the time the second one wins.
The Example App
We'll use a minimal Todo app to show each concept:
Todo List Page
Todos
------------------
Buy milk
Learn Angular
Build app
Todo Details Page
Todo #1
Title: Buy milk
Completed: No
[Back]
Universal Mental Model
Every data-fetching page follows the same pattern, regardless of framework:
Load data
↓
Loading?
↓
Error?
↓
Render data0. The Baseline — Vanilla JS, and Why Frameworks Exist
Before frameworks: the DOM API and manual wiring. Building the todo app without any framework is instructive — every line of pain here is a line a framework removes.
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 |
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–8) 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 |
Vite solves all of this.
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) |
| JSX/TSX | Transformed to React.createElement() (or Solid/Preact equivalent) |
.vue/.svelte |
SFCs compiled to plain JS + scoped CSS |
| Production build | Bundles + minifies + tree-shakes via Rollup → dist/ folder |
Vite across frameworks
Vite is framework-agnostic — a build tool, not a UI library:
| Framework | Default build tool |
|---|---|
React (create vite) |
Vite |
Vue (create-vue) |
Vite |
| Svelte | Vite |
| Solid | Vite |
| Angular | esbuild via Angular CLI (Vite-based builder available as opt-in) |
| Next.js | Turbopack (own bundler) |
| Nuxt | 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).
1b. 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.
1c. Child → Parent (Events & Callbacks)
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 4 is exactly what wires an @Output() to the parent.
1d. 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.
1e. 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.
2. 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).
3. 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>4. 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.
5. 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.
6. 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(...)6b. 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.
7. Putting It Together — One Complete Example
The sections above showed each concept in isolation. Here's how the pieces
assemble into one working file: fetch → loading → error → render. We show the
component body in React only — the assembly is structurally identical in every
framework (swap useQuery for injectQuery/createQuery, JSX for the template
syntax) — then show the one-time provider setup in all three of the top
frameworks below, since that's the one part that genuinely differs. The complete
details page is the same shape, with a URL param read added (see Section 8 for
how each framework reads the param).
fetch todos → if loading → if error → render list// TodoList.tsx
import { useQuery } from "@tanstack/react-query";
type Todo = { id: number; title: string };
export function TodoList() {
const { data, isPending, error } = useQuery<Todo[]>({
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((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
);
}The component body — query, loading check, error check, render — maps one-to-one to the syntax in Sections 1–6, so we don't repeat it per framework here. The one piece those sections don't cover is the one-time provider setup at the app root, and that genuinely differs. You wire it once and forget it:
// React — main.tsx
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 (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. This is the part worth seeing per-framework precisely
because it's the part Sections 1–6 leave out; everything else is the dialect
swap you've already met.
8. 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 Section 7 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.
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, and toward the neutrality this guide keeps pointing at. 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?" That's the direction the ecosystem is drifting, and it's a good one.
Folder Structures
How each framework typically organizes a real project.
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
Nuxt (file-based routing)
pages/
├─ index.vue → /
└─ todos/
├─ index.vue → /todos
└─ [id].vue → /todos/123
SvelteKit (file-based routing)
src/routes/
├─ +layout.svelte ← root layout
├─ +page.svelte → /
└─ todos/
├─ +page.svelte → /todos
└─ [id]/
└─ +page.svelte → /todos/123
SolidStart (file-based routing)
src/routes/
├─ index.tsx → /
└─ todos/
├─ index.tsx → /todos
└─ [id].tsx → /todos/123
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. Nearly every other library on this
list is an npm package: you npm install it and customize only through the
surface its authors expose — props, theme tokens, styled() overrides. If a
component doesn't bend the way you need, you're stuck working around it or
fighting CSS specificity. shadcn inverts this. Its CLI copies the component
source straight into your repo:
npx shadcn@latest add dialog
# → drops dialog.tsx into your codebase; it's now your fileThere's no package boundary. The accessibility comes from Radix/Base UI
underneath, the styling is plain Tailwind classes in a file you own — so you
edit markup, classes, and behavior directly with zero abstraction in the way.
That's why it's the natural foundation for a real design system: you get
accessible primitives + sane defaults, then shape everything into your product's
look. The tradeoff is you also own the maintenance (no automatic upgrades via
npm update).
shadcn isn't React-only anymore. The model has spread across the ecosystem: shadcn-vue and shadcn-svelte are official, while solid-ui (Solid) and spartan/ui (Angular) are community ports that copy the approach. Each wraps that framework's headless primitives (Reka UI, Bits UI, Kobalte, Angular CDK) in copy-paste Tailwind components — same idea everywhere: you own the source. In Angular the mainstream pick is still Angular Material; spartan is the niche shadcn-style alternative. It's the closest thing the ecosystem has to a cross-framework design-system convention — and another data point for this guide's neutrality thesis.
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. It's also the utility framework LLMs are by far the best trained on: ask any model for a component and it reaches for Tailwind classes fluently, which makes a Tailwind + shadcn stack the fastest path when you're 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.
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.
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.
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
(section 0 of this guide). Pick one framework and go deep with its roadmap.
"I know Angular and need to learn Vue" Read Library vs Framework → skim
sections 1–8 for 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 in this guide for SSR/SSG options.