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:


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

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:

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 data

0. 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:

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

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 + 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).


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


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


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


5. 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.


6. 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(...)

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 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 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 file

There'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:

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