Reusable Blade Components with Alpine.js

/ Content

Laravel Breeze ships with several Blade components that use Alpine.js. Dropdowns, modals, responsive nav menus. They're good examples of encapsulating interactivity inside a reusable component. What they don't show is how to build components that accept data from the outside and keep it in sync.

Say you're integrating a third-party JavaScript library like Flatpickr, TomSelect, or a rich text editor. You want to wrap it in a Blade component so you can drop it into forms without repeating setup code. The component needs its own Alpine state to manage the library instance, but it also needs to bind to a parent component's data so form submissions work. That's where things get interesting.

The Problem

Flatpickr is a simple date picker. No dependencies, works great. Basic setup looks like this:

<input type="text" id="date-input">
<script>
    flatpickr('#date-input', {});
</script>

We could wrap this in Alpine to make it reactive:

<div x-data="{ selectedDate: '' }">
    <input 
        type="text"
        x-ref="picker"
        x-init="flatpickr($refs.picker, {
            onChange: (dates, dateStr) => selectedDate = dateStr
        })"
    >
    <p>Selected: <span x-text="selectedDate"></span></p>
</div>

This works, but it's all inline. If you need this date picker in multiple places, you're copying the same initialization code everywhere. You want a Blade component:

<form x-data="{ startDate: '', endDate: '' }">
    <x-flatpickr x-model="startDate" />
    <x-flatpickr x-model="endDate" />
    <button type="submit">Submit</button>
</form>

The question is how to build <x-flatpickr> so the x-model on the outside binds to the picker's value on the inside.

First Attempt

A naive approach might look like this:

{{-- resources/views/components/flatpickr.blade.php --}}
<div x-data="{ value: '' }">
    <input 
        type="text"
        x-ref="input"
        x-model="value"
        x-init="flatpickr($refs.input, {
            onChange: (dates, dateStr) => value = dateStr
        })"
        {{ $attributes }}
    >
</div>

If you use this component with <x-flatpickr x-model="startDate" />, the rendered HTML looks wrong. The x-model="startDate" ends up on the inner input alongside x-model="value". You've got two x-model directives fighting each other, and the component's internal x-data doesn't know about startDate from the parent scope.

Reading Attributes

Blade components can access attributes passed to them through the $attributes variable. We can grab the x-model attribute specifically and use it inside the component:

{{-- resources/views/components/flatpickr.blade.php --}}
@props(['xModel' => null])

@php
    $xModel = $xModel ?? $attributes->get('x-model');
@endphp

<div x-data="{ value: '' }">
    <input 
        type="text"
        x-ref="input"
        x-model="{{ $xModel }}"
        x-init="flatpickr($refs.input, {
            onChange: (dates, dateStr) => {{ $xModel }} = dateStr
        })"
        {{ $attributes->except('x-model') }}
    >
</div>

Now we're extracting x-model from the attributes and placing it where we need it. The {{ $xModel }} will output startDate in the rendered HTML.

But there's still a problem. The {{ $xModel }} renders literally as startDate, which means we're setting a variable called startDate that exists in the outer scope. The component's own x-data="{ value: '' }" creates an isolated scope, but we're reaching past it. This works for pushing data out, but what about pulling data in? If the parent changes startDate, the picker won't know about it.

x-modelable

Alpine provides x-modelable for exactly this situation. It creates a bridge between a component's internal state and an external x-model.

Here's how it works:

<div x-data="{ outer: 'hello' }">
    <div x-data="{ inner: '' }" x-modelable="inner" x-model="outer">
        <input x-model="inner">
    </div>
    <p>Outer value: <span x-text="outer"></span></p>
</div>

The inner component declares x-modelable="inner", which says "my inner property should be bound to whatever x-model points to." The parent passes x-model="outer". Now changes to inner propagate to outer, and changes to outer propagate to inner.

Applied to our Flatpickr component:

{{-- resources/views/components/flatpickr.blade.php --}}
@php
    $xModel = $attributes->get('x-model');
@endphp

<div 
    x-data="{ selectedDate: '' }"
    x-modelable="selectedDate"
    {{ $attributes->only('x-model') }}
>
    <input 
        type="text"
        x-ref="input"
        x-init="flatpickr($refs.input, {
            onChange: (dates, dateStr) => selectedDate = dateStr
        })"
        {{ $attributes->except('x-model') }}
    >
</div>

The x-modelable="selectedDate" tells Alpine that selectedDate is the internal value that should sync with the external x-model. The $attributes->only('x-model') passes through just the x-model attribute from the parent.

Now you can use it like this:

<form x-data="{ startDate: '', endDate: '' }" @submit.prevent="handleSubmit()">
    <div>
        <label>Start Date</label>
        <x-flatpickr x-model="startDate" class="border rounded px-3 py-2" />
    </div>
    <div>
        <label>End Date</label>
        <x-flatpickr x-model="endDate" class="border rounded px-3 py-2" />
    </div>
    <button type="submit">Submit</button>
</form>

Changes flow both ways. Pick a date in the picker, startDate updates. Set startDate programmatically, the picker updates.

Watching External Changes

There's one more piece. When the parent updates the value, we need to tell Flatpickr to reflect that change. Flatpickr doesn't watch the input's value attribute automatically.

{{-- resources/views/components/flatpickr.blade.php --}}
@php
    $xModel = $attributes->get('x-model');
@endphp

<div 
    x-data="{ 
        selectedDate: '',
        picker: null,
        init() {
            this.picker = flatpickr(this.$refs.input, {
                onChange: (dates, dateStr) => this.selectedDate = dateStr
            });
            
            this.$watch('selectedDate', (value) => {
                if (this.picker.selectedDates[0]?.toISOString().slice(0, 10) !== value) {
                    this.picker.setDate(value, false);
                }
            });
        }
    }"
    x-modelable="selectedDate"
    {{ $attributes->only('x-model') }}
>
    <input 
        type="text"
        x-ref="input"
        {{ $attributes->except('x-model') }}
    >
</div>

The $watch listens for changes to selectedDate and calls picker.setDate() when needed. The comparison prevents infinite loops where setting the date triggers onChange which updates selectedDate which triggers the watcher.

Passing Options

Most libraries need configuration. Let's add an options prop:

{{-- resources/views/components/flatpickr.blade.php --}}
@props(['options' => []])

@php
    $xModel = $attributes->get('x-model');
@endphp

<div 
    x-data="{ 
        selectedDate: '',
        picker: null,
        init() {
            this.picker = flatpickr(this.$refs.input, {
                ...{{ json_encode($options) }},
                onChange: (dates, dateStr) => this.selectedDate = dateStr
            });
            
            this.$watch('selectedDate', (value) => {
                if (this.picker.selectedDates[0]?.toISOString().slice(0, 10) !== value) {
                    this.picker.setDate(value, false);
                }
            });
        }
    }"
    x-modelable="selectedDate"
    {{ $attributes->only('x-model') }}
>
    <input 
        type="text"
        x-ref="input"
        {{ $attributes->except('x-model') }}
    >
</div>

Usage:

<x-flatpickr 
    x-model="appointmentDate" 
    :options="['enableTime' => true, 'dateFormat' => 'Y-m-d H:i']" 
/>

Moving JavaScript to a Separate File

Inline Alpine components work but get messy as complexity grows. You can register the component with Alpine.data() in your app.js:

// resources/js/app.js
import Alpine from 'alpinejs';
import flatpickr from 'flatpickr';

Alpine.data('flatpickrComponent', (options = {}) => ({
    selectedDate: '',
    picker: null,
    
    init() {
        this.picker = flatpickr(this.$refs.input, {
            ...options,
            onChange: (dates, dateStr) => this.selectedDate = dateStr
        });
        
        this.$watch('selectedDate', (value) => {
            const current = this.picker.selectedDates[0]?.toISOString().slice(0, 10);
            if (current !== value) {
                this.picker.setDate(value, false);
            }
        });
    },
    
    destroy() {
        this.picker?.destroy();
    }
}));

Alpine.start();

Then simplify the Blade component:

{{-- resources/views/components/flatpickr.blade.php --}}
@props(['options' => []])

<div 
    x-data="flatpickrComponent({{ json_encode($options) }})"
    x-modelable="selectedDate"
    {{ $attributes->only('x-model') }}
>
    <input 
        type="text"
        x-ref="input"
        {{ $attributes->except('x-model') }}
    >
</div>

Cleaner template, easier to test, and you can add TypeScript types if you're into that.

Other Patterns

This same approach works for any third-party library:

TomSelect / Select2:

<x-tom-select x-model="selectedTags" :options="$availableTags" multiple />

Rich text editors:

<x-rich-editor x-model="content" />

Color pickers:

<x-color-picker x-model="brandColor" />

The pattern is always the same:

  1. Create internal state for the library to manipulate
  2. Use x-modelable to bridge that state to the external x-model
  3. Watch for external changes and sync them back to the library
  4. Pass through other attributes for styling and customization

Once you've internalized this pattern, you'll start seeing opportunities everywhere. Any JavaScript library that manages state can be wrapped in a Blade component that plays nicely with Alpine's reactivity system.