Geoff Rich I blog about web development, focusing on the frontend. At the moment, I'm particularly interested in Svelte and web accessibility. If you enjoy what you read, come say hi on Twitter.

Introduction to Svelte Actions

6 min read 1764

Introduction to Svelte actions

Actions are one of Svelte’s less commonly used features. An action allows you to run a function when an element is added to the DOM. While that sounds simple, an action used in the right way can greatly simplify your code and allow you to reuse bits of logic without creating an entirely separate component.

In this post, I’ll give two examples where a Svelte action would be useful and show why an action is the right tool for the job.

Using Svelte actions to focus an input

Let’s start with the following Svelte component. We have some static text with an edit button next to it. When the edit button is clicked, a text field is revealed. Typing in the field updates the text, and you can confirm to save your changes.

<script>
    let name = 'world';    
    let editing = false;


    function toggleEdit() {
        editing = !editing
    }
</script>

<p>
    Name: {name}
</p>

{#if editing}
<label>
    Name
    <input type="text" bind:value={name}>
</label>
{/if}

<button on:click={toggleEdit}>
    {editing ? 'Confirm' : 'Edit'}
</button>

This UI is a little annoying, since you need to click (or tab into) the edit field after clicking the edit button. It would be a better experience if it was automatically focused, so you could start typing right away. How can we do that?

Option 1: bind:this

If you’re familiar with binding to DOM elements in Svelte, you might think of doing something like this:

<script>
    let name = 'world';    
    let editing = false;
    let input;


    function toggleEdit() {
        editing = !editing
            if (editing) {
                    input.focus();
            }
    }
</script>

<p>
    Name: {name}
</p>

{#if editing}
<label>
    Name
    <input bind:this={input} type="text" bind:value={name}>
</label>
{/if}

<button on:click={toggleEdit}>
    {editing ? 'Confirm' : 'Edit'}
</button>

However, if you try to run that code, you get an error in the console:

Uncaught TypeError: input is undefined

This is because the input is not added to the DOM yet, so you can’t focus it after setting editing to true.

Instead, we need to call Svelte’s tick function, which returns a promise that resolves when Svelte has finished applying any pending state changes. Once tick resolves, the DOM will be updated and we can focus the input.

function toggleEdit() {
    editing = !editing
    if (editing) {
            tick().then(() => input.focus());
    }
}

That works, but it doesn’t feel very intuitive. It’s also not very reusable — what if we want to apply this behavior to other inputs?

Option 2: Move the input into a separate component

Another option is to move the input into its own component, and focus the input when that component mounts. Here’s what that looks like:

<script>
    export let value;
    export let label;
    let input;


    import { onMount } from 'svelte';


    onMount(() => {
            input.focus();
    });
</script>

<label>
    {label}
    <input type="text" bind:this={input} bind:value>
</label>

Then it can be used in the parent component, like so:

{#if editing}
<Input bind:value={name} label="name" />
{/if}

However, with this approach you have to incur the cost of creating a new component, which you didn’t need to do otherwise. If you want to apply this behavior to another input element, you would need to make sure to expose props for every attribute that is different.

You are also limited to input elements with this method, and would need to reimplement this behavior if you wanted to apply it to another element.

Option 3: Use Svelte actions

Though these are all viable solutions, it feels like you’re having to work around Svelte instead of with it. Thankfully, Svelte has an API to make this sort of thing easier: actions.

An action is just a function. It takes a reference to a DOM node as a parameter and runs some code when that element is added to the DOM.

Here’s a simple action that will call focus on the node. We don’t have to call tick this time because this function will only run when the node already exists.

function focusOnMount(node) {
    node.focus();
}

We can then apply it to a node with the use: directive.

{#if editing}
<label>
Name
<input use:focusOnMount type="text" bind:value={name}>
</label>
{/if}

That’s a lot cleaner! This is just a few lines of code to solve the same problem we were dealing with before, and it’s reusable without needing to create a separate component. It’s also more composable, since we can apply this behavior to any DOM element that has a focus method.



You can see the final demo in this Svelte REPL.

Example 2: Integrating Svelte actions with Tippy

Actions are also great when you want to integrate with a vanilla JavaScript library that needs a reference to a specific DOM node. This is another strength of Svelte — while the Svelte-specific ecosystem is still growing, it’s still easy to integrate with the vast array of vanilla JS packages!

Let’s use the tooltip library Tippy.js as an example. We can pass a DOM element to initialize Tippy on that node, and also pass an object of parameters.

For example, here’s how we can add a tooltip using vanilla JS:

import tippy from 'tippy.js';

tippy(document.getElementById('tooltip'), { content: 'Hello!' });

We can use a Svelte action to run this code so we have a reference to the node without calling document.getElementById. Here’s what that might look like:

function tooltip(node) {
    let tip = tippy(node, { content: 'Hello!' });
}

And it can be used on an element like so:

<button use:tooltip>
    Hover me
</button>

But how do we customize the properties we use to initialize the tooltip? We don’t want it to be the same for every use of the action.

Passing parameters to actions

Actions can also take parameters as a second argument, which means we can easily customize the tooltip and allow consumers to pass in the parameters they want.

function tooltip(node, params) {
    let tip = tippy(node, params);
}

And here’s how you use it on an element:

<button use:tooltip={{
    content: 'New message'
}}>
    Hover me
</button>

Note the double curly brackets. You put the parameters you want to pass to the action inside the curly brackets. Since we’re passing an object to this action, there are two sets of curly brackets: one to wrap the parameters and one for the parameter object itself.

This works, but there’s a few problems:

  1. There’s no way to update the parameters after the action runs
  2. We aren’t destroying the tooltip when the element is removed

Thankfully, actions can return an object with update and destroy methods that handle both these problems.

The update method will run whenever the parameters you pass to the action change, and the destroy method will run when the DOM element that the action is attached to is removed. We can use the Tippy setProps function to update the parameters, and destroy to remove the element when we’re done.

Here’s what the action looks like if we implement these methods:

function tooltip(node, params) {
    let tip = tippy(node, params);
    return {
     update: (newParams) => {
         tip.setProps(newParams);
     },
     destroy: () => {
         tip.destroy();
     }
    }
}

This allows us to write a more complicated example that updates the placement and message of the tooltip after initial creation:

<script>
    import tippy from 'tippy.js';


    function tooltip(node, params) {
     let tip = tippy(node, params);
     return {
         update: (newParams) => {
             tip.setProps(newParams);
         },
         destroy: () => {
             tip.destroy();
         }
     }
    }


    const placements = ['top', 'right', 'bottom', 'left'];
    let selectedPlacement = placements[0];
    let message = "I'm a tooltip!";
</script>

<label for="placement">Placement</label>
<select bind:value={selectedPlacement} id="placement">
    {#each placements as placement}
     <option>{placement}</option>
    {/each}
</select>

<label>Message <input bind:value={message} type="text"></label>

<button use:tooltip={{
    content: message,
    placement: selectedPlacement
}}>
    Hover me
</button>

You can find the final example in this Svelte REPL.
Our tooltip example

Alternate approaches without using actions

As with the example before, we didn’t need actions to be able to do this. We could also attach the tooltip when the component mounts and update the parameters using reactive statements. Here’s what that might look like:

<script>
    import tippy from 'tippy.js';
    import { onMount, onDestroy } from 'svelte';


    let button;
    let tip;


    onMount(() => {
     tip = tippy(button, { content: message, placement: selectedPlacement});
    });


    $: if (tip) {
     tip.setProps({ content: message, placement: selectedPlacement });
    }


    onDestroy(() => {
     tip.destroy();
    });


    const placements = ['top', 'right', 'bottom', 'left'];
    let selectedPlacement = placements[0];
    let message = "I'm a tooltip!";
</script>

<label for="placement">Placement</label>
<select bind:value={selectedPlacement} id="placement">
    {#each placements as placement}
     <option>{placement}</option>
    {/each}
</select>

<label>Message <input bind:value={message} type="text"></label>

<button bind:this={button}>
    Hover me
</button>

This approach is totally valid. However, it is less reusable across multiple components and becomes tricky if the tooltip element is conditionally rendered or in a loop.

You might also think of creating a component like <TooltipButton> to encapsulate the logic. This will also work, though it limits you to one type of element. Implementing it as an action lets you apply the tooltip to any element, not just a button.

Wrapping up

Actions are a very powerful Svelte feature. Now that you’re familiar with them, make sure you check out the official tutorial and docs to see other ways to use actions. They aren’t always the right solution — many times, it is better to encapsulate the behavior in other ways, such as in a separate component or with a simple event handler. However, there are times like the examples above where they make your component code much cleaner and more reusable.

There is also an open RFC to add inbuilt actions to Svelte, similar to how Svelte includes inbuilt transitions. As part of that RFC, the community created a POC library with some commonly-used actions such as longpress, clickOutside, and lazyload.

You may also be interested in a post I wrote last year on using actions to detect when a sticky-positioned element becomes stuck to the viewport.

: Full visibility into your web and mobile apps

LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.

In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page and mobile apps.


More great articles from LogRocket:


.
Geoff Rich I blog about web development, focusing on the frontend. At the moment, I'm particularly interested in Svelte and web accessibility. If you enjoy what you read, come say hi on Twitter.

Leave a Reply