sveltejs / svelte

web development for the rest of us
https://svelte.dev
MIT License
79.83k stars 4.24k forks source link

Batched store updates #7844

Closed dpmccabe closed 1 week ago

dpmccabe commented 2 years ago

Describe the problem

(Copied from my stackoverflow post)

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 my case causes an extra network request.

Describe the proposed solution

Other reactivity libraries like SolidJS, effector, and Preact Signals include utilities that let you batch updates to stores, effectively solving this problem.

Is this something that could be easily added to Svelte? Right now I'd be thrilled just to have a utility function I could drop in to my app to approximate other libraries' batch update solution if this is possible.

Alternatives considered

Importance

would make my life easier

Conduitry commented 2 years ago

By "batch", do you mean something like a derived store that always waits til the next tick before updating its value? An async store like that is something that has been discussed before, but I'm not sure that it has its own issue.

But in any case, this is something that can happily live in userland in the meantime. The store implementations that come with Svelte are basically just blessed userland code. It's the store contract that's the important part. It's not too difficult to write a derived store composition function (or any other custom store) that has precisely the characteristics you need for a particular problem.

dpmccabe commented 2 years ago

Yes, I suppose it could be a variant of the derived store that waits for the next tick. Alternatively, there could be an explicit batch function like in the linked SolidJS example, which might be useful if the updates you're making affect multiple derived stores.

I did find this alternative store, which claims to solve the "diamond" update problem (as it's described there). I'm trying it out now.

Edit: That store library is actually solving a slightly different problem. Leaving my issue here open as a feature request.

divdavem commented 2 years ago

@dpmccabe You can use @amadeus-it-group/tansu instead of svelte/store. It has a compatible API and, in addition, has a batch function that can be called to delay updates synchronously:

        batch(() => {
            $searchText = searchEl.value;
            $page = 1;
        })      

Cf the following updated REPL: https://svelte.dev/repl/445ef0a0ce684a47ac67222c686784f5?version=3.50.0

Note that the "diamond" problem you are talking about is also solved in @amadeus-it-group/tansu (cf this pull request).

dpmccabe commented 2 years ago

Wow, thanks @divdavem. I've been looking for something exactly like this for a while!

aolose commented 2 years ago

you can create a store to subscribe them in batches

const batch = (...stores) => {
    const values = stores.map(a => get(a))
    return readable(values, set => {
        let requestId
        stores.forEach((s, i) => {
            s.subscribe(v => {
            if(values[i]!==v){
            cancelAnimationFrame(requestId)
            values[i] = v
            requestId = requestAnimationFrame(() => set(values))
            }
            })
        })
    })
}

then:

const results = derived(batch(searchText, page), ([theSearchText, thePage], set) => {

https://svelte.dev/repl/aa26bf2b4cb247e3a60f02d0c6e4757b?version=3.50.0

WHenderson commented 2 years ago

Yes, I suppose it could be a variant of the derived store that waits for the next tick. Alternatively, there could be an explicit batch function like in the linked SolidJS example, which might be useful if the updates you're making affect multiple derived stores.

I did find this alternative store, which claims to solve the "diamond" update problem (as it's described there). I'm trying it out now.

Edit: That store library is actually solving a slightly different problem. Leaving my issue here open as a feature request.

I'm the author of the @crikey libraries you mentioned. You are correct that the diamon dependency problem I describe is slightly different.

Another way you could go is to store the value as a single object.

{ searchText: 'xxx', page: 1 }

You could then change this composite value atomically and avoid any need for batching.

You could either manage this composite value manually, or use something like @crikey/stores-immer and @crikey/stores-selectable to help you.

dummdidumm commented 1 week ago

Closing since Svelte 5 with $effect etc provides a way to do this, and it's also possible to debounce updates in the store world.