Matthew Moisen
Matthew Moisen

Reputation: 18299

Svelte how to wrap an on:click event in a child and await the parents function?

I'm trying to make a button component that will hide the button and display a spinner when it is clicked and is waiting for some asynchronous job to complete, and then hide the spinner and display the button after the asynchronous job is complete.

The key difficulity I'm facing is that I want parents to use on:click in this way:

<ActionButton 
    on:click={someAsycnFunction}
>
    Go
</ActionButton>

I do not want the parent to have to use a workaround such as:

onClick={someAsyncFunction}

Here is my ActionButton.svelte child component now. While this successfully wraps the call to the parents someAsyncFunction, I am unable to await the result of this.

<script>
  
    import { createEventDispatcher } from 'svelte';

    let isRunning = false;

    const dispatch = createEventDispatcher();

    // Wrapper function for the click event
    function handleClick(event) {
        console.log('child handleClick');
        
        isRunning = true;
        
        /* This doesn't work because the parent function is async
        * and there is no way to await on this */
        dispatch('click', event);

        isRunning = false;
    }

</script>

{#if !isRunning}

    <button
        on:click={handleClick}
    >
        <slot />
    </Button>
{:else}
    ...
{/if}

The handleClick successfully wraps the parent's call to someAsyncFunction, however, I do not see a way to await the dispatch('click', event) The result is that isRunning is set to false immediately instead of waiting for the parent someAsyncFunction to complete.

Is there any way to accomplish this while still allowing the parent to 1) not have to know about the internal isRunning and for the parent to be able to use on:click instead of something like onClick ?

Upvotes: 2

Views: 1095

Answers (3)

chmin.seo
chmin.seo

Reputation: 315

Parent components (importing ActionButton) have their async tasks, but state ActionButton.isRunning depends on the state from parents.

It means that component ActionButton is not owner of the state isRunning.

running state is inherently coupled with async task from each parents.

class ButtonModel encapsulates action and state.

+- ActionButton.svelte 
+- App.svelte          - Parent component
+- button-model.js     - defines class ButtonModel

button-model.js

class ButtonModel manages async action and it's state(running).

import {writable} from 'svelte/store';
/**
 * proxy implementation encapsulating store updates
 */
export class ButtonModel {
    /** @type{boolean} */
    running;
    /** @type {() => Promise<void>} async operation(maybe) */
    action;
    /** @type {Wriable<ButtonModel>} */
    store;
    constructor(asyncAction) {
        this.running = false;
        this.action = asyncAction
        this.store = writable(this)
    }
    // store contract
    // @see https://svelte.dev/docs/svelte-components#script-4-prefix-stores-with-$-to-access-their-values-store-contract
    subscribe(callback) {
        return this.store.subscribe(callback);
    }
    get isRunning() {
        return this.running
    }
    update(callback) {
        if(callback) {
            callback()
        }
        // calling store.update(...) makes changes visible
        this.store.update((state) => state)
    }
    async execute() {
        if(this.running) {
            return
        }
        this.update(() => { this.running = true })
        await this.action()
        this.update(() => { this.running = false })
    }
}
  • constructor(asyncAction) receives reference to async task(function returning Promise)
  • execute() calls async task from parent component and manages running state.

App.svelte

<script>
    import {ButtonModel} from './button-model';
    import ActionButton from './ActionButton.svelte';

    const asyncTask = () => new Promise((resolve, reject) => {
        console.log('sending request to server....')
        setTimeout(() => {
            console.log('response received from server...')
            resolve()
        }, 3000)
    })
    const model = new ButtonModel(asyncTask)
</script>

<h1>Button Model</h1>
<ActionButton {model}>Click Me</ActionButton>
  • asyncTask - long time action(request to server etc)
  • variable model is an instance of svelte store
  • <ActionButton {model}> - passing model to ActionButton

ActionButton.svelte

<script>
    export let model; // Writable<ButtonModel>
    const handleClick = () => model.execute()
</script>

<button disabled={$model.isRunning} on:click={handleClick}><slot/>{#if $model.isRunning}<span>[SPINNER]</span>{/if}</button>
  • handleClick just calls model.execute()
  • And watch the state isRunning from model
  • accessing model with prefix $ (like $model) guarantees the changes visible

Upvotes: 0

Ben Bucksch
Ben Bucksch

Reputation: 682

I do not want the parent to have to use a workaround such as:
`onClick={someAsyncFunction}`

I had exactly the same problem, and used exactly that workaround, and I had the same feeling about it as you do. However, this works beautifully and is very clean.

  • The event handler can be a simple, normal async function, without any special considerations.
  • The button can simply await it.
  • The caller only has to change on:click to onClick.
  • You can move the try/catch into the button around the onClick call. That reliefs all the event handlers from needing a try/catch and improves the reliability of your app and also makes your code more concise.
    • Of course that works only if you have a general error handler that can handle all errors and is not specific to the page. If you want page-specific error handlers, you can still do that with another property where you pass the error handler.

Here's what I have in my Button component:

<button on:click on:click={myOnClick}
 {disabled} class:disabled ...>...</button>

<script lang="ts">
  ...
  export let disabled = false;
  export let onClick: (event: Event) => void = null;
  export let onError = showError;

  async function myOnClick(event: Event) {
    if (!(onClick && typeof(onClick) == "function")) {
      return;
    }
    let previousDisabled = disabled;
    disabled = true;
    try {
      await onClick(event);
    } catch (ex) {
      onError(ex);
    }
    disabled = previousDisabled;
  }
</script>

Use it like this:

<Button ... onClick={onSubmit} />

async function onSubmit(event) {
  let resp = await fetch("https://..."); ...
}

This will:

  • Disable the button when clicked
  • Wait for async function to finish
  • Re-enable the button after that (if it was enabled before)
  • Catch errors in the event handler - even async errors - and send them to showError(ex) function
  • If you want a custom error display, you can pass an onError function.
  • If you don't want the fancy onClick handler, you can still use the previous on:click handler, and you manage state and errors yourself.

Upvotes: 2

brunnerh
brunnerh

Reputation: 185140

You can pass a function in the event object that signals that the operation has finished.
(If you want to also allow synchronous operations, you would need to also add some other function or property that signal what the ActionButton is supposed to expect.)

Here is a simple example that is async only:

<!-- ActionButton -->
<script>
    import { createEventDispatcher } from 'svelte';

    let executing = false;
    const dispatch = createEventDispatcher();

    async function onClick() {
        executing = true;
        await new Promise(resolve => {
            dispatch('click', { done: resolve })
        });
        executing = false;
    }
</script>

{#if !executing}
    <button type="button" on:click={onClick}>
        <slot />
    </button>
{:else}
    [Executing]
{/if}
// event handler usage in parent:
async function onClick(e) {
    // [async stuff here]
    e.detail.done();
}

REPL (has sync and async)

Note that passing callbacks will be the default in Svelte 5 rather than a workaround.

Upvotes: 2

Related Questions