DEV Community

Cover image for "Helper" Varaibles in Svelte 5
kvetoslavnovak
kvetoslavnovak

Posted on • Edited on

"Helper" Varaibles in Svelte 5

Bye Bye Magical Svelte 4 $:

Following my recent post Experiences and Caveats of Svelte 5 Migration I would like to highlight some techniques and change of mindset when going from Svelte 4 to Svelte 5.

Svelte 4 uses "magical" $: and let and does all the heavy lifting to make code reactive. We also embraced varaibles reassignment like

<script>
let arr = [1, 2, 3]
let value = 4

arr = [...arr, value]
</script>
Enter fullscreen mode Exit fullscreen mode

instead of methods updating/mutating varaibles like push etc.

I was quite suprprised to re-learn good old JS patterns using Svelte 5.

No Need to Be Reactive All the Time

And I was also probably quite spoiled by let in Svelte 4 , no reasoning about reactivity, it was included if needed. But not all varaibles have to be reactive. Also non reactive variables may be updated in reactive or even "traditional mutating code". The real need for reactive variable is when we use it in UI (when this varaible is rendered in html/page and we need it to update later).

You may encounter erros in Svelte 5 like Cannot assign to derived state, State referenced in its own scope will never update. Did you mean to reference it inside a closure? or derived_references_self\nA derived value cannot reference itself recursively if using Svelte 4 coding style.

Example of Helper Variables

Take a look at this example of Svelte 4 style of code:

<script>
    let value;

    let derivedArr = []
    $: if (value) {
        derivedArr = [...derivedArr, value]
    }

    function random () {
        value = Math.floor(1 + Math.random() * 10)
    }
</script>

<button on:click={random}>Generate Random Value</button>
<p>value: {value}</p>
<p>derivedArr: {derivedArr}</p>
Enter fullscreen mode Exit fullscreen mode

DEMO

The random function works as some kind of simulation of server response sending as a result of a search query, on the client we are accumulating the results, so it work like a "Load more" button. Hereby random function should not be changed. We have two reactive variables and Svelte 4 solves the updates automatically. We only needed to remember that the right way is by reassigning the variable.

In Svelte 5 we should think a little how to achieve the same result. The two variables we are using are not enough, we need one more, the helper one.

One way is to use a $derived rune.

<script>
    let value = $state();


                  let helperArr = [];   
let derivedArr =  $derived.by(() => {
                   if (value) {
                     helperArr.push(value);
                     return helperArr;
                   }
                  });

    function random () {
        value = Math.floor(1 + Math.random() * 10)
    }
</script>

<button onclick={random}>Generate Random Value</button>
<p>value: {value}</p>
<p>derivedArr: {derivedArr}</p>
Enter fullscreen mode Exit fullscreen mode

DEMO

I am trying visually emphasize which parts of the code are relevant and somehow "wrapped" from Svelte 4 into Svelte 5 mental model. helperArr is declared outside of a$derived.by() function scope not to be reseted every time $derived.by() reruns (which is when reactive variables inside $derived updates). If you know easier way to do this let me know.

There is also an $effect() rune way to achieve the same with untrack trick. It might look even simplier but we should avoid effects if possible (mainly Svetlet 5 effects do not run on the server/SSR).

<script>
    import { untrack } from 'svelte';

    let value = $state();
    let derivedArr = $state([]);

    $effect.pre(() => {
        if (value)
            untrack(() => derivedArr.push(value))
    });

    function random () {
        value = Math.floor(1 + Math.random() * 10)
    }
</script>

<button onclick={random}>Generate Random Value</button>
<p>value: {value}</p>
<p>derivedArr: {derivedArr}</p>
Enter fullscreen mode Exit fullscreen mode

DEMO

Real Life Example

This is the example how I tried to migrate quite stright forward Svelte 4 page to Svelte 5. It took me a while to rethink the code. This page works as a posts search with a "Load More" functionality (adding results or pagging if a user does not have JS):

Svelte 4

<script>
    import Icon from '../components/Icon.svelte';
    import { enhance } from '$app/forms';
    import { tick } from 'svelte';

    export let form;
    export let searchingLang;
    export let l;

    let results = [];
    let previousSearch = '';
    let searchTerm;
    let skip;

    $: if (!!form && form?.thereIsMore) {
        searchTerm = form.searchTerm;
        skip = Number(form?.skip) + 20;
    }

    $: if (!!form?.searchResultFromAction) {
        if (previousSearch == form.searchTerm && form.thereWasMore) {
            results = [...results, ...form.searchResultFromAction];
        } else {
            results = [...form.searchResultFromAction];
            previousSearch = form.searchTerm;
        }
    }

    async function intoView(el) {
        await tick();
        if (el.attributes.index.nodeValue == skip - 20 && skip != undefined) {
            el.scrollIntoView({ behavior: 'smooth' });
        }
    }
</script>

{#if results.length}
    <ol>
        {#each results as item, index}
            <li use:intoView {index} aria-posinset={index}>
                <!-- users without javascript have calculated order of results within paggination and css disables standard ol ul numbering -->
                <!-- users with javascript have standard ol ul numbering and loading more feature -->
                <noscript>{Number(index) + 1 + Number(form?.skip)}. </noscript>
                <a href="/post/{searchingLang}/{item.id}/content">{item.title}</a>
            </li>
        {/each}
    </ol>

    {#if form?.thereIsMore}
        <form
            method="POST"
            action="?/search&skip={skip}&thereWasMore={form?.thereIsMore}"
            use:enhance
            autocomplete="off"
        >
            <label>
                <!-- Probably we do not need to bind the value as this is hidden input -->
                <!-- <input name="searchTerm" type="hidden" bind:value={searchTerm} /> -->
                <input name="searchTerm" type="hidden" value={searchTerm} />
            </label>
            <button aria-label="Button to load more search results" class="outline">
                <Icon name="loadMore" />
            </button>
        </form>
    {/if}
{:else if form?.searchResultFromAction.length == 0}
    {l.noResultsFound}
{/if}

<style>
    @media (scripting: none) {
        /* users without javascript have calculated order of results within paggination and css disables standard ol ul numbering
users with javascript have standard ol ul numbering and loading more feature */
        ol {
            list-style-type: none;
        }
    }
</style>
Enter fullscreen mode Exit fullscreen mode

Svelte 5

<script>
    import Icon from '../components/Icon.svelte';
    import { enhance } from '$app/forms';
    import { tick } from 'svelte';

    let { form, searchingLang, l } = $props();

    let previousSearch = '';
    let skip = $derived.by(() => {
        if (!!form && form?.thereIsMore) {
            return Number(form?.skip) + 20;
        }
    });

    let helperResultsArr = [];
    let results = $derived.by(() => {
        if (!!form?.searchResultFromAction) {
            if (previousSearch == form.searchTerm && form.thereWasMore) {
                helperResultsArr.push(...form.searchResultFromAction);
                return helperResultsArr;
            } else {
                helperResultsArr = [];
                helperResultsArr.push(...form.searchResultFromAction);
                previousSearch = form.searchTerm;
                return helperResultsArr;
            }
        } else return [];
    });

    async function intoView(el) {
        await tick();
        if (el.attributes.index.nodeValue == skip - 20 && skip != undefined) {
            el.scrollIntoView({ behavior: 'smooth' });
        }
    }
</script>

{#if results.length}
    <ol>
        {#each results as item, index}
            <li use:intoView {index} aria-posinset={index}>
                <!-- users without javascript have calculated order of results within paggination and css disables standard ol ul numbering -->
                <!-- users with javascript have standard ol ul numbering and loading more feature -->
                <noscript>{Number(index) + 1 + Number(form?.skip)}. </noscript>
                <a href="/post/{searchingLang}/{item.id}/content">{item.title}</a>
            </li>
        {/each}
    </ol>

    {#if form?.thereIsMore}
        <form
            method="POST"
            action="?/search&skip={skip}&thereWasMore={form?.thereIsMore}"
            use:enhance
            autocomplete="off"
        >
            <label>
                <input name="searchTerm" type="hidden" value={form.searchTerm} />
            </label>
            <button aria-label="Button to load more search results" class="outline">
                <Icon name="loadMore" />
            </button>
        </form>
    {/if}
{:else if form?.searchResultFromAction.length == 0}
    {l.noResultsFound}
{/if}

<style>
    @media (scripting: none) {
        /* users without javascript have calculated order of results within paggination and css disables standard ol ul numbering
users with javascript have standard ol ul numbering and loading more feature */
        ol {
            list-style-type: none;
        }
    }
</style>
Enter fullscreen mode Exit fullscreen mode

That is all for now.

PS: Do not hesitate to let me know if you would do the migration in a different way.

Top comments (2)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.