Reputation: 2456
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
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
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
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
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