0
\$\begingroup\$

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 nor resolved 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.

\$\endgroup\$
3
  • \$\begingroup\$ For starters, how about using an AbortController for the cancellablePromise? \$\endgroup\$
    – Jacob
    Oct 21, 2022 at 22:22
  • \$\begingroup\$ AbortController is cool but it's only supported by fetch. There are many other Promise usecases that don't involve fetch. Furthermore, AbortController would lead to unwanted behavior unless 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\$
    – m93a
    Oct 24, 2022 at 23:16
  • \$\begingroup\$ Can you explain why you need this part: if neither lastValue nor resolved have a subscriber, then we don't subscribe to the original store. I don't understand why you need this \$\endgroup\$
    – Jacob
    Nov 1, 2022 at 19:31

1 Answer 1

1
\$\begingroup\$

Your awaitedStore function is rather verbose. All of the functionality for unsubscribing and resubscribing to the input store seems unnecessary. I also noticed that since the cancellableThen function is only used once and never exported, it made sense just to inline the functionality.

Here is a shorter implementation of awaitedStore:

// awaited-store.ts
import { writable, type Readable } from "svelte/store"

export function awaited<T>(store: Readable<Promise<T>>): {
  loading: Readable<boolean>;
  value: Readable<T>;
} {
  const value = writable<T>();
  const loading = writable<boolean>(true);

  store.subscribe((promise) => {
    loading.set(true);
    const valueThen = get(value);
    promise.then((result) => {
      const valueNow = get(value);
      if (valueThen !== valueNow) return;
      value.set(result);
      loading.set(false);
    });
  });

  return { loading, value };
}

And it is used the same way, I just renamed settled to loading and lastValue to value because those names seemed more obvious to me as to what the variable represented.

<script lang="ts">
  import { writable, derived } from "svelte/store";
  import { awaited } from "./awaited-store.ts";
  import { fetchPost } from "./utils.ts";

  const postId = writable(1);
  const article = derived(postId, (id) => fetchPost(id));
  const { loading, value } = awaited(article);
</script>

{#if $loading}
  <p>Loading...</p>
{/if}
{#if $value}
  <article>
    <h2>{$value.title}</h2>
    <p>{$value.body}</p>
  </article>
{/if}

Demo link on Stackblitz

Let me know if there are any issues; I haven't tested this thoroughly yet.

\$\endgroup\$
0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.