DEV Community

Cover image for Experiences and Caveats of Svelte 5 Migration
kvetoslavnovak
kvetoslavnovak

Posted on

Experiences and Caveats of Svelte 5 Migration

I have recently updated a rather complex web application. The application has features like auth, Stripe, i18n, dark/light mode, PWA, etc. Overall, it has around 30 pages and components, with almost no third-party npm packages.

I would like to point out what I found quite challenging when migrating the app to Svelte 5.

Auto-Migration Script Hammer

The auto-migration script provided by Svelte can do the job for you with this "one-liner" command in the terminal npx sv migrate svelte-5 (after you do all the necessary updates and installs: "@sveltejs/vite-plugin-svelte": "^4.0.0" and "svelte": "^5"). But I do not recommend this "hammer" approach.

Go file by file, component by component with Ctrl + Shift + P (Windows/Linux) / Shift + Command + P (Mac) and use the Migrate Component to Svelte 5 Syntax command in the VS Code command palette instead. You will have more control that way.

Deprecated run() Surprise

The script cannot perform miracles. Upgrading reactive variable declarations to $state() is usually fine. However, the script may struggle to detect whether $: should be converted to $derived()/$derived.by(() => {}) or $effect(() => {}).

So, guess what? With the auto-migration script, you might end up with lots of run(() => {}).

For example, imagine as a simplified example using something like this:

<script>
...
   let notext = false;
   $: if (data.completeDoc == 'NoLangVersion') {
      notext = true;
   }
   $: if (data.completeDoc !== 'NoLangVersion') {
      notext = false;
   }
</script>

...
{#if notext}
   {data.userPrefferedLang.noTextWarning}
{:else}
...
{/if}
...
Enter fullscreen mode Exit fullscreen mode

The auto-migration script will give you this:

<script>
    import { run } from 'svelte/legacy';
...
    let notext = $state(false);
    run(() => {
        if (data.completeDoc == 'NoLangVersion') {
            notext = true;
        }
    });
    run(() => {
        if (data.completeDoc !== 'NoLangVersion') {
            notext = false;
        }
    });
</script>
Enter fullscreen mode Exit fullscreen mode

with a nice little warning that the run function is deprecated.

The better Svelte 5 code would be this I guess:

<script>
...
    let notext = $derived.by(() => {
        if (data.completeDoc == 'NoLangVersion') {
            return  true;
        }
        if (data.completeDoc !== 'NoLangVersion') {
            return false;
        }
    });
...
</script>
Enter fullscreen mode Exit fullscreen mode

or if your code is not really complicated even somehting like this:

<script>
...
    let notext = $derived(
        data.completeDoc == 'NoLangVersion' 
        ? 
        true
        :
        false
        ) 
...
</script>
Enter fullscreen mode Exit fullscreen mode

The reason is that the script cannot transform code to $derived.by(() => {}) easily, so it would use a more dirty approach with $effect(). But $effect() runs only client-side, so the script uses the deprecated run function instead.

Avoid $effect If You Can

Now we are getting to the most important takeaway. Which is $effect() running only client-side. So no $effect() on the server, for prerendering pages and SSR.

$effect() DOES NOT RUN ON THE SERVER!

This should be really emphasized in the Svelte 5 documentation.

Look at this two examples:

<script>
let a = 1
let b = 2

$: c = a + b
</script>

{c}  // server responds with c == 3
Enter fullscreen mode Exit fullscreen mode
<script>
let a = $state(1)
let b = $state(2)
let c = $state(0)

$effect(() => {
  c = a + b
})
</script>

{c}  // server responds with c == 0
Enter fullscreen mode Exit fullscreen mode

They are not the same. This causes a lot of challenges. The client will need to reevaluate the c variable when mounting the page. The page will look different when sent from the server and when finally DOM-rendered on the client (SSR, SEO, flicker issues, etc.).

So always try to use $derived or $derived.by(() => {}) over $effect(). It will save you lots of trouble.

It's quite the same story as when we were discouraged from using stores in SvelteKit and SSR.

$effect vs onMount() in SvelteKit

You might be tempted to replace your onMount() with $effect() thanks to the examples that were given during the arrival of Svelte 5. For the reasons already mentioned, I would discourage this for the time being. onMount is still a a core Svelte lifecycle hook.

$bindable $props Surprise

The other nice surprise is that Svelte 5 takes care to have consistent variable values. If you pass a variable as a prop to a component and change this variable in the component later on, the script will try to solve this inconsistency using $bindable $prop. The parent should be notified, so your app state is consistent.

Look at this example:

// parent svelte file
<script>
   import ComponentBinded from './ComponentBinded.svelte';
   import ComponentWithDerived from './ComponentWithDerived.svelte';
   let name = $state('John Wick');
</script>

<p>Name value in parent: {name}</p>

<ComponentBinded bind:name={name} />

<ComponentWithDerived {name} />
Enter fullscreen mode Exit fullscreen mode

The auto-migration script will want you to use a component with binded value to ensure the parent may get the updated value back:

// ComponentBinded.svelte
<script>
   let { name = $bindable() } = $props();
   name = name.toUpperCase()
</script>

<p>
Name value in component with binded value: {name}
</p>
Enter fullscreen mode Exit fullscreen mode

We are mutating the name varaible in this child component. So we are notifing the parent so. The parent will use this mutated value as well.

If you do not need the parent to reflect the mutated name value we can use quite simpler way as well, you guessed it, with $derived():

// ComponentWithDerived.svelte
<script>
   let { name } = $props();
   let upperCaseName = $derived(name.toUpperCase())
</script>

<p>
Name value in component with derived value: {upperCaseName}
</p>
Enter fullscreen mode Exit fullscreen mode

But in this later case we are not mutating name variable in the component.

:global { } Block

A very nice feature that I found during migration was that we can use CSS :global with block now. Styling with :global is quite necessary if you want to style the HTML elements in @html, for example.

So instead of this:

...
<style>
    #blog :global(table) {
        width: 100%;
    }
    #blog :global(td) {
        text-align: left;
    }
    #blog :global(th) {
        font-weight: bolder;
        font-size: medium;
        text-align: center;
    }
