Initializing a custom Svelte store asynchronously

4.1k Views Asked by At

Background
I am attempting to develop a cross-platform desktop app using Svelte and Tauri
When the app starts i need to load a settings.json-file from the filesystem into a custom Svelte store.
It needs to be a custom store because I must validate the data using a custom set-function before writing to it
The store will hold an object.

I am using regular Svelte and not Svelte-kit as SSR is not necessary.

Problems

  1. Tauri does not have any synchronous methods for reading files in their fs-api
  2. Svelte does not seem to have any intuitive way I can find for doing this

Tests

  • Following Svelte's promiseStore example, this works for regular stores but not custom stores as the custom set method cannot be reached
  • Using a recursive timout-function waiting for the file to be read
  • Using a while-loop waiting for the file to be read
  • Attempted to find a way to load the data into a global variable before Svelte initializes

Example
It would be a lot of code if I were to post all the failed attempts, so I will provide a example of what I am attempting to achieve.
Everything in the code works when createStore is not async, except reading the settings-file.

import { writable, get as getStore } from 'svelte/store'; // Svelte store
import _set from 'lodash.set';                            // Creating objects with any key/path
import _merge from 'lodash.merge';                        // Merging objects
import { fs } from '@tauri-apps/api';                     // Accessing local filesystem


async function createStore() {
  // Read settings from the file system
  let settings = {}
  try { settings = JSON.parse(await fs.readTextFile('./settings.json')); }
  catch {}

  // Create the store
  const store = writable(settings);

  // Custom set function
  function set (key, value) {
    if(!key) return;

    // Use lodash to create an object
    const change = _set({}, key, value);

    // Retreive the current store and merge it with the object above
    const currentStore = getStore(store)
    const updated = _merge({}, currentStore, change)

    // Update the store
    store.update(() => updated)
    
    // Save the updated settings back to the filesystem
    fs.writeFile({
      contents: JSON.stringify(updated, null, 2),
      path: './settings.json'}
    )
  }

  // Bundle the custom store
  const customStore = {
    subscribe: store.subscribe,
    set
  }

  return customStore;
}

export default createStore();
3

There are 3 best solutions below

4
On BEST ANSWER

When having a custom store which needs to be initialized asynchronously, I do this via an async method on the store which I'd call from the App component, if the store is directly needed
(note that fs.writeFile() also returns a Promise. If there was an error, this wouldn't be handled yet...)

App.svelte
<script>
    import settings from './settings'
    import {onMount} from 'svelte'
    
    let appInitialized

    onMount(async () => {
        try {
            await settings.init()           
            appInitialized = true
        }catch(error) {
            console.error(error)
        }
    })

</script>

{#if appInitialized}
    'showing App'
{:else}
    'initializing App'
{/if}

alternative component logic when there's just the one store to initialize using an {#await} block

<script>
    import settings from './settings'
</script>

{#await settings.init()}
    'initializing store'
{:then}
    'show App'
{:catch error}
    'Couldn't initialize - '{error.message}
{/await}

or one if there were more stores to initialize

<script>
    import settings from './settings'
    import store2 from './store2'
    import store3 from './store3'

    const initStores = [
        settings.init(),
        store2.init(),
        store3.init()
    ]
</script>

{#await Promise.all(initStores)}
    'initializing stores'
{:then}
    'showing App'
{:catch error}
    'Couldn't initialize - '{error.message}
{/await}
settings.js
import { writable, get } from 'svelte/store';
import { fs } from '@tauri-apps/api';  

function createStore() {

    let initialValue = {}
    // destructure the store on creation to have 'direct access' to methods
    const {subscribe, update, set} = writable(initialValue);

    return {
        subscribe,

        async init() {
            const savedSettings = JSON.parse(await fs.readTextFile('./settings.json'))
            set(savedSettings);
        },

        changeSetting(key, value) {
            if(!key) return;

            const storeValue = get(this)

            storeValue[key] = value

            update(_ => storeValue)
            
            fs.writeFile({
                contents: JSON.stringify(storeValue, null, 2),
                path: './settings.json'
            })
        }
    }
}

export default createStore();
1
On

Update: I'd recommend the wrapper component approach in Corrl revised answer,
but with an #await block instead of an #if.


As loading the settings are part of the app startup, you can delay the mounting your Svelte App until after the settings are loaded.

This allows components to use the store without worrying about the loading state:

// main.js
initSettings().then(()=> {
    new App({ target: document.body })
})

// settings.js
import { writable, get } from 'svelte/store';
import { fs } from '@tauri-apps/api';  

let store;
const settings = { 
  subscribe() {
    if (!store) {
      throw new Error('Not initialized')
    }
    return store.subscribe()
  },
  async changeSetting(key, value) {
    if (!store) {
      throw new Error('Not initialized')
    }
    // ... save to fs
  }
}

export default settings;

export async function initSettings() {
  const data = JSON.parse(await fs.readTextFile('./settings.json'))
  if (store) {
    store.set(data)
  } else {
    store = writable(data);
  }
}

Downside it that it delays the startup of the app and if you don't implement a .catch in main.js the app would stay blank when the promise is rejected.

0
On

I got inspired from @Corrl's excellent answer. Here are 2 store creation functions; asyncDerived resolves promises passed into it and passes the results to its callback fn. lateInitLoadable only does its initialization when the init() function is called, so you can do late init like when it needs to be done from onMount().

So here's an example of instantiating actual stores:


export const websocketClient = lateInitLoadable(async () => {
    const client: MySpecialWebsocket = await setupWSOnlyAfterPageHasLoaded();
    return client;
});


export const highLevelClient = asyncDerived(
    [websocketClient.load],
    async ([$websocketClient]) => {
        const client = new HighLevelClient($websocketClient);
        await client.asyncSetup();
        return client;
    }
);

And then get reactive in svelte! Make sure you call init() from onMount.

<script>
    import {onMount} from 'svelte'

    onMount(async () => {
        await websocketClient.init();
        await highLevelClient.load
        const val = await $highLevelClient.websocketRequest();
    })
</script>

{#await highLevelClient.load then $highLevelClient}
    'showing App'
{/await}

Remember that you can always do an if (browser) to localize the init to the store:

import { browser } from '$app/environment';

if (browser) {
    websocketClient.init();
}

Here's the source for asyncDerived and lateInitLoadable:

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

export type Loadable<T> = Readable<T> & { load: Promise<T>; init?: () => Promise<T> };

export function asyncDerived<S extends readonly unknown[], T>(
    deps: S,
    cb: (values: { [K in keyof S]: Awaited<S[K]> }) => Promise<T>
): Loadable<T> {
    const { subscribe, set } = writable<T>();
    const load = new Promise<T>((resolve) => {
        Promise.all(deps).then((resolvedDeps) => {
            cb(resolvedDeps).then((value) => {
                resolve(value);
                set(value);
            });
        });
    });

    return {
        subscribe,
        load
    };
}

export function lateInitLoadable<T>(lateInitFn: () => Promise<T>): Loadable<T> {
    const { subscribe, set } = writable<T>();
    // eslint-disable-next-line @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars
    let loadResolver: (value: T) => void = (_: T) => {};
    const load = new Promise<T>((resolve) => {
        loadResolver = resolve;
    });

    return {
        subscribe,
        async init() {
            const value = await lateInitFn();
            set(value as T);
            loadResolver(value as T);
            return value as T;
        },
        load
    };
}