Livewire, Web Components, and the Battle with Attributes
tldr; A bit of initial tweaking is necessary to harmonize Livewire with Web Components, but once set up, they cooperate smoothly.
Laravel and Livewire are respected and well-known for their great DX. Web Components on the other hand are best known for wide compatibility across different JavaScript frameworks AND even with plain HTML. On paper, they seem like a dream team, right?
Well – a while back, I experimented with Shoelace Web Components in combination with Livewire and immediately ran into issues with <sl-button>
elements losing their styles after interaction. I didn't have a pressing use case, so I paused the project. Now, with Livewire 3 being available and a new idea for my website, I wanted to dive back in.
Livewire eats attributes which don't come from the server...
Problem
I installed a fresh Livewire project, added Shoelace via CDN and again replaced <button>
elements with their <sl-button>
counterparts.
After interaction the same style loss happened – for every component on the page.
Here’s a breakdown of the issue:
- Initial Delivery: The server sends the component with its initial attributes:
<!-- counter.blade.php -->
<sl-button wire:click="decrement">
Decrement
</sl-button>
-
Hydration Phase: Upon hydration,
sl-button
adds its own attributes, which aren't known by the server:
<!-- DOM -->
<sl-button variant="default" size="medium" wire:click="decrement">
Decrement
</sl-button>
- Morphing Phase: Alpine.js syncs with the server, finds no matching attributes, and therefore strips them, leading to:
<!-- counter.blade.php & DOM -->
<sl-button wire:click="decrement">
Decrement
</sl-button>
Solution
To fix this, I tweaked Livewire's morphing system to preserve any DOM attributes of custom elements utilizing its morph.updating
hook:
// app.blade.php (after Livewire)
Livewire.hook('morph.updating', ({
el,
component,
toEl
}) => {
// Check if element is a custom element;
if (!el.tagName.includes('-')) {
return;
}
// Store the original attributes.
let oldAttributes = Array.from(el.attributes)
.reduce((attrs, attr) => {
attrs[attr.name] = attr.value;
return attrs;
}, {});
// Restore all attributes that might have been removed by Livewire.
let newAttributes = Array.from(toEl.attributes).map(attr => attr.name);
Object.entries(oldAttributes).forEach(([name, value]) => {
if (!newAttributes.includes(name)) {
toEl.setAttribute(name, value);
}
});
});
Problems solved? Not quite.
...and sometimes it's right about that.
Problem
The situation got trickier when I tried to programmatically disable the button:
<sl-button
{{ $count < 1 ? 'disabled' : '' }}
wire:click="decrement"
>
Decrement
</sl-button>
Let's recall my earlier modification:
I tweaked Livewire's morphing system to preserve any DOM attributes of custom elements [...].
Now, a disabled
attribute, once set, couldn't be removed anymore. 🤦🏻♂️
Solution
I was at the point where I was forced to create workarounds:
- I set the falsy expression to the attribute prefixed with
!
.
<sl-button
{{ $count < 1 ? 'disabled' : '' }}
{{ $count < 1 ? 'disabled' : '!disabled' }}
wire:click="decrement"
>
Decrement
</sl-button>
- My hook got smarter, interpreting a
!
prefix as a cue to drop the attribute in the DOM:
// app.blade.php (after Livewire)
Livewire.hook('morph.updating', ({
el,
component,
toEl
}) => {
// Store the original attributes.
let oldAttributes = Array.from(el.attributes)
.reduce((attrs, attr) => {
attrs[attr.name] = attr.value;
return attrs;
}, {});
// Restore all attributes that might have been removed by Livewire.
let currentAttributes = Array.from(toEl.attributes).map(attr => attr.name);
Object.entries(originalAttributes).forEach(([name, value]) => {
if (!newAttributes.includes(name)) {
if (!name.startsWith('!') && !currentAttributes.includes(name)) {
toEl.setAttribute(name, value);
}
});
// Remove attributes starting with '!' from the `toEl`.
Array.from(toEl.attributes).forEach(attr => {
if (attr.name.startsWith('!')) {
toEl.removeAttribute(attr.name.substring(
1)); // Remove the corresponding actual attribute.
toEl.removeAttribute(attr
.name); // Remove the attribute with the '!' prefix if it exists from initial render.
}
});
});
This isn't standard practice, but it was effective for continuing.
Interoperability solved – but what about DX?
Problem
As initially said, Laravel and Livewire shine in their great DX. Yet, this improvisational "fake manual boolean" began to feel like a step backward. The situation worsened when I brought Livewire's wire:model
into play with other form elements.
Plainly spoken: Form elements and Web Components (especially with ShadowDOM) are challenging. I was even surprised that sl-input
and sl-textarea
just worked out of the box with wire:model
. Still, sl-radio-group
, sl-checkbox
and sl-select
required a more hands-on approach involving event listeners and manual value synchronization:
<!-- Works out of the box! -->
<sl-input wire:model.live='input'></sl-input>
<sl-textarea wire:model.live='textarea'></sl-textarea>
<!-- Needs manual tweaking -->
<sl-checkbox
{{ $checkbox ? 'checked' : '' }}
x-on:sl-change="$wire.set('checkbox', $el.checked);"
></sl-checkbox>
<sl-select
value="{{ $selected }}"
x-on:sl-change="$wire.set('selected', $el.value);"
> ...</sl-select>
<sl-radio-group
value="{{ $radio }}"
x-on:sl-change="$wire.set('radio', $el.value);"
>...</sl-radio-group>
Solution
That's why I decided to write some practical Blade directives for the "hacky boolean toggle" and manual model bindings:
// app/Providers/AppServiceProvider.php
public function boot(): void
{
// Sets an attribute if the value is defined and removes the attribute if undefined.
Blade::directive('wcSetAttribute', function ($arguments) {
list($attribute, $condition) = explode(',', $arguments);
$attribute = trim(str_replace(['"', "'"], '', $attribute));
$condition = trim($condition);
return "<?php echo {$condition} ? '{$attribute}' : '!{$attribute}' ?>";
});
// Creates a model binding for sl-checkbox
Blade::directive('slCheckboxModel', function ($arguments) {
list($expression, $value) = explode(',', str_replace([' ', '"', "'"], '', $arguments));
return "<?php echo {$value} ? 'checked' : '' ?> x-on:sl-change=\"\$wire.set('{$expression}', \$el.checked);\"";
});
// Creates a model binding for sl-select including multiple select
Blade::directive('slSelectModel', function ($arguments) {
list($expression, $value) = explode(',', str_replace([' ', '"', "'"], '', $arguments));
return "value=\"<?php echo is_array({$value}) ? implode(' ', {$value}) : {$value}; ?>\" x-on:sl-change=\"\$wire.set('{$expression}', \$el.value);\"";
});
// Creates a model binding for sl-radio-group
Blade::directive('slRadioGroupModel', function ($arguments) {
list($expression, $value) = explode(',', str_replace([' ', '"', "'"], '', $arguments));
return "value=\"<?php echo {$value}; ?>\" x-on:sl-change=\"\$wire.set('{$expression}', \$el.value);\"";
});
}
This refactoring allowed a much neater usage in our Livewire component:
<sl-button
@wcSetAttribute('disabled', $count < 1)
wire:click="decrement"
>...</sl-button>
<sl-checkbox
@slCheckboxModel('checkbox', $checkbox)
>...</sl-checkbox>
<sl-select
@slSelectModel('selected', $selected)
>...</sl-select>
<sl-radio-group
@slRadioGroupModel('radio', $radio)
>...</sl-radio-group>
There might be a way to make this even simpler, but for now, I'm happy with that. 💪🏻
Conclusion
I'm looking forward to see how it'll work when I add new features to my website. In the meantime, I'd love to hear what you think: Have you ever mixed Livewire with Web Components? Are you thinking about it? Did I miss something in my approach? Jump into the conversation on Mastodon and let me know!