</style>
Enter fullscreen mode Exit fullscreen mode

you can use this:

...
<style>
   #blog :global {
    table {
        width: 100%;
    }
    td {
        text-align: left;
    }
    th {
        font-weight: bolder;
        font-size: medium;
        text-align: center;
    }
}
</style>
Enter fullscreen mode Exit fullscreen mode

Style as a Prop in Components

In Svelte 4, if you wanted to provide a CSS class as a prop to a component, you would use {$$props.class}:

// Icons Component
<script>
   export let name;
   export let width = '1.5em';
   export let height = '1.5em';
   export let focusable = false;

   let icons = {
    user: {
    svg: `<path fill="none" d="M0 0h24v24H0V0z"/><path d="M12 6c1.1 0 2 .9 2 2s-.9 2-2 2-2-.9-2-2 .9-2 2-2m0 10c2.7 0 5.8 1.29 6 2H6c.23-.72 3.31-2 6-2m0-12C9.79 4 8 5.79 8 8s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4zm0 10c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>`
    },
    user_logged: {
    svg: `<path fill="none" d="M0 0h24v24H0z"/><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>`
    }
   };

   let displayIcon = icons[name];
</script>

<svg
    xmlns="http://www.w3.org/2000/svg"
    class={$$props.class}
    viewBox="0 0 24 24"
    fill="currentColor"
    {focusable}
    {width}
    {height}
>
   {@html displayIcon.svg}
</svg>

<style>
    ...
</style>

Enter fullscreen mode Exit fullscreen mode

In Svelte 5 you may use class={className}:

<script>
   let {
    name,
    width = '1.5em',
    height = '1.5em',
    focusable = false,
    class: className = ''
    } = $props();

let icons = {
    user: {
    svg: `<path fill="none" d="M0 0h24v24H0V0z"/><path d="M12 6c1.1 0 2 .9 2 2s-.9 2-2 2-2-.9-2-2 .9-2 2-2m0 10c2.7 0 5.8 1.29 6 2H6c.23-.72 3.31-2 6-2m0-12C9.79 4 8 5.79 8 8s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4zm0 10c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>`
    },
    user_logged: {
    svg: `<path fill="none" d="M0 0h24v24H0z"/><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>`
    }
   };

   let displayIcon = icons[name];
</script>

<svg
    xmlns="http://www.w3.org/2000/svg"
    viewBox="0 0 24 24"
    fill="currentColor"
    class={className}
    {focusable}
    {width}
    {height}
>
    {@html displayIcon.svg}
</svg>

<style>
...
</style>

Enter fullscreen mode Exit fullscreen mode

Possible Lighthouse Perfomance Drop

When I used the auto-merging script, I was shocked at how my app's performance dropped. With Svelte 4, I had nearly all 100%s. It was only after I manually migrated and carefully considered how (mainly how to avoid $effect() if possible) that my Lighthouse scores were back in the green again.

Final Words

It took longer to migrate to Svelte 5 than I had expected. I still have not pushed this new version to production, though. The updates to Svelte 5 are still coming in with quite high frequency.

I hope my experience may be useful to others.

Top comments (0)