Search code examples
javascriptsveltesvelte-store

Only fire set if object data has changed in Svelte store


In Svelte, when using a writable store that takes a single primitive value (e.g. a string), when firing set multiple times for the same value, subscribe will only be called when the value changes. Demo in Svelte REPL

However, when storing a complex object, every time set is called, even if the object has the same properties, subscribe will still be fired. Demo in Svelte REPL

I'm running a fairly expensive external API call on updates (handled via subscribe), so I want to limit if the data hasn't changed. How best should I prevent firing set or listening to subscribe if the data is the same as the previous run?

An attempt at a solution would be to keep the previous value inside a closure and compare before calling set and then carefully expose which API methods are available for consumers of the store like this:

import { writable, get } from "svelte/store";

const initialContentState = {
  title: "",
  body: "",
};

const { subscribe, set } = writable(initialContentState);

const isQuestionEqual = (a,b) => {
    return a.title === b.title && a.body === b.body;
}

const initQuestionContentStore = () => {
    let prevValue = initialContentState;

    return {
        subscribe,
        reset() {
            set(initialContentState)
        },
        setContent(content) {
                if (!isQuestionEqual(content, prevValue)) {
                    set(content);
                    prevValue = content;
            }
        }
    }
}

export const questionContentStore = initQuestionContentStore()

Demo in Svelte REPL

However, feels weird to have to keep track of the state within the store which is supposed to be responsible for keeping track of state itself. I could also use get to fetch the value from the store inside of the setContent method, but docs suggest against it for perf reasons

Note: This is similar to Why does my Svelte store subscribe() get fired when the value hasn't changed?, but I want a workaround, not a reason.


Solution

  • This approach seems fine to me. You could extract the logic for easier reuse/better encapsulation into a writable wrapper function that takes an optional equality comparison function.

    export function strictWritable(value, equalityComparer, start) {
        const { subscribe, set: originalSet } = writable(value, start);
    
        let previous = value;
        const set = v => {
            if (
                equalityComparer == null
                    ? v != previous
                    : equalityComparer(previous, v) == false
            ) {
                originalSet(v);
                previous = v;
            }
        };
    
        return {
            subscribe,
            update: cb => set(cb(previous)),
            set,
        }
    }
    

    REPL

    The performance note on get is about things like retrieving a store value in a loop. In most common cases it should not really matter.


    Side note:
    The linked question is about something different, namely that the handler will be called for every new subscription which can happen a lot if multiple components use a given store. That is also how get works: The subscribe handler will be invoked immediately.

    Stores fire on any set invocation involving objects to allow DOM updates based on mutations. Otherwise you would have to create new object hierarchies when setting a deeply nested property on a store.

    In your example you actually set the value to a new object ({ title, body }), so if you don't want an update triggered here, some additional equality comparisons are necessary which can be non-trivial and impact performance.

    Hence, Svelte only checks equality for primitives and otherwise invalidates the store.