Imagine you’re in a game of telephone, commonly known as Chinese whisper. One person whispers a message, and it gets passed down the line until the right person finally hears it. That’s event forwarding in Svelte: a child hears something (like a click
), but instead of handling it itself, it passes the mic to the parent so the parent decides what happens.
This is super handy when you’re making reusable components. Maybe you want a button that looks fancy or has extra markup, but you don’t want that component to decide what happens when clicked. That’s the parent’s job.
Let’s build this step by step.
Step 1: The Basic Button
📂 src/lib/Button.svelte
<script>
// accept a click handler from the parent
let { onclick } = $props();
</script>
<button onclick={onclick}>
Click me
</button>
What’s happening?
- The
<button>
is just a normal HTML button. - We give it an
onclick={onclick}
attribute. - That
onclick
comes from the parent, passed down as a prop. - So when the user clicks, the parent’s function runs.
👉 This is the simplest form of event wiring.
The button itself doesn’t know what happens when it’s clicked. It just calls the parent’s handler.
Think of it as a click factory: it produces click
s, but it doesn’t care how they’re used.
Step 2: The Fancy Button
📂 src/lib/FancyButton.svelte
<script>
import Button from './Button.svelte';
// forward the parent's click handler
let { onclick } = $props();
</script>
<Button class="fancy" onclick={onclick} />
<style>
.fancy {
background-color: blue;
color: white;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
}
</style>
What’s happening?
- We import
Button
from Step 1. - We add a
class="fancy"
for styling. - Most importantly, we forward the parent’s
onclick
down to the real<button>
insideButton.svelte
.
👉 Without that onclick={onclick}
, clicks would stop here and never reach the parent.
So FancyButton
is a transparent wrapper: it decorates the button but doesn’t interfere with its behavior.
Step 3: Using It in the Parent
📂 src/routes/+page.svelte
<script>
import FancyButton from '$lib/FancyButton.svelte';
let count = $state(0);
function handleClick() {
count++;
}
</script>
<h1>Event Forwarding Demo</h1>
<FancyButton onclick={handleClick} />
<p>You’ve clicked {count} times</p>
What happens when you click?
- The real
<button>
(insideButton.svelte
) fires a native click. -
FancyButton
passes the parent’shandleClick
into it. -
handleClick
runs, incrementingcount
. - The
<p>
updates with the new count.
👉 It’s like a telephone game:
Button
whispers → FancyButton
relays → Parent hears it.
Step 4: What If We Don’t Forward?
📂 src/lib/BrokenFancyButton.svelte
<script>
import Button from './Button.svelte';
</script>
<!-- No onclick forwarding here -->
<Button class="fancy" />
Now use it in the same parent as before:
<BrokenFancyButton onclick={handleClick} />
What happens?
- You click the inner
<button>
. - But since
BrokenFancyButton
never forwardsonclick
, the parent’shandleClick
is never called.
👉 The event chain breaks.
It’s like a mail carrier who takes your letter but never delivers it.
Step 5: Forwarding More Than Clicks
📂 src/lib/TextField.svelte
<script>
// accept multiple event handlers
let { oninput, onfocus, onblur, placeholder } = $props();
</script>
<input
placeholder={placeholder}
oninput={oninput}
onfocus={onfocus}
onblur={onblur}
/>
What’s happening?
- We accept
oninput
,onfocus
, andonblur
from the parent. - We pass them directly to the real
<input>
. - The wrapper itself doesn’t decide what happens — it just relays events.
👉 The input is like a walkie-talkie 📻:
“Typed something!” → Parent hears.
“Focused!” → Parent hears.
“Blurred!” → Parent hears.
📂 src/routes/+page.svelte
<script>
import TextField from '$lib/TextField.svelte';
let name = $state('');
</script>
<TextField
placeholder="Enter your name"
oninput={(e) => name = e.target.value}
onfocus={() => console.log("Focused!")}
onblur={() => console.log("Blurred!")}
/>
<p>You typed: {name}</p>
What happens?
- Typing triggers
oninput
, which updatesname
. - Clicking in/out logs focus/blur in the console.
- To the parent,
TextField
feels exactly like a plain<input>
.
Step 6: Staying in Control
Here’s the key detail:
👉 Forwarding is selective and explicit.
If we leave out onblur
in TextField.svelte
, then this code:
<TextField onblur={() => console.log("Blurred!")} />
…will ❌ never fire.
That’s not a bug — it’s intentional.
As the wrapper author, you choose which events become public.
This keeps your component’s API clean: parents hear only the signals you want to expose.
Step 7: The Big Picture
Let’s pause and zoom out 🌲.
What have we learned so far?
Components can wrap other elements.
Example:FancyButton
wrapsButton
.Events can get trapped.
Example:BrokenFancyButton
— no forwarding, parent never hears the click.Forwarding makes wrappers invisible.
Withon:click
, the parent can’t even tell if it’s talking to a plain<button>
or a wrapper.Forwarding is explicit.
Only the events you choose to forward (likeoninput
oronfocus
) survive the trip upward.
👉 That’s the beauty of event forwarding: it makes components reusable, predictable, and transparent.
It’s like the telephone game — as long as each person whispers the message along, the chain works no matter how many people are in the middle.
Step 8: Controlled vs Uncontrolled Components 🎛️
So far, we’ve been talking about events — how parents know that something happened.
But what about data — who owns the value of an input?
This brings us to a classic design choice:
👉 Controlled vs uncontrolled components.
- Uncontrolled → the child is the boss of its own state.
- Controlled → the parent is the boss, child is just a mirror.
Uncontrolled Component — child is the boss
📂 src/lib/UncontrolledInput.svelte
<script>
let text = $state('');
</script>
<input bind:value={text} />
<p>Local value: {text}</p>
📂 src/routes/+page.svelte
<script>
import UncontrolledInput from '$lib/UncontrolledInput.svelte';
</script>
<UncontrolledInput />
What happens?
- The input is bound to
text
inside the child. - Typing updates only the child’s local state.
- The parent doesn’t know what’s going on.
👉 It’s like a teenager with their own room 🛋️.
They control it, they decorate it, and the parent has no visibility.
Controlled Component — parent is the boss
📂 src/lib/ControlledInput.svelte
<script>
let { value = $bindable() } = $props();
</script>
<input bind:value={value} />
📂 src/routes/+page.svelte
<script>
import ControlledInput from '$lib/ControlledInput.svelte';
let name = $state('Ada');
</script>
<ControlledInput bind:value={name} />
<p>Parent sees: {name}</p>
What happens?
- The parent owns the
name
state. - The child just mirrors it via
bind:value={value}
. - Changing the input updates the parent’s
name
. - Changing
name
in the parent would also update the input.
👉 This is like the parent keeping a spare key 🔑 to the teenager’s room.
The teen can still decorate, but the parent always knows what’s happening.
Which to pick?
- Uncontrolled = simpler, self-contained, great for isolated widgets.
- Controlled = safer when the parent needs visibility (validation, syncing, sharing state).
Rule of thumb:
👉 Use controlled when parent needs to stay in charge.
👉 Use uncontrolled when local state is enough.
Step 9: Mini-Project — Chat Input with Emoji Picker 💬😎
Let’s build something more realistic: a chat box where you can type messages and insert emojis.
This will use all four superpowers:
- Props → parent says what to do when sending.
- Bindings → input stays in sync with local state.
- Events → emoji picker announces emoji clicks.
- Forwarding → chat input relays messages up to the parent.
9a) The EmojiPicker
📂 src/lib/EmojiPicker.svelte
<script>
let { onselect } = $props();
const emojis = ["😀", "😂", "🥳", "😎", "❤️"];
</script>
<div>
{#each emojis as emoji}
<button type="button" onclick={() => onselect?.(emoji)}>
{emoji}
</button>
{/each}
</div>
What’s happening?
- The parent gives us an
onselect
callback. - When you click an emoji, we call it with that emoji.
- The picker doesn’t decide what happens — it just reports.
👉 Like a waiter taking your order 🍽️. They don’t eat the food — they just deliver it to the kitchen.
9b) The ChatInput
📂 src/lib/ChatInput.svelte
<script>
import EmojiPicker from './EmojiPicker.svelte';
let { onsend } = $props();
let message = $state('');
function sendMessage() {
if (message.trim()) {
onsend?.(message);
message = '';
}
}
</script>
<div>
<input
placeholder="Type a message..."
bind:value={message}
onkeydown={(e) => e.key === "Enter" && sendMessage()}
/>
<EmojiPicker onselect={(emoji) => message += emoji} />
<button type="button" onclick={sendMessage}>
Send
</button>
</div>
What’s happening?
- Parent provides an
onsend
function. -
message
holds the input text. - Press Enter or click Send → we call
onsend(message)
. - Emoji clicks just append characters to
message
.
👉 ChatInput
is like a translator: it listens to typing and emojis, then passes the finished message up.
9c) The Parent Page
📂 src/routes/+page.svelte
<script>
import ChatInput from '$lib/ChatInput.svelte';
let messages = $state([]);
</script>
<h1>Chat Demo</h1>
<ChatInput onsend={(msg) => messages = [...messages, msg]} />
<ul>
{#each messages as msg}
<li>{msg}</li>
{/each}
</ul>
What’s happening?
- Parent owns the
messages
list. - Each time
ChatInput
callsonsend
, we append to the list. - Parent doesn’t care how typing or emojis work — it just gets the finished messages.
Step 10: Wrapping Up 🎁
We’ve covered a lot:
- Started with a plain
Button
. - Wrapped it with
FancyButton
. - Broke it with
BrokenFancyButton
to see why forwarding matters. - Built
TextField
to forward multiple events. - Compared controlled vs uncontrolled inputs.
- Finished with a chat demo combining everything.
👉 The big lesson:
With just four tools — props, events, bindings, and forwarding — you can make components that are transparent, reusable, and powerful.
These aren’t toy examples — the same patterns apply in real apps: forms, media players, dashboards.
So next time you wrap a component, ask:
💡 Am I still passing the message along?
If yes → the telephone game continues, and your app stays predictable. 📞✨
Next in the Svelte Deep Dive Series, we’ll cover conditionals and loops — bringing your components to life with {#if}, {#each}, and keyed lists.
Follow me on DEV for future posts in this deep-dive series.
https://dev.to/a1guy
If it helped, leave a reaction (heart / bookmark) — it keeps me motivated to create more content
Checkout my offering on YouTube with (growing) crash courses and content on JavaScript, React, TypeScript, Rust, WebAssembly, AI Prompt Engineering and more: @LearnAwesome
Top comments (0)