About State Binding In livewire and Blade with Alpinejs
We built Fluxtor to work out of the box with typical Laravel apps no big JavaScript frameworks required. Just Blade, Alpine, and optionally Livewire.
But to make the most of it, we follow a small pattern that lets our components work smoothly in both Livewire and plain Blade. Once you get the idea, it’s super flexible.
Using with Livewire
All dynamic components in Fluxtor work with Livewire the same way you'd expect. Just bind your property using wire:model
, like you normally do:
<x-ui.key-value wire:model="configurations" />
That’s it. Livewire will pick up the value, and it’ll react like any other input field.
Using with Raw Blade
Now if you're just using Blade + AlpineJS (no Livewire), we’ve still got you covered.
The pattern we suggest is binding the state using x-model
, then syncing it through a hidden input like this:
<x-ui.key-value x-model="configurations" /> <input type="hidden" name="configurations" x-model="configurations" /> <!-- Now you’ll get the value in your controller as usual -->
So whether you’re submitting with a form or capturing it via JS, you’re good.
A Bit of Theory
Under the hood, Fluxtor components expose a state
a global value that needs to be either synced with Livewire or sent through a request. That’s where x-model
and wire:model
come into play.
You can use either of them depending on your setup, and things will just work. But how?
Well, shoutout to Caleb Porzio who introduced x-modelable
an underrated gem in Alpine. It makes it super easy to sync component state with an outside model.
Here’s a quick refresher:
<div x-data="{ number: 5 }"> <!-- `count` is exposed and bound to `number` --> <div x-data="{ count: 0 }" x-modelable="count" x-model="number"> <button @click="count++">Increment</button> </div> Number: <span x-text="number"></span> </div>
But sometimes that’s just not enough especially when you want more control over the shape of the state or need two-way syncing with extra logic. So we go a step further and bind the state manually inside the component.
Fun Fact About Livewire
Dig into the Livewire source code and you’ll see this:
wire:model
is basically just a powered-up x-model
. Under the hood, it uses Alpine’s bind()
utility.
You can see that in action here.
So yeah, next time you use wire:model
, know that it’s just x-model
with some Laravel seasoning.
And guess what? When Alpine sees an element with x-model
, it adds a _x_model
property to the element, which you can access and modify directly even if it’s not a form input read this.
Here's how:
- Get the value:
$el._x_model.get()
- Set the value:
$el._x_model.set(newValue)
Neat, right?
Real Example
Let’s take our Key Value component as a real-world example. Here's how we wire the state inside it:
<div x-data="{ state: [], ... init() { this.$nextTick(() => { this.state = this.$root?._x_model?.get() ?? [] ... }) this.$watch('state', (value) => { // Sync with Alpine state this.$root?._x_model?.set(value); // Sync with Livewire state if(this.$wire){ let wireModel = this?.$root.getAttributeNames().find(n => n.startsWith('wire:model')) let prop = this.$root.getAttribute(wireModel) this.$wire.set(prop, value, wireModel.includes('.live')); } }); }, ... }" >
So what’s going on?
- We check if there’s an existing external state to pull in
- We assign it to our internal
state
- Then anytime
state
changes, we push it back to the external model (x-model
orwire:model
) usingset()
This pattern gives us full control without breaking Livewire or Blade compatibility. It’s how we make components that are truly reusable, reactive, and enjoyable to work with no matter your setup.
Design Patterns
As a team working on this amazing project, we’ve defined a few essential rules to guide how we design and manage component styles, especially when it comes to handling variants.
data-slot
for Core Elements
Rule #1: Use Every essential element inside a component gets a data-slot="..."
attribute. For example:
data-slot="tabs-group"
data-slot="tabs-item"
data-slot="tabs-panel"
This pattern was inspired by Adam Wathan’s talk at Laracon, where he broke down how to build scalable UI libraries.
In our case, this convention has been a game changer especially for managing multiple variants of the same component in a clean, CSS-first way.
For instance, take our Tabs component. When the tabs group is left- or right-aligned, we want to dynamically remove panel rounding based on whether the active tab is the first or last item.
Now here’s the catch: we don’t use any JS, no custom props, no extra logic just pure CSS selectors made possible by the data-slot
attributes.
Here’s a real example for the outlined
variant:
$classes = match($variant){ 'outlined' => [ 'dark:text-gray-200 text-gray-800 rounded-3xl', // If tabs are left-aligned and the first tab is active, remove top-left rounding from the panels '[&:has(:first-child[data-slot=tabs-group].justify-start_>_:first-child[data-active=true])_[data-slot=tabs-panel]]:rounded-tl-none', // Same, but on :focus or :hover '[&:has(:first-child[data-slot=tabs-group].justify-start_>_:first-child:is(:focus,:hover))_[data-slot=tabs-panel]]:rounded-tl-none', // If tabs are right-aligned and the last tab is active, remove top-right rounding from the panels '[&:has(:first-child[data-slot=tabs-group].justify-end_>_:last-child[data-active=true])_[data-slot=tabs-panel]]:rounded-tr-none', // Same, but on :focus or :hover '[&:has(:first-child[data-slot=tabs-group].justify-end_>_:last-child:is(:focus,:hover))_[data-slot=tabs-panel]]:rounded-tr-none', ], ... };
that's all driven by css selectors in specific cretarias
Rule #2: Hover States
I will write this tomorrow
Rule #3:
also this.