LCM Logo

Svelte

A concept-first tour of Svelte — components, props, state, derived values, and effects — each idea explained before the code.

Svelte is a tool for building user interfaces out of components: small, reusable pieces of UI that describe what the screen should look like for a given set of data. Unlike most frameworks, Svelte is a compiler — it turns your components into small, fast vanilla JavaScript at build time, so there's no virtual DOM and very little runtime. The examples here use Svelte 5 and its modern reactivity system, runes.

Svelte on its own handles the UI layer. For routing, data fetching, and a production build, it's usually paired with a framework — see the SvelteKit guide, which builds directly on these concepts.

Coming from React?

Most concepts map across, but the spelling differs:

  • useState → $state, and you assign (count++) instead of calling a setter.
  • useMemo / computed values → $derived.
  • useEffect → $effect, with no dependency array (Svelte tracks dependencies for you).
  • Props (function C({ name })) → let { name } = $props().
  • JSX {cond ? <A/> : <B/>} and list.map(...) → {#if} and {#each} template blocks.
  • Custom hooks → plain functions in a .svelte.ts module.
  • Controlled inputs (value + onChange) → bind:value.

The biggest shift: there's no virtual DOM and no re-running of the whole component. Svelte compiles your assignments into targeted DOM updates, so plain = is the whole reactivity story.

Components and markup

A component lives in a .svelte file and is mostly just HTML. A <script> block holds the component's logic, and the markup below it is plain HTML with {expressions} in curly braces. There's no return, no JSX — what you write is close to what the browser gets.

Greeting.svelte
<script lang="ts">
  let name = "world";
</script>

<h1>Hello, {name}</h1>
App.svelte
<script lang="ts">
  import Greeting from "./Greeting.svelte";
</script>

<Greeting />

Props: passing data in

Props are the inputs to a component — values passed from parent to child, just like attributes on an HTML element. In Svelte 5 you declare them with the $props rune, destructuring the fields you expect.

Greeting.svelte
<script lang="ts">
  let { name }: { name: string } = $props();
</script>

<h1>Hello, {name}</h1>
App.svelte
<Greeting name="Ada" /> <!-- renders "Hello, Ada" -->

That declaration packs two jobs into one line:

  • { name } is JavaScript destructuring. $props() returns an object of all the props passed in — here { name: "Ada" } — and { name } reaches into it and pulls the name field out into its own variable. To give a prop a default, write { name = "world" }.
  • : { name: string } is a TypeScript type annotation. It isn't code that runs; it tells the type checker "this component expects a prop called name, and it must be a string." In plain JavaScript you'd drop it and write let { name } = $props();.

State: data that changes over time

State is data a component owns and can change in response to interaction. The $state rune makes a variable reactive: assign to it with plain =, and Svelte updates every part of the UI that reads it. There's no setter function — assignment is the update.

Counter.svelte
<script lang="ts">
  let count = $state(0);
</script>

<button onclick={() => count++}>
  Clicked {count} times
</button>

This is the big difference from React: count++ is the state update. Svelte's compiler rewrites that assignment into the code that re-renders the button, so you work with ordinary variables instead of a [value, setValue] pair. The same goes for objects and arrays — push to a reactive array or set a property and the UI follows.

Handling events

Event handlers are functions you attach with on-prefixed attributes like onclick or oninput. They receive a normal DOM event and typically update state, which re-renders the affected markup.

Toggle.svelte
<script lang="ts">
  let on = $state(false);
</script>

<button onclick={() => (on = !on)}>{on ? "ON" : "OFF"}</button>

The on = !on part is worth a beginner's pause: !on is JavaScript's "not" operator flipping the boolean, so true becomes false and back again. Because on is $state, the plain assignment is all Svelte needs to update the button text — no function form, no setter.

Rendering lists

To render a collection, use an {#each} block. Give each item a unique key in parentheses — a real id where you can — so Svelte can track items efficiently across updates.

PenguinList.svelte
<script lang="ts">
  let { penguins }: { penguins: { id: number; species: string }[] } = $props();
</script>

<ul>
  {#each penguins as p (p.id)}
    <li>{p.species}</li>
  {/each}
</ul>

Conditional rendering

Svelte uses {#if} blocks for conditional markup, with optional {:else if} and {:else} branches. Unlike React's ternaries, this is real template syntax, so the markup stays readable.

Status.svelte
<script lang="ts">
  let { loading, count }: { loading: boolean; count: number } = $props();
</script>

{#if loading}
  <p>Loading…</p>
{:else}
  <p>{count} results</p>
{/if}

{#if count === 0}
  <p>No matches.</p>
{/if}

Derived values

When a value can be computed from other state, don't store it separately — derive it. The $derived rune recalculates automatically whenever the state it reads changes, so it's always in sync and never goes stale.

Doubler.svelte
<script lang="ts">
  let count = $state(2);
  let doubled = $derived(count * 2);
</script>

<button onclick={() => count++}>
  {count} doubled is {doubled}
</button>

doubled is read-only — you never assign to it. Each time count changes, Svelte re-runs the expression. This is the rune you'll reach for far more often than $effect: prefer deriving a value over computing it inside an effect.

Side effects with $effect

Most reactivity in Svelte is handled by $state and $derived. For the rest — anything that reaches outside the component (timers, subscriptions, manual DOM work, logging) — use the $effect rune. It re-runs whenever the reactive values it reads change, and you return a cleanup function to undo the effect.

Clock.svelte
<script lang="ts">
  let now = $state(new Date());

  $effect(() => {
    const id = setInterval(() => (now = new Date()), 1000);
    return () => clearInterval(id); // cleanup when the component is destroyed
  });
</script>

<time>{now.toLocaleTimeString()}</time>

Notice there's no dependency array: Svelte tracks dependencies automatically by watching which reactive values the effect reads. Reach for $effect only when you genuinely need to step outside Svelte — for deriving data, use $derived instead.

Reusing logic with .svelte.ts modules

When several components need the same reactive logic, move it into a .svelte.ts module — an ordinary TypeScript file where runes are allowed. Export a function that creates and returns the state, and each caller gets its own independent copy.

toggle.svelte.ts
export function createToggle(initial = false) {
  let on = $state(initial);
  return {
    get on() {
      return on;
    },
    toggle: () => (on = !on),
  };
}
Panel.svelte
<script lang="ts">
  import { createToggle } from "./toggle.svelte.ts";
  const t = createToggle();
</script>

<button onclick={t.toggle}>{t.on ? "Hide" : "Show"}</button>

Two pieces are easy to trip over:

  • initial = false is a default parameter: createToggle() starts off, createToggle(true) starts on.
  • The get on() getter is what keeps reactivity working across the module boundary. If you returned { on } directly, you'd copy the boolean's value once and lose the live connection. A getter re-reads the $state variable each time it's accessed, so t.on always reflects the current value.

Two-way binding for forms

Svelte's bind:value makes an input and a state variable mirror each other automatically — type in the box and the variable updates, change the variable and the box updates. It replaces the value/onChange pair you'd write by hand elsewhere.

NameField.svelte
<script lang="ts">
  let name = $state("");
</script>

<input bind:value={name} />
<p>Hello, {name || "stranger"}</p>

Runes only run where Svelte compiles

Runes like $state, $derived, and $effect aren't imported — they're keywords the Svelte compiler recognizes inside .svelte files and .svelte.ts/.svelte.js modules. Use a rune in a plain .ts file and it won't work, because that file never goes through the compiler. This is the detail that most often trips up newcomers moving logic out of components.