Psionman
Psionman

Reputation: 3677

How to get Svelte to react to changes in a variable

I am attempting to get my Svelte App to react to changes to a dictionary defined in a data-store

In the following code REPL, the createNewItem function gets called when a new item is created and the user store gets updated between the ante and post displays in that function, but the getOptions function does not get called even though user.options is demonstrably changed. What is the problem?

App.svelete

<select bind:value={selectedOption}>
{#each user_options as option}
    <option value={option}>{option}</option>
{/each}
</select>

<button on:click={addNewItem}>New item</button>
<NewItem />

<script>
    import {user, state, elementDisplay } from './data'
import NewItem from "./NewItem.svelte";
    
$: user_options = getOptions($user.options);
$: new_item = createNewItem($state.new_item);
    
let selectedOption = "";
    
    function getOptions(user_options) {
        console.log('user changed')
        let options = []
        if (state.new_item != '') {
            user_options[$user.new_item] = ''
        }
        for (const [option, value] of Object.entries(user_options)) {
            options.push(option)
        }
        return options
    }
    
    function createNewItem(new_item) {
            if (new_item.length > 0 ) {
                console.log('ante', $user)
                $user.options[new_item] = '';
                console.log('post', $user)
            }
        }

function addNewItem() {
        elementDisplay('new-item', 'block')
        document.getElementById('new-item-value').focus();
    }
</script>

NewItem.svelte

<div id="new-item">
    <input type="text" id="new-item-value">
    <div class="buttons">
        <button class="select-button" on:click={useItem}>Use</button>
        <button class="select-button" on:click={close}>Close</button>
    </div>
</div>

<style>
    #new-item {
        display: none;
    }
</style>

<script>
    import { state, elementDisplay } from './data'

    function useItem() {
        let input_field = document.getElementById('new-item-value')
        if (input_field.value) {
            $state.new_item = input_field.value
        }
        close()
    }

    function close() {
            elementDisplay('new-item', 'none');
        }
</script>

data.js

import { writable } from 'svelte/store';

export let user = writable({
    new_item: '',
    options: {
    "2": "Option 2",
    "1": "Option 1",
    "3": "Option 3",
        }
    }
);

export let state = writable({
    new_item: '',
});

export function elementDisplay(item_name, display) {
    let item = document.getElementById(item_name);
    item.style.display = display;
};

Upvotes: 2

Views: 2056

Answers (1)

hackape
hackape

Reputation: 19957

TL;DR: swap the order of those two $: reactive statements, and you'll get desired result.

Explanation

Normally order of $-statements doesn't matter. However in your case there's a hidden causality between createNewItem function and $user variable, which svelte compiler fails to notice.

What happened? It all boils down to how svelte compiles the reactive statements.

$: user_options = getOptions($user.options);
$: new_item = createNewItem($state.new_item);

/** ABOVE IS COMPILED INTO BELOW */

$$self.$$.update = () => {
    if ($$self.$$.dirty & /*$user*/ 8) {
        $$invalidate(0, user_options = getOptions($user.options));
    }

    if ($$self.$$.dirty & /*$state*/ 16) {
        $: new_item = createNewItem($state.new_item);
    }
};

Notice that all reactive statements are packed into one single $$.update function.

Your chain of reaction goes like this:

1) NewItem.svelte#useItem() -> $state.new_item = "..." -> 
2) $: new_item = createNewItem($state.new_item) -> $user.options[new_item] = '' -> 
3) $: user_options = getOptions($user.options);

Hidden causality (or "dependency" if you like) lies inside createNewItem($state.new_item) (second $-statement) where $user.options is mutated. It then in turn causes getOptions($user.options) (first $-statement) to run.

That's your intention. However the way svelte interprets things goes like this:

  • The compiler packs both $-statements into the same $$.update function, which will just execute JUST ONCE during each update cycle (source).
  • useItem() -> $state.new_item triggers a new update cycle, so $$.update() is called to run those $: reactions.
  • if ($$self.$$.dirty & /*$user*/ 8) check runs first, it sees $user is not dirty *AT THIS POINT*, so it moves on.
  • if ($$self.$$.dirty & /*$state*/ 16) sees that $state is dirty, so it runs createNewItem($state.new_item)
  • If we were to run the if ($$self.$$.dirty & /*$user*/ 8) check again, we'd see $user dirty by now! But it's too late, because the way svelte compiles, we've moved pass that point already.

Solution

The quick fix, you just need to swap the order of $-statements:

$: new_item = createNewItem($state.new_item);
$: user_options = getOptions($user.options);

In fact the svelte docs has a very similar example that talks about this caveat:

It is important to note that the reactive blocks are ordered via simple static analysis at compile time, and all the compiler looks at are the variables that are assigned to and used within the block itself, not in any functions called by them. This means that yDependent will not be updated when x is updated in the following example: [...]

Moving the line $: yDependent = y below $: setY(x) will cause yDependent to be updated when x is updated.

However I'd personally avoid this kind of deeply (and hidden) chained reactivity altogether. To me this is a huge red-flagged anti-pattern. The dependency is hidden to begin with, the reaction chain is convoluted, and plus this ordering caveat which is hard to notice, why would you get yourself into this mess?

Better organize your code into more clear structure, instead of jump wiring bad designs.

Upvotes: 7

Related Questions