Actions in Svelte: a practical Svelte & SvelteKit guide
Svelte's action directive lets you attach reusable DOM behavior without wrapping elements in components or cluttering your markup with event listeners. Once you start using actions, you'll realize they solve a surprisingly common problem: how do you apply logic to vanilla DOM elements without creating component overhead or duplicating code across your application?
What makes use:action different
In most frameworks, you attach behavior through event handlers, component wrappers, or lifecycle hooks. Svelte's action directive takes a different approach. Instead of binding behavior to a component, you bind it directly to an element using the use: syntax. An action is simply a function that receives a DOM node and returns an object with optional lifecycle methods.
Here's the fundamental pattern:
function myAction(node: HTMLElement) {
// Setup code runs here
console.log('Action attached to:', node);
return {
update(params?: any) {
// Runs when reactive properties change
},
destroy() {
// Cleanup runs here
}
};
}You attach it to an element with use:myAction. The framework handles calling setup, update, and destroy at the right times. This pattern fits naturally into Svelte's reactive model and keeps your component logic focused on state management rather than DOM wrangling.
Click outside: detecting external interactions
One of the most practical uses for actions is detecting when a user clicks outside an element. This pattern powers dropdown menus, modals, popovers, and any UI that should close when focus moves elsewhere. Without actions, you'd need to attach global click listeners in component code, which gets messy fast.
Here's a real example from a typical SvelteKit admin dashboard:
function clickOutside(node: HTMLElement) {
function handleClick(event: MouseEvent) {
if (node && !node.contains(event.target as Node) && !event.defaultPrevented) {
node.dispatchEvent(new CustomEvent('clickoutside'));
}
}
document.addEventListener('click', handleClick, true);
return {
destroy() {
document.removeEventListener('click', handleClick, true);
}
};
}Now you use it in your dropdown component:
<script>
let isOpen = false;
function closeDropdown() {
isOpen = false;
}
</script>
<div use:clickOutside on:clickoutside={closeDropdown}>
<button on:click={() => (isOpen = !isOpen)}>Menu</button>
{#if isOpen}
<div class="dropdown">
<a href="/profile">Profile</a>
<a href="/settings">Settings</a>
<a href="/logout">Logout</a>
</div>
{/if}
</div>The action attaches a global listener only when the component mounts, cleans it up when it unmounts, and dispatches a custom event that your component listens to. No manual addEventListener or removeEventListener calls scattered throughout your component code.
Tooltips: data-driven behavior
Actions shine when you need to apply the same behavior to many elements with different configurations. Tooltips are a perfect example. You pass parameters to an action using the reactive syntax, and the action handles positioning, show/hide logic, and DOM manipulation.
Here's a practical tooltip action used in a SaaS billing interface:
function tooltip(node: HTMLElement, text: string) {
let tooltipEl: HTMLElement;
function show() {
tooltipEl = document.createElement('div');
tooltipEl.textContent = text;
tooltipEl.className = 'tooltip';
const rect = node.getBoundingClientRect();
tooltipEl.style.position = 'absolute';
tooltipEl.style.top = (rect.top - 40) + 'px';
tooltipEl.style.left = (rect.left + rect.width / 2) + 'px';
document.body.appendChild(tooltipEl);
}
function hide() {
if (tooltipEl) tooltipEl.remove();
}
node.addEventListener('mouseenter', show);
node.addEventListener('mouseleave', hide);
return {
update(newText: string) {
text = newText;
if (tooltipEl) tooltipEl.textContent = text;
},
destroy() {
node.removeEventListener('mouseenter', show);
node.removeEventListener('mouseleave', hide);
hide();
}
};
}Using it in your component:
<script>
let helpText = 'Your current plan renews on March 15, 2025';
</script>
<button use:tooltip={helpText}>
Plan Details
</button>
<button use:tooltip={'Upgrade to unlock advanced features'}>
Upgrade
</button>If your help text changes (say, after fetching from an API), the action's update method runs automatically. The action owns all positioning logic, so you're not duplicating that code across components.
Focus traps: keyboard navigation in modals
Modal dialogs need to trap keyboard focus—when users press Tab, focus should cycle within the modal rather than escaping to the page behind it. This is both a UX best practice and an accessibility requirement. An action handles this cleanly.
function focusTrap(node: HTMLElement) {
function handleKeyDown(e: KeyboardEvent) {
if (e.key !== 'Tab') return;
const focusableElements = node.querySelectorAll(
'a, button, input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (focusableElements.length === 0) return;
const firstElement = focusableElements[0] as HTMLElement;
const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
if (e.shiftKey && document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
} else if (!e.shiftKey && document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}
node.addEventListener('keydown', handleKeyDown);
return {
destroy() {
node.removeEventListener('keydown', handleKeyDown);
}
};
}Apply it to your modal:
<script>
let isOpen = false;
</script>
{#if isOpen}
<div class="modal-overlay" on:click={() => (isOpen = false)}>
<div class="modal" use:focusTrap on:click|stopPropagation>
<h2>Confirm Action</h2>
<p>Are you sure you want to proceed?</p>
<button on:click={() => (isOpen = false)}>Cancel</button>
<button on:click={handleConfirm}>Confirm</button>
</div>
</div>
{/if}Users can now navigate through interactive elements with Tab and Shift+Tab without leaving the modal. The action is completely reusable—drop it on any element that needs focus containment.
Return, update, and destroy lifecycle
Understanding the action lifecycle is key to building robust reusable behavior. When you attach use:action to an element, Svelte calls your action function immediately, passing the node. Your function can return an object with three optional methods.
The update method runs whenever the reactive properties you pass to the action change. For instance, if your component recalculates a position or label, you can react to that change inside the action without remounting or reinitializing.
The destroy method always runs when the component unmounts or the element is removed. This is where you remove event listeners, clear timeouts, and cancel any pending operations. Forgetting cleanup is a common source of memory leaks and stale event handlers.
Here's an action that demonstrates all three phases:
function timer(node: HTMLElement, seconds: number) {
let count = seconds;
let interval: number;
function start() {
interval = window.setInterval(() => {
count--;
node.textContent = `${count}s remaining`;
if (count <= 0) {
clearInterval(interval);
node.dispatchEvent(new CustomEvent('complete'));
}
}, 1000);
}
start();
return {
update(newSeconds: number) {
// If the duration changes, reset
clearInterval(interval);
count = newSeconds;
start();
},
destroy() {
clearInterval(interval);
}
};
}This action starts a timer when mounted, updates it if the duration prop changes, and cleans up the interval on destroy. Every lifecycle phase is explicit and testable.
Attachments: a newer alternative
Svelte 5 introduced attachments as an alternative to actions for cases where you need to add behavior without accessing the DOM node directly. Attachments are simpler when you're not doing DOM manipulation—they're particularly useful for setting up subscriptions or reactive behavior triggered by element lifecycle.
An attachment is defined similarly to an action but works at a higher level of abstraction:
function myAttachment() {
return {
setup() {
// Runs when the element is created
},
update() {
// Runs when reactive values change
},
teardown() {
// Runs when the element is removed
}
};
}In practice, most of what you'd reach for actions to do still requires direct DOM access, so actions remain the go-to choice for form validation, DOM event binding, and dynamic positioning. Attachments excel when you're orchestrating behavior that's more about component logic than element manipulation. For the vast majority of production use cases we've seen in e-commerce and SaaS projects, actions are what you'll reach for first.
Common mistakes
Forgetting cleanup in destroy: If your action adds event listeners, appends DOM nodes, or starts timers, not cleaning them up in destroy will leak memory and leave stale handlers behind. Every addEventListener needs a removeEventListener in destroy.
Not handling update for reactive parameters: If you pass a reactive variable to an action and it changes, your action needs an update method to react to that change. Otherwise, the action uses stale data.
Coupling actions too tightly to component state: Actions are most reusable when they dispatch custom events rather than directly calling component functions. This keeps the action self-contained and lets you use it in different contexts.
Assuming action runs after mount: The action function runs synchronously when the directive is evaluated. If you need to wait for the DOM to be fully painted, use setTimeout(..., 0) or requestAnimationFrame inside your action.
Key takeaways
- Actions attach reusable behavior to DOM elements without wrapping them in components, reducing boilerplate across your codebase.
- Use the
use:actiondirective to apply a function to an element; that function receives the node and can set up listeners, DOM manipulation, or subscriptions. - Return an object with optional
updateanddestroymethods to handle reactive changes and cleanup respectively. - Common patterns like click-outside, tooltips, and focus traps are excellent candidates for actions because they're used repeatedly across many components.
- Always clean up event listeners, intervals, and DOM nodes in the destroy method to avoid memory leaks.
- For behavior that doesn't require direct DOM access, consider attachments (Svelte 5+) as a more lightweight alternative.
- Dispatch custom events from actions rather than calling component methods directly to keep actions loosely coupled and maximally reusable.






Comments
Loading comments…