Contents

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:

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:

  1. Display data
  2. Render lists
  3. Render conditionally
  4. Handle user input
  5. Navigate between pages
  6. Load data from a server
  7. 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 data

Keep 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:

Use a framework when:


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 + UI

React

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 } = props breaks reactivity. Always access as props.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 render

The 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 template

Svelte — $: 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 UI

React

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 component

SPA ≠ 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.

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 → Render

TanStack 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 details

The 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:

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:

  1. State
  2. Data flow
  3. Rendering
  4. Side effects
  5. Routing
  6. 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.