In my Svelte app, I need to reactively fetch data from the server and then render the result. The ideal data struture for this is a store whose values are promises – eg. like this:
let article = writable("about");
let text = derived(article, a => fetch(`http://example.com?article=${a}`));
However, the way I want to render the values is different from this representation: if the article
variable changes, I still want to render the previous text and overlay it with a spinner until the new text loads (ie. until the promise resolves). Because something like this could be useful in more places, I decided to write a little helper function for this.
The awaitedStore
function takes a store whose values are promises, and returns two new stores: lastValue
and settled
. The lastValue
store contains the last resolved value – if the original store changes to a new promise, lastValue
won't update until the promise is resolved. The resolved
store contains a boolean indicating whether the current promise is settled or not – the UI can display a spinner any time resolved
is false.
There are a few other important features:
- if neither
lastValue
norresolved
have a subscriber, then we don't subscribe to the original store - never update
lastValue
with an outdated promise – if a new promise comes before the old was resolved, we stop waiting until it resolves
This is the code:
import { writable, type Readable } from "svelte/store"
function cancellableThen<T>(p: Promise<T>, f: (v: T) => void): () => void {
let canceled = false;
p.then((v) => {
if (canceled) return;
f(v);
});
return () => (canceled = true);
}
const Void: void = void 0;
export function awaitedStore<T>(store: Readable<Promise<T>>): {
lastValue: Readable<T | undefined>;
resolved: Readable<boolean>;
} {
let cold = true;
let lastValueHasSubscriber = false;
let loadingHasSubscriber = false;
const start = (what: "lastValue" | "loading") => () => {
if (what === "lastValue") lastValueHasSubscriber = true;
if (what === "loading") loadingHasSubscriber = true;
const stop = () => {
if (what === "lastValue") lastValueHasSubscriber = false;
if (what === "loading") loadingHasSubscriber = false;
if (lastValueHasSubscriber || loadingHasSubscriber) return;
cold = true;
unsubPromise?.();
unsubStore();
};
if (!cold) return stop;
cold = false;
let unsubPromise = () => Void;
const unsubStore = store.subscribe((p) => {
unsubPromise?.();
resolved.set(false);
unsubPromise = cancellableThen(p, (v) => {
lastValue.set(v);
resolved.set(true);
});
});
return stop;
};
const lastValue = writable<T | undefined>(undefined, start("lastValue"));
const resolved = writable(false, start("loading"));
return { lastValue, resolved };
}
Link to TS playground. Link to Svelte REPL.
An obvious shortcoming of the code is the lack of error handling – I decided to omit it in favor of simplicity, and I intend to add it later.
AbortController
for the cancellablePromise? \$\endgroup\$awaitedStore
is the sole consumer of those promises. Eg. if there were two components that use the same fetched data in different ways, one of them could cancel a request that is awaited by the other one. My code makes no such assumptions. To sum up: while I agree that, in this specific usecase, AbortController would make the code more optimized, it would also be less general and would introduce a footgun \$\endgroup\$lastValue
norresolved
have a subscriber, then we don't subscribe to the original store. I don't understand why you need this \$\endgroup\$