Changing multiple parents of Svelte derived store at same time

Viewed 66

I have a Svelte app with a relatively complex reactivity model. Data for different parts of the app is loaded from an API based on the state of potentially many parts of the UI, and changing one part of that state often requires simultaneous changes to other parts.

A simple example would be a UI with a search box, loaded results, and pagination. If you search for something, click through a few pages of results, and then change your search query, the new results should start back at page 1.

In terms of Svelte stores, results is a derived store with dependencies on writable stores searchText and page:

<script>
    import { writable, derived } from 'svelte/store';
    
    let searchEl;
    
    const searchText = writable('');
    const page = writable(1);
    
    const results = derived([searchText, page], ([theSearchText, thePage], set) => {
        console.log(`calling API with "${theSearchText}", page ${thePage}`);
        // network request to API happens here
        set([]);
    }, []);
    
    function doSearch() {
        $searchText = searchEl.value;
        $page = 1;
    }
    
    function advancePage() {
        $page = $page + 1;
    }
</script>

<p>
    <label>
        search text:
        <input type="text" on:keyup="{doSearch}" bind:this="{searchEl}" />
    </label>
</p>

<p>
    (results for page {$page} of "{$searchText}" would go here)
    {$results}
</p>

<p>
    page: {$page} <button type="button" on:click="{advancePage}">next</button>
</p>

REPL: https://svelte.dev/repl/17ac70e4d4144616aa8af5c01e67a4cc?version=3.50.0

Note what happens in the console if you enter a search term, advance the page, and then search for something else:

calling API with "a", page 1
calling API with "a", page 2
calling API with "a", page 3
calling API with "b", page 3
calling API with "b", page 1

You can see that when the search text is changed from "a" to "b", the derived store triggers an unnecessary update for page 3 of "b".

While this makes perfect sense (nothing in my code says that the updates to the two writable stores should happen simultaneously), it's obviously inefficient and in this case causes an extra network request.

This question from two years ago addresses the same issue, but is phrased terms of how to debounce the update. The only answers involve setting timeouts, which I don't really find satisfactory. Why introduce the delay? What I want to do is prevent the extra store update in the first place.

I know that I could combine searchText and page into a single writable object, but that kind of design pattern isn't going to be sustainable or efficient in my more complicated app.

Is there some standard way of dealing with this? Are there alternatives to the standard Svelte derived store that attempt to address this shortcoming?

2 Answers

To my knowledge you cannot prevent the store update. You can, however, bundle the effects of the changes either manually or by using a reactive statement:

const searchText = writable('');
const page = writable(1);
const results = writable([]);

$: {
    console.log(`calling API with "${$searchText}", page ${$page}`);
    // network request to API happens here
    $results = [];
}

REPL

For a store based solution in more complex cases, you could wrap the derived store and only call the callback at the end of the event loop, e.g.:

import { derived } from 'svelte/store';

export function batchedDerived(stores, callback) {
    let key;

    return derived(stores, (values, set) => {
        key = {};
        const currentKey = key;
        queueMicrotask(() => {
            if (key == currentKey)
                callback(values, set);
        });
    });
}

REPL

Because micro tasks cannot be cancelled this uses an invalidation mechanism. This still defers the update, albeit not as significantly as setTimeout which pushes it into at least the next event loop (depending on browsers even longer).


In your Github issue you mentioned various libraries which you could just wrap. E.g. @preact/signals-core is not too different in its API and tracks store access internally.

Example:

import { signal, effect, batch } from "@preact/signals-core";

export function writable(initial) {
    const s = signal(initial);
    return {
        _signal: s,
        subscribe: callback => effect(() => callback(s.value)),
        set: value => s.value = value,
        update: callback => s.value = callback(s.value),
    };
}

export function derived(stores, compute, initial) {
    const single = !Array.isArray(stores);
    const list = single ? [stores] : stores;
    let initialized = false;
    
    return {
        subscribe(callback) {
            if (initialized == false) {
                callback(initial);
                initialized = true;
            }

            return effect(() => {
                const values = list.map(x => x._signal.value);
                compute.length > 1 ?
                    compute(single ? values[0] : values, callback) :
                    callback(compute(single ? values[0] : values))
            });
        }
    }
}

export { batch };
<script>
   import { writable, derived, batch } from './store';
   // ...

    function doSearch() {
        batch(() => {
            $searchText = searchEl.value;
            $page = 1;
        });
    }
    // ...
</script>

REPL

Since the stores use the signals internally, the batch function can be used directly.

Note that this is just a proof of concept. E.g. the interface of writable is not exactly the same as it does not support the start parameter. Also, derived does not cache its value (Svelte's derived is implemented via readable which in turn is implemented via writable).

Related