Introduction: The Bridge Between Declarative and Imperative
When you first start building with Svelte, you fall in love with its declarative nature. You update a variable, and the DOM magically reflects that change. However, every web developer eventually hits a wall where declarative code isn’t enough. Perhaps you need to integrate a legacy jQuery plugin, manage complex focus states for accessibility, or listen for clicks outside a modal window.
This is where Svelte Actions come in. Actions are the “Swiss Army Knife” of the Svelte ecosystem. They provide a clean, reusable way to apply imperative logic to DOM elements without cluttering your component’s core logic. If Svelte components are the “what,” then Svelte Actions are often the “how” when it comes to low-level browser interactions.
In this guide, we are going to go deep. We won’t just look at basic syntax; we will explore how to build high-performance, reusable interactions that can be shared across projects. Whether you are a beginner looking to understand the use: directive or an expert aiming to optimize memory management in complex dashboards, this guide is for you.
What Exactly is a Svelte Action?
At its core, a Svelte Action is a simple function that is called when an element is created. The function can return an object with an update method (called when the parameters change) and a destroy method (called when the element is removed from the DOM).
The syntax looks like this: <div use:myAction>.
Why use actions instead of onMount? While onMount is great for component-level logic, actions are attached to specific elements. This makes them highly portable. You can define an action in one file and use it across dozens of different components, keeping your code DRY (Don’t Repeat Yourself).
The Anatomy of an Action
Before we build anything complex, let’s look at the basic structure of an action function. Understanding the lifecycle is crucial for avoiding memory leaks and ensuring your application remains fast.
/**
* @param {HTMLElement} node - The DOM element the action is attached to
* @param {any} params - The argument passed to the action
*/
export function myAction(node, params) {
// 1. Initialization: This runs when the element enters the DOM
console.log('The element was created!', node);
return {
// 2. Update: This runs whenever the 'params' value changes
update(newParams) {
console.log('Params changed to:', newParams);
},
// 3. Destroy: This runs when the element is removed from the DOM
destroy() {
console.log('Cleanup logic goes here');
}
};
}
1. The Initialization Phase
When the node is first rendered, Svelte calls your function. This is the perfect place to set up event listeners, initialize third-party libraries (like Chart.js or D3), or start an IntersectionObserver.
2. The Update Phase
If you pass a value to your action, like use:myAction={count}, Svelte will track that value. Whenever count changes, the update method inside your action is triggered. This allows your imperative logic to stay in sync with Svelte’s reactive state.
3. The Destroy Phase
This is arguably the most important part. If you add a window.addEventListener in your action, you must remove it in the destroy method. Failure to do so leads to memory leaks, which can slow down your application or cause crashes in long-lived browser tabs.
Real-World Example 1: The “Click Outside” Action
A common requirement for modals, dropdowns, and tooltips is closing the element when the user clicks anywhere else on the page. Doing this manually in every component is tedious. Let’s build a reusable action for this.
// clickOutside.js
export function clickOutside(node) {
const handleClick = (event) => {
// Check if the click happened outside the element and its children
if (node && !node.contains(event.target) && !event.defaultPrevented) {
// Dispatch a custom event that the component can listen to
node.dispatchEvent(new CustomEvent('click_outside'));
}
};
// Attach listener to the document
document.addEventListener('click', handleClick, true);
return {
destroy() {
// Clean up to prevent memory leaks
document.removeEventListener('click', handleClick, true);
}
};
}
How to use it in a component:
<script>
import { clickOutside } from './clickOutside.js';
let isModalOpen = true;
function closeModal() {
isModalOpen = false;
}
</script>
{#if isModalOpen}
<div
class="modal"
use:clickOutside
on:click_outside={closeModal}
>
<p>Click anywhere outside this box to close me!</p>
</div>
{/if}
<style>
.modal {
border: 1px solid #ccc;
padding: 20px;
background: white;
}
</style>
Why this is powerful: You’ve just created a global behavior that can be applied to any element with a single line of code. Notice the use of CustomEvent. This allows the action to communicate back to the Svelte component in a way that feels native to the framework.
Real-World Example 2: Intersection Observer (Lazy Loading)
Modern web performance relies heavily on not loading what you don’t need. The IntersectionObserver API is perfect for this, but its imperative nature can be messy inside a script tag. Let’s wrap it in a Svelte Action.
// lazyLoad.js
export function lazyLoad(node, src) {
const options = {
root: null, // use the viewport
rootMargin: '0px',
threshold: 0.1 // trigger when 10% of the image is visible
};
const observer = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// Set the src attribute to trigger the image load
node.src = src;
// Once loaded, we don't need to observe it anymore
observer.unobserve(node);
}
});
}, options);
observer.observe(node);
return {
destroy() {
observer.disconnect();
}
};
}
In this example, we pass the src as a parameter. This demonstrates how actions can receive data from the parent component to perform specific tasks.
Managing Dynamic Parameters
What happens if the parameters passed to an action change? For example, consider a tooltip action where the text might update based on user input.
// tooltip.js
import tippy from 'tippy.js';
import 'tippy.js/dist/tippy.css';
export function tooltip(node, content) {
// Initialize the library
const instance = tippy(node, { content });
return {
update(newContent) {
// Update the library instance when the parameter changes
instance.setContent(newContent);
},
destroy() {
instance.destroy();
}
};
}
Without the update method, the tooltip would show stale data. By including it, we ensure that our imperative library (Tippy.js) stays in sync with Svelte’s state. This is the bridge that makes Svelte so friendly with the vast ecosystem of vanilla JavaScript libraries.
Advanced Topic: Using TypeScript with Actions
If you are using TypeScript (which is highly recommended for Svelte projects), you want your actions to be type-safe. In Svelte 4, we use the Action type from the svelte/action package.
import type { Action } from 'svelte/action';
interface ActionAttributes {
'on:click_outside'?: (event: CustomEvent) => void;
}
export const clickOutside: Action<HTMLElement, any, ActionAttributes> = (node) => {
const handleClick = (event: MouseEvent) => {
if (node && !node.contains(event.target as Node)) {
node.dispatchEvent(new CustomEvent('click_outside'));
}
};
document.addEventListener('click', handleClick, true);
return {
destroy() {
document.removeEventListener('click', handleClick, true);
}
};
};
By defining ActionAttributes, you provide autocomplete and type-checking for the custom events your action dispatches. This prevents bugs where you might misspell an event name in your component.
Common Mistakes and How to Avoid Them
1. Forgetting the Destroy Method
The most common mistake is failing to clean up. If you use setInterval, requestAnimationFrame, or addEventListener inside an action, you must stop or remove them in destroy. If you don’t, every time the component re-renders or the element is toggled, a new listener is added, leading to memory leaks and erratic behavior.
2. Not Using the Update Method
Developers often pass variables to actions but forget that those variables might change. Always ask yourself: “Should this action react when this value changes?” If the answer is yes, implement the update method.
3. Overusing Actions
Don’t use actions for things Svelte can do natively. For example, adding a CSS class based on a state variable should be done with class:active={isActive}, not a custom action. Actions are for imperative tasks that Svelte doesn’t have a built-in syntax for.
4. Scope Issues with “this”
Inside an action function, the value of this might not be what you expect. Always use the node parameter provided by the function signature to refer to the element.
Step-by-Step: Creating an Auto-Resizing Textarea
Let’s walk through building a practical action from scratch. A common UX requirement is a textarea that grows in height as the user types.
- Define the function: Create a function that takes the
node(the textarea). - Initial Setup: Set the initial height and overflow properties.
- The Logic: Create a function called
resizethat sets the height toscrollMax. - Event Listeners: Attach the
inputevent listener to the textarea. - Cleanup: Remove the listener in the
destroymethod.
export function autoResize(node) {
function resize() {
node.style.height = 'auto';
node.style.height = node.scrollHeight + 'px';
}
node.style.overflow = 'hidden';
node.addEventListener('input', resize);
// Initial resize for pre-filled content
resize();
return {
destroy() {
node.removeEventListener('input', resize);
}
};
}
Svelte Actions vs. Svelte 5 Runes
With the advent of Svelte 5, the framework introduces “Runes” (like $state and $effect). You might wonder if actions are still relevant. The answer is a resounding yes.
While Runes make state management easier, the use: directive remains the primary way to hook into the lifecycle of a specific DOM element. Svelte 5 even improves actions by making the parameter handling more intuitive. Actions provide an encapsulation layer that Runes aren’t designed to replace; they work together.
Performance Optimization Tips
- Debounce Heavy Tasks: If your action responds to window resizing or scrolling, use a debounce or throttle function to avoid choking the main thread.
- Passive Listeners: When adding scroll or touch events, use
{ passive: true }in youraddEventListenerto improve scrolling performance. - Lightweight Dependencies: If your action wraps a library, consider using dynamic imports (
import()) inside the action so the library code is only downloaded when the action is actually used.
Summary / Key Takeaways
- Svelte Actions are functions used with the
use:directive to apply imperative logic to DOM elements. - They have a simple lifecycle: Initialization, Update, and Destroy.
- Use Custom Events to send data from the action back to the component.
- Always cleanup event listeners and timers in the
destroymethod to prevent memory leaks. - Actions are perfect for integrating third-party libraries like Tippy.js, Chart.js, or D3.
- Use TypeScript to ensure type safety for parameters and custom events.
Frequently Asked Questions (FAQ)
1. Can I use multiple actions on a single element?
Yes! You can stack them like this: <div use:actionOne use:actionTwo={data}>. They will execute in the order they are defined.
2. Can I pass multiple parameters to an action?
An action only accepts one parameter. However, that parameter can be an object. For example: use:myAction={{ color: 'red', speed: 100 }}.
3. Do actions run on the server during SSR?
No. Svelte actions are strictly client-side. They only run when the component is hydrated in the browser. This is helpful because it means you don’t have to check if (typeof window !== 'undefined') inside your action.
4. Why should I use an action instead of an onMount hook?
Actions are more reusable and focused on the element. If you have logic that needs to be applied to many different elements in different components, an action is much cleaner than repeating onMount logic and managing bind:this references.
5. How do I handle reactivity if my parameter is a complex object?
When the update method is called, Svelte provides the new version of that object. If you need to compare it with the old version, you’ll need to store the previous state within the action’s closure.
