Common Alpine.js Mistakes in Laravel Projects to Avoid

The problem: A handful of Alpine.js mistakes keep showing up across different Laravel projects — not because Alpine is confusing, but because its behavior around scope, reactivity, and Livewire interop has a few non-obvious edges that aren't covered in the official docs' quick-start examples.

Environment: Laravel 11, Alpine.js 3.x (Livewire-bundled), Blade views, Vite 5.

The Error

// These mistakes rarely throw one consistent error.
// Most common console noise across all of them:

Alpine Expression Error: [property] is not defined
    Expression: "[property]"

// or simply silent wrong behavior with no console output at all.

Why It Happens

Five mistakes account for the majority of Alpine bugs reported in Laravel + Livewire projects:

1. Mutating array/object state without triggering reactivity properly. Direct index assignment like items[0] = newValue or mutating a nested object property Alpine hasn't seen before can behave inconsistently depending on how the object was initialized.

2. Referencing a property from the wrong scope. Alpine expressions inside nested x-data, x-for, or template blocks only see properties from their own scope and ancestor scopes — not sibling components.

3. Using x-html with unescaped user input. This renders raw HTML directly, which is a stored XSS risk if the content includes anything a user submitted without sanitization — Blade's {{ }} already escapes by default, but x-html bypasses that entirely.

4. Forgetting that x-if and x-show behave differently for initialization. x-show renders the element immediately (just hidden via CSS) so its x-init/refs exist right away; x-if doesn't render the element into the DOM at all until true, so anything depending on that element existing has to wait.

5. Not accounting for Livewire re-renders resetting local Alpine state. If Alpine state should persist across a Livewire update, initializing it purely inline (rather than seeding from a Livewire property) means it resets to its default every time Livewire re-renders that part of the DOM.

The Fix

For #1 — use array/object methods Alpine tracks reliably (push, splice, spreading into a new array) rather than raw index assignment when in doubt:

// Prefer this:
this.items = [...this.items, newItem];
this.items.splice(index, 1);

// Over direct index mutation when the array was
// built dynamically rather than declared inline

For #2 — if two components genuinely need the same data, use Alpine.store() (see the earlier post on sharing state) rather than trying to reach across sibling scopes.

For #3 — sanitize on the server before it ever reaches x-html, and default to x-text wherever raw HTML rendering isn't actually required:

<!-- Only if you've already sanitized server-side, e.g. with
     a package like HTMLPurifier, and truly need HTML output -->
<div x-html="sanitizedDescription"></div>

<!-- Otherwise, always prefer: -->
<div x-text="description"></div>

For #4 — choose deliberately: x-show when you need the element and its refs to exist immediately (just visually hidden), x-if/template when the field truly shouldn't exist in the submitted DOM until the condition is met:

<!-- Needs to exist immediately for $refs/focus to work: -->
<input x-show="showField" x-ref="field">

<!-- Genuinely shouldn't exist in the DOM/be submitted until true: -->
<template x-if="showField">
    <input name="conditional_field">
</template>

For #5 — seed Alpine state from a Livewire/PHP value instead of a hardcoded default, so a re-render doesn't silently reset UI state the user already set:

<div x-data="{ expanded: {{ $userPreference ? 'true' : 'false' }} }">
    ...
</div>

Step-by-Step

  1. Search your codebase for direct array index assignments (arr[i] = ...) inside Alpine scopes and replace with splice/spread patterns where the array is built dynamically.
  2. Search for any x-html usage and confirm the bound value is sanitized server-side, not raw user input.
  3. For every conditional field, decide deliberately between x-show and x-if based on whether it needs to exist in the DOM/submitted payload before the condition is true.
  4. For state that should survive a Livewire re-render, confirm it's seeded from a PHP/Livewire value in x-data, not hardcoded to a default.
  5. Check any place two components seem to need the same data — replace ad hoc cross-scope references with a proper Alpine.store().
  6. Rebuild with npm run build and manually retest each fixed area, including a Livewire re-render trigger where relevant.
Edge case: Mistake #3 (x-html with unsanitized input) is the one worth treating as a security issue, not just a bug — if any user-submitted field (a bio, comment, or profile description) ever reaches x-html without passing through server-side sanitization first, audit that path immediately rather than deferring it, since it's a real stored XSS vector, not a cosmetic mistake.

Found a different Alpine gotcha not listed here? Share it on the Contact page — I might turn it into a future post.

Advertisement
Advertisement