DEV Community

Cover image for Svelte Event Forwarding & Advanced Component Patterns
Ali Aslam
Ali Aslam

Posted on

Svelte Event Forwarding & Advanced Component Patterns

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>
Enter fullscreen mode Exit fullscreen mode

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 clicks, 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>
Enter fullscreen mode Exit fullscreen mode

What’s happening?

  1. We import Button from Step 1.
  2. We add a class="fancy" for styling.
  3. Most importantly, we forward the parent’s onclick down to the real <button> inside Button.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>
Enter fullscreen mode Exit fullscreen mode

What happens when you click?

  1. The real <button> (inside Button.svelte) fires a native click.
  2. FancyButton passes the parent’s handleClick into it.
  3. handleClick runs, incrementing count.
  4. 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" />
Enter fullscreen mode Exit fullscreen mode

Now use it in the same parent as before:

<BrokenFancyButton onclick={handleClick} />
Enter fullscreen mode Exit fullscreen mode

What happens?

  • You click the inner <button>.
  • But since BrokenFancyButton never forwards onclick, the parent’s handleClick 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}
/>
Enter fullscreen mode Exit fullscreen mode

What’s happening?

  • We accept oninput, onfocus, and onblur 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>
Enter fullscreen mode Exit fullscreen mode

What happens?

  • Typing triggers oninput, which updates name.
  • 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!")} />
Enter fullscreen mode Exit fullscreen mode

…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 wraps Button.

  • Events can get trapped.
    Example: BrokenFancyButton — no forwarding, parent never hears the click.

  • Forwarding makes wrappers invisible.
    With on: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 (like oninput or onfocus) 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>
Enter fullscreen mode Exit fullscreen mode

📂 src/routes/+page.svelte

<script>
  import UncontrolledInput from '$lib/UncontrolledInput.svelte';
</script>

<UncontrolledInput />
Enter fullscreen mode Exit fullscreen mode

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} />
Enter fullscreen mode Exit fullscreen mode

📂 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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

What’s happening?

  • Parent owns the messages list.
  • Each time ChatInput calls onsend, 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)