Why Svelte 5?

Svelte 5 introduces significant improvements, making the framework faster, easier to use, and more robust. Key features include the new signal-powered reactivity API, better event handling, improved component composition, and native TypeScript support. For more details, visit the Svelte 5 Release Candidate blog post.

Improved Performance with Granular Reactivity

One of the standout features of Svelte 5 is its granular reactivity. By default, the $state rune in Svelte 5 tracks state changes precisely, updating only the necessary DOM nodes, thus enhancing performance.

Visual Comparison: Svelte 4 vs. Svelte 5

Let’s devise a way to make a visual representation of this.

A visual representation of fine-grained reactivity in Svelte 5

👉Only data for the circle inside white ring is modified
👉If a circle is re-rendered it becomes larger
Svelte 4
Svelte 5

We only update one item in our data array the circle inside the white ring by the updateState function which will enlarge its radius by 2. We are using another function to track whether any element is re-rendered by using getRadius function.

This function will increment a radius variable each time it is called.

var radius = 1;
var getRadius = () => ++radius;

The radius determines the radius of our svg circles, so if a circle is re-rendered it will have a bigger radius since radius value is incremented each time getRadius is called.

<circle cx="{d.x}" cy="{d.y}" r="{d.r" + getRadius()} fill="{d.fill}" />`

When you click on the update button in Svelte 4 version you will see that although we are only modifying the circle inside white ring all other circles also grow in size. This is because they are re-rendered although the underlying data has not changed.

<!-- svelte 4 version -->
<script>
  var radius = 1;
  var getRadius = () => ++radius;

  let data = [
    { x: 180, y: 30, r: 1, fill: 'orange' },
    { x: 100, y: 40, r: 0, fill: 'red' },
    { x: 170, y: 160, r: 0, fill: 'cyan' },
    { x: 120, y: 220, r: 0, fill: 'yellow' },
    { x: 30, y: 100, r: 0, fill: 'fuchsia' }
  ];

  function updateState() {
    data[0].r = data[0].r + 4;
  }

  function reset() {
    data = [
      { x: 180, y: 30, r: 1, fill: 'orange' },
      { x: 100, y: 40, r: 0, fill: 'red' },
      { x: 170, y: 160, r: 0, fill: 'cyan' },
      { x: 120, y: 220, r: 0, fill: 'yellow' },
      { x: 30, y: 100, r: 0, fill: 'fuchsia' }
    ];
    radius = 1;
  }
</script>

<div class="mt-12 min-w-fit text-xl font-bold text-gray-300">Svelte 4</div>
<div class="mb-12 mt-24">
  <svg width="300" height="300" viewBox="0 0 250 250">
    {#each data as d, i}
      {#if i === 0}
        <circle cx={d.x} cy={d.y} r={d.r + 4} stroke="white" fill="none" />
        <circle cx={d.x} cy={d.y} r={d.r} fill={d.fill} />
      {:else}
        <circle cx={d.x} cy={d.y} r={d.r + getRadius()} fill={d.fill} />
      {/if}
    {/each}
  </svg>
  <button class="variant-filled btn btn-sm mt-4" onclick={updateState}
    >Update</button>
  <button class="variant-filled-ghost btn btn-sm" onclick={reset}>Reset</button>
</div>

<style>
  circle {
    transition: all 1s ease;
  }
</style>

Granular Reactivity

However, in svelte 5 version, we declare our data as a $state rune. When we click the update button only the circle inside the white circle is updated. Svelte updated the minimum amount of DOM nodes necessary.

<!-- svelte 5 version -->
<script>
  var radius = 1;
  var getRadius = () => ++radius;
  let data = $state([
    { x: 180, y: 30, r: 1, fill: 'orange' },
    { x: 100, y: 40, r: 0, fill: 'red' },
    { x: 170, y: 160, r: 0, fill: 'cyan' },
    { x: 120, y: 220, r: 0, fill: 'yellow' },
    { x: 30, y: 100, r: 0, fill: 'fuchsia' }
  ]);

  function updateState() {
    data[0].r = data[0].r + 4;
  }

  function reset() {
    data = [
      { x: 180, y: 30, r: 1, fill: 'orange' },
      { x: 100, y: 40, r: 0, fill: 'red' },
      { x: 170, y: 160, r: 0, fill: 'cyan' },
      { x: 120, y: 220, r: 0, fill: 'yellow' },
      { x: 30, y: 100, r: 0, fill: 'fuchsia' }
    ];
    radius = 1;
  }
</script>

<div class="mt-12 min-w-fit text-xl font-bold text-gray-300">Svelte 5</div>
<div class="mb-12 mt-24">
  <svg width="300" height="300" viewBox="0 0 250 250">
    {#each data as d, i}
      {#if i === 0}
        <circle cx={d.x} cy={d.y} r={d.r + 4} stroke="white" fill="none" />
        <circle cx={d.x} cy={d.y} r={d.r} fill={d.fill} />
      {:else}
        <circle cx={d.x} cy={d.y} r={d.r + getRadius()} fill={d.fill} />
      {/if}
    {/each}
  </svg>
  <button class="variant-filled btn btn-sm mt-4" onclick={updateState}
    >Update</button>
  <button class="variant-filled-ghost btn btn-sm" onclick={reset}>Reset</button>
</div>

<style>
  circle {
    transition: all 1s ease;
  }
</style>

Bar Chart using Svelte 5 and D3

We saw a bit of the advantages Svelte 5 brings on the table. Now let’s update our bar chart using Svelte 5. If you are using Svelte 4, you can first upgrade to Svelte 5…

npm install --save-dev svelte@next

You don’t need to rewrite your whole application, Svelte 5 makes upgrading from Svelte 4 easy. Let’s update our existing Bar Chart code to utilize Svelte 5 state and derived runes instead of reactive statements that we were using like:

<script>
  $: xScale = scaleLinear()
    .domain([0, points.length])
    .range([padding.left, width - padding.right]);
</script>

We will be mainly changing the following piece of code:

<script>
  let width = 500;
  let height = 350;

  $: xScale = scaleLinear()
    .domain([0, points.length])
    .range([padding.left, width - padding.right]);

  let yScale = scaleLinear()
    .domain([0, Math.max.apply(null, yTicks)])
    .range([height - padding.bottom, padding.top]);

  $: innerWidth = width - (padding.left + padding.right);
  $: barWidth = innerWidth / points.length;
</script>

Since the width depends on the clientWidth we need to make it a reactive value by using the $state rune.

<script>
  let width = $state(500);
  let height = 350;
</script>

Next we will update the xScale to use svelte runes, in this case we will use derived rune since they depend on the width. yScale does not need to be reactive since our height variable is fixed. We also use $derived rune for innerWidth and barWidth since they depend on width and the innerWidth respectively.

<script>
  let width = $state(500);

  let xScale = $derived(
    scaleLinear()
      .domain([0, points.length])
      .range([padding.left, width - padding.right])
  );

  let yScale = scaleLinear()
    .domain([0, Math.max.apply(null, yTicks)])
    .range([height - padding.bottom, padding.top]);

  let innerWidth = $derived(width - (padding.left + padding.right));
  let barWidth = $derived(innerWidth / points.length);
</script>

and here is our bar chart with the Svelte 5 runes:


0 5 10 15 20 per 1,000 population1990199520002005201020152020202520302035

Final Code

<script>
  import { scaleLinear } from 'd3-scale';

  // 1. Basic Setup: Get the data
  // Some random birthrate data
  let points = $state([
    { year: 1990, birthrate: 6.7 },
    { year: 1995, birthrate: 4.6 },
    { year: 2000, birthrate: 14.4 },
    { year: 2005, birthrate: 18 },
    { year: 2010, birthrate: 7 },
    { year: 2015, birthrate: 12.4 },
    { year: 2020, birthrate: 17 },
    { year: 2025, birthrate: 10.9 },
    { year: 2030, birthrate: 8 },
    { year: 2035, birthrate: 12.9 }
  ]);

  // 2. Dimensions, Margins & Scales

  // Data for plotting x-y axis
  const yTicks = [0, 5, 10, 15, 20];
  const padding = { top: 20, right: 15, bottom: 20, left: 25 };

  let width = $state(500);
  let height = 350;

  let xScale = $derived(
    scaleLinear()
      .domain([0, points.length])
      .range([padding.left, width - padding.right])
  );

  let yScale = scaleLinear()
    .domain([0, Math.max.apply(null, yTicks)])
    .range([height - padding.bottom, padding.top]);

  let innerWidth = $derived(width - (padding.left + padding.right));
  let barWidth = $derived(innerWidth / points.length);

  // 3. Functions needed to create the Data elements or Helper functions, e.g d3.line d3.arc when needed

  // Shorten the date axis values for mobile
  function formatMobile(tick) {
    return "'" + tick.toString().slice(-2);
  }

  // 4. Create the main data elements (usually by iteration, using svelte each blocks)
  // We will do this in the html markup
</script>

<div class="chart" bind:clientWidth={width}>
  <svg {width} {height}>
    <!-- 4. Design the bars -->
    <g class="bars">
      {#each points as point, i}
        <rect
          x={xScale(i) + 2}
          y={yScale(point.birthrate)}
          width={barWidth * 0.9}
          height={yScale(0) - yScale(point.birthrate)} />

        <!-- Circle showing the start of each Bar -->
        <circle
          cx={xScale(i) + 2}
          cy={yScale(point.birthrate)}
          fill="white"
          r="5" />
      {/each}
    </g>
    <!-- Design y axis -->
    <g class="axis y-axis">
      {#each yTicks as tick}
        <g class="tick tick-{tick}" transform="translate(0, {yScale(tick)})">
          <line x2="100%" />
          <text y="-4"
            >{tick} {tick === 20 ? ' per 1,000 population' : ''}</text>
        </g>
      {/each}
    </g>

    <!-- Design x axis -->
    <g class="axis x-axis">
      {#each points as point, i}
        <g class="tick" transform="translate({xScale(i)}, {height})">
          <text x={barWidth / 2} y="-4">
            {width > 380 ? point.year : formatMobile(point.year)}</text>
        </g>
      {/each}
    </g>
  </svg>
</div>

<style>
  .x-axis .tick text {
    text-anchor: middle;
    color: white;
  }

  .bars rect {
    fill: #fcd34d;
    stroke: none;
  }

  .tick {
    font-family: Poppins, sans-serif;
    font-size: 0.725em;
    font-weight: 200;
    color: white;
  }

  .tick text {
    fill: white;
    text-anchor: start;
    color: white;
  }

  .tick line {
    stroke: #fcd34d;
    stroke-dasharray: 2;
    opacity: 1;
  }

  .tick.tick-0 line {
    display: inline-block;
    stroke-dasharray: 0;
  }
</style>

Svelte 5 is backwards compatible so even if you keep using the older version of your chart your app will still continue to work.

Thanks for reading!