Avishay28
Avishay28

Reputation: 2456

How to not trigger svelte store subscription if the value was not changed

I'm trying to figure out how to work with Svelte stores properly. In my code I have a store that it's initial value is either come from localStore if set or from const, I never called set or update on that store without some action from the user. In other component there is a subscriber for that store that doing server request in each change (I want the request to happen only if the store changes), however I notice that on app init the request is fire (the subscription callback is called)

Looking at the docs here https://svelte.dev/tutorial/writable-stores

count.subscribe(value => {
    countValue = value;
});

I can see that the subscribe callback is running once even before I clicked any button. How can I subscribe only to store changes (considering setting default value I pass to writeable is not "change")?

Upvotes: 4

Views: 4263

Answers (4)

Mero
Mero

Reputation: 763

It's a built-in behavior, without it, our page code with an auto subscription to store (e.g. $store) won't work at the first component render (#if block, #each block, and so on).

The solution is to create your own custom store, which provides the same interface, but without calling the new subscribed callback. But you can't use it with an auto subscription syntax in DOM (the reason is described in the previous paragraph).

My example is in TS, if you need plain JS just remove the types syntax:
(pay attention to subscribe method)

export type Callback<T, R = void> = (value: T) => R;

export class CustomWritableStore<T>  {
  value: T;
  protected subscriptions: Callback<T>[] = [];

  constructor(value: T) {
    this.value = value;
  }

  subscribe(callback: Callback<T>) {
    // doesn't make initial call of subscribing callback
    // callback(this.value); 
    this.subscriptions.push(callback);
    return () => this.unsubscribe(callback);
  }

  update(callback: Callback<T, T>) {
    this.set(callback(this.value));
  }

  set(newValue: T) {
    this.value = newValue;
    this.broadcast();
  }

  protected broadcast() {
    this.subscriptions.forEach(subscriptionCallback => subscriptionCallback(this.value));
  }

  protected unsubscribe(callback: Callback<T>) {
    this.subscriptions = this.subscriptions.filter(subscriptionCallback => subscriptionCallback !== callback);
  }
}

Store creation:

const CustomWritableStoreWithoutInitialCallFactory = <T>(initialValue?: T) => {
  //You may add here some tweaks, like extending the store with passed interface etc.)
  return new CustomWritableStoreWithoutInitialCall<T>(initialValue as T);
}

In this solution you may avoid performance issues with getting the store vaue through svelte get() method.

Upvotes: 0

anxpara
anxpara

Reputation: 1

Patrick from the Svelte discord help forum said:

"The store contract says: The subscriber needs to be called immediately after subscribing to the store. That is the way the subscriber receives the current value. If the subscriber is not called immediately after subscribing, the subscriber will not know what the current value is, thus it is undefined https://svelte.dev/docs#component-format-script-4-prefix-stores-with-$-to-access-their-values-store-contract"

So it isn't valid to create a custom store that doesn't trigger its subscribers upon initialization. If you try Casey's solution, you'll notice that the store starts as undefined, even if you provide an initial value.

Patrick suggested using a helper function:

    function notifyWhenChanges(store, callback) {
      let init = false;
      return store.subscribe(value => {
        if (init) callback(value);
        else init = true;
      });
    }
    
    notifyWhenChanges(myStore, value => {
      console.log('store changed to ', value);
    });

I was interested in finding an all-in-one custom store solution, so I used a 3rd-party events library (Eventery, although any will work) to make a factory method that adds a "changes" event to a new writable store:

    import { writable, type Writable, type StartStopNotifier } from 'svelte/store';
    import { Event } from 'eventery';
    
    export interface ChangeEmittingWritable<T> extends Writable<T> {
      changes: Event<[newValue: T, oldValue?: T]>;
    }
    
    export function createChangeEmittingWritable<T>(
      value: T,
      start?: StartStopNotifier<T>,
      ignoreInitialValue = true,
      ignoreSameValue = true,
    ): ChangeEmittingWritable<T> {
      const store = writable<T>(value, start);
      const changes = new Event<[newValue: T, oldValue?: T]>();
    
      let init = false;
      let currentV: T | undefined = undefined;
      store.subscribe((v: T) => {
        if (ignoreInitialValue && !init) {
          currentV = v;
          init = true;
          return;
        }
        if (ignoreSameValue && v === currentV) {
          return;
        }
    
        changes.emit(v, currentV);
    
        currentV = v;
      });
    
      return {
        ...store,
        changes,
      };
    }
    export const myBoolStore = createChangeEmittingWritable<boolean>(true);
    <script lang="ts">
      import { myBoolStore } from 'my-bool-store-path';

      onMount(() => {
        myBoolStore.changes.subscribe(handleMyBoolStoreChanged);
      });

      onDestroy(() => {
        myBoolStore.changes.unsubscribe(handleMyBoolStoreChanged);
      });

      function handleMyBoolStoreChanged(myBool: boolean, oldMyBool?: boolean): void {
          console.log(`store changed from ${oldMyBool} to ${myBool}`);
      }
    <script>

    <p>myBoolStore: {$myBoolStore}</p>

Also, Patrick cautioned that if you find yourself using a solution like this, it might be a sign that you've made an earlier problem more complicated, and there might be a simpler solution that avoids this entirely.

In my case, I wanted a svelte store to be the source of truth for a UI state, and I wanted to use the store autosubscriber where possible, but I also needed to run complex animation routines on state changes. I couldn't use svelte's built-in store subscribe because my initialization routine is separate from my update routine, and I wanted to know if the value actually changed.

Upvotes: 0

Casey Plummer
Casey Plummer

Reputation: 3088

I used @rixo's example, and made another version as a factory method which returns a writeable and provides a subscribe method that let's you optionally skip the initial value, so the caller can decide.

import { writable, type StartStopNotifier } from 'svelte/store';

export function newStore<T>(value?: T, start?: StartStopNotifier<T>) {
  const store = writable(value, start);

  return {
    ...store,
    subscribe(subscriber: (arg: T) => void, skipInitial = false) {
      let initial = true;
      const unsubscribe = store.subscribe(($value) => {
        if (!skipInitial || !initial) {
          subscriber($value);
        }
      });
      initial = false;
      return unsubscribe;
    }
  };
}

Creating the store instance:

export const myStore = newStore('default value');

Subscribing, note the 2nd param is true to skip the initial value:

myStore.subscribe((value) => onValueChange(value), true);

Upvotes: 1

rixo
rixo

Reputation: 25031

You'd have to build some utility for yourself. It's pretty straightforward when you know Svelte's stores contract, which is itself pretty tiny.

Something like this would work:

import { writable } from 'svelte/store'

const events = store => ({
    ...store,
    subscribe(subscriber) {
        let initial = true
        
        const unsubscribe = store.subscribe($count => {
            if (!initial) {
                subscriber($count)
            }
        })
        
        // the init call of the subscriber is made synchronously so, by
        // now, we know any further call is a change
        initial = false
        
        return unsubscribe
    }
})

export const count = writable(0)

// countEvents will only call its subscribers for changes that happen
// after the call to subscribe
export const countEvents = events(count)

You could then use this "event store" just normally. For example (REPL):

(Notice that $countEvents is undefined until the underlying count stores actually changes, since countEvent's subscribers are not called on subscribe.)

<script>
    import { onMount } from 'svelte'
    import { count, countEvents } from './stores'
    
    const increment = () => count.update(x => x + 1)
    const decrement = () => count.update(x => x - 1)
    
    let logs = []
    
    onMount(() => {
        return count.subscribe((value) => {
            logs = [...logs, value]
        })
    })
    
    let changeLogs = []
    
    onMount(() => {
        return countEvents.subscribe((value) => {
            changeLogs = [...changeLogs, value]
        })
    })
</script>

<p>
    <button on:click={decrement}>-</button>
    <button on:click={increment}>+</button>
    {$count} / {$countEvents}
</p>

<table>
    <caption>Calls</caption>
    <thead>
        <tr>
            <th>subscribe</th>
            <th>subscribeChanges</th>
        </tr>
    </thead>
    {#each logs as log, i}
        <tr>
            <td>{log}</td>
            <td>{changeLogs[i] ?? ""}</td>
        </tr>
    {/each}
</table>

Upvotes: 1

Related Questions