The same small app in Vue, Preact (React's small cousin) and Svelte: state, worked-out values, events and showing data are the same ideas in all three; only the syntax and the update rules differ.
Job adverts ask for React, Vue or Svelte. Learn the ideas once, and each framework is a new way of writing them.
Vue is one of several frameworks that keep a page in step with its data. The best known is React; here we use Preact, a small version of React with the same way of writing (about 15 KB, served beside the page). Svelte works differently: its compiler turns your component into plain JavaScript before it runs.
The same app, three ways:
| Idea | Vue | Preact (like React) | Svelte |
|---|---|---|---|
| Data that changes | ref([]) |
useState([]) |
$state([]) |
| A worked-out value | computed(() => …) |
a plain line, stars.length |
$derived(…) |
| An event | @click="add" |
onClick=${add} |
onclick={add} |
| Showing data | {{ count }} |
${count} |
{count} |
Two recipes to copy, not to learn: Preact's page is written inside html`…` (htm, a way to write HTML-like tags in JavaScript), and the Svelte component sits in a hidden <textarea data-svelte>, where svelte.js finds it. In a real project a Svelte component is its own file, App.svelte. stars.join("") puts the list's items together as one piece of text.
Make your prediction first. Help opens once you've locked it in.
<h2>Vue</h2>
<div id="vue-app">
<button @click="add">Add a star</button>
<p>Stars: {{ count }} {{ stars.join("") }}</p>
</div>
<script src="vue.js"></script>
<script>
const { createApp, ref, computed } = Vue;
createApp({
setup() {
const stars = ref([]);
const count = computed(() => stars.value.length);
function add() {
stars.value.push("⭐");
}
return { stars, count, add };
}
}).mount("#vue-app");
</script>
<h2>Preact</h2>
<div id="preact-app"></div>
<script src="preact.js"></script>
<script>
const { html, render, useState } = htmPreact;
function App() {
const [stars, setStars] = useState([]);
const count = stars.length;
function add() {
setStars([...stars, "⭐"]);
}
return html`
<button onClick=${add}>Add a star</button>
<p>Stars: ${count} ${stars.join("")}</p>`;
}
render(html`<${App} />`, document.querySelector("#preact-app"));
</script>
<h2>Svelte</h2>
<textarea data-svelte hidden>
<script>
let stars = $state([]);
let count = $derived(stars.length);
function add() {
stars.push("⭐");
}
</script>
<button onclick={add}>Add a star</button>
<p>Stars: {count} {stars.join("")}</p>
</textarea>
<script src="svelte.js"></script>
Read all three panels. Which lines keep the data, work out the count, and handle the click?
Press Run. Svelte takes a moment longer to start: its compiler (about 1 MB) turns the component into JavaScript first.
<h2>Vue</h2>
<div id="vue-app">
<button @click="add">Add a star</button>
<p>Stars: {{ count }} {{ stars.join("") }}</p>
</div>
<script src="vue.js"></script>
<script>
const { createApp, ref, computed } = Vue;
createApp({
setup() {
const stars = ref([]);
const count = computed(() => stars.value.length);
function add() {
stars.value.push("⭐");
}
return { stars, count, add };
}
}).mount("#vue-app");
</script>
<h2>Preact</h2>
<div id="preact-app"></div>
<script src="preact.js"></script>
<script>
const { html, render, useState } = htmPreact;
function App() {
const [stars, setStars] = useState([]);
const count = stars.length;
function add() {
setStars([...stars, "⭐"]);
}
return html`
<button onClick=${add}>Add a star</button>
<p>Stars: ${count} ${stars.join("")}</p>`;
}
render(html`<${App} />`, document.querySelector("#preact-app"));
</script>
<h2>Svelte</h2>
<textarea data-svelte hidden>
<script>
let stars = $state([]);
let count = $derived(stars.length);
function add() {
stars.push("⭐");
}
</script>
<button onclick={add}>Add a star</button>
<p>Stars: {count} {stars.join("")}</p>
</textarea>
<script src="svelte.js"></script>
<h2>Vue</h2>
<div id="vue-app">
<button @click="add">Add a star</button>
<p>Stars: {{ count }} {{ stars.join("") }}</p>
</div>
<script src="vue.js"></script>
<script>
const { createApp, ref, computed } = Vue;
createApp({
setup() {
const stars = ref([]);
const count = computed(() => stars.value.length);
function add() {
stars.value.push("⭐");
}
return { stars, count, add };
}
}).mount("#vue-app");
</script>
<h2>Preact</h2>
<div id="preact-app"></div>
<script src="preact.js"></script>
<script>
const { html, render, useState } = htmPreact;
function App() {
const [stars, setStars] = useState([]);
const count = stars.length;
function add() {
setStars([...stars, "⭐"]);
}
return html`
<button onClick=${add}>Add a star</button>
<p>Stars: ${count} ${stars.join("")}</p>`;
}
render(html`<${App} />`, document.querySelector("#preact-app"));
</script>
<h2>Svelte</h2>
<textarea data-svelte hidden>
<script>
let stars = $state([]);
let count = $derived(stars.length);
function add() {
stars.push("⭐");
}
</script>
<button onclick={add}>Add a star</button>
<p>Stars: {count} {stars.join("")}</p>
</textarea>
<script src="svelte.js"></script>
Twelve pieces, four ideas.
A different update rule.
The catch in React and Preact.
Sort each piece of code by the idea it is.
In this Preact app, Add a star does nothing: the count stays at 0. There is no error. Click the two lines that are wrong.
Its own way to write v-if.
Svelte's v-if is a block: {#if …} at the start and {/if} at the end.
{#if count >= 5} then a paragraph, then {/if}, under the count.

A new, empty list.
In Preact you never change the list: you give setStars a new one.
A clear function that runs setStars([]), and a button with onClick=${clear}.

In Svelte, show That's a lot of stars! when there are 5 or more.
This ticks when your page has all of the above.
In Preact, add a Clear button.
This ticks when your page has all of the above.
Add a Remove one button to all three apps. In Vue and Svelte, pop() takes the last item off the list. In Preact, give setStars a new list without the last item: stars.slice(0, -1). Which was easiest? Which was clearest to read?
You have seen the same ideas in three frameworks. Real projects use a build tool and one file per component; the ideas stay the same.
Students predict that three versions of the same app (Vue, Preact, Svelte) behave alike, sort each piece of code by the idea it is, find React's update catch in Preact, add an if in Svelte and a Clear button in Preact, and add one feature to all three. About 50 minutes.
Open the teacher guide: answers, solutions, slides and a worksheet.
An optional lesson for students who finished the to-do app. The point: components, state, derived (worked-out) values and events are the same ideas in every framework; only the syntax and the update rules differ.
useState). Its page is written with htm, a way to write HTML-like tags in a JavaScript string, because real React uses JSX, which needs a build step. Its update rule: the component function runs again after every change, so a plain const count = stars.length is always right, but you must replace data, never change it in place. stars.push(...); setStars(stars) gives Preact the same list, so it skips the update: the planted bug, and the most common React mistake.$state and $derived are its "runes". Its data can be changed in place (stars.push). Here the compiler runs in the browser (about 1 MB, loaded only by this lesson), reading the component from a hidden <textarea data-svelte>; in a real project it is a file called App.svelte, compiled by a build tool.Every library is served from this site, not a CDN.
The three spellings blur together. Keep returning to the four ideas: ask "which line is the data? the worked-out value? the event?" in each panel.
Stretch: Make each framework's star list into a component of its own. Find the same app in React on react.dev and compare it with the Preact version.
Support: Do the sort together on the board; students do only the Svelte if.
Real projects: npm create vue@latest (Vue), Vite with React, or npx sv create (Svelte), each with one file per component. Class discussion: why might a team choose each one (popularity and jobs, simplicity, speed)?