Chapter 6: Alpine.js - When You Just Need to Sprinkle Some Magic

jQuery’s Cool Nephew Who Went to College

Remember jQuery?

$(document).ready(function() {
  $('.button').on('click', function() {
    $(this).toggleClass('active')
    $('.menu').slideToggle()
  })
})

Simple. Direct. No build step. Just include a script tag and go.

Then React came along and said “you need to npm install 47 packages, configure webpack, set up babel, learn JSX, understand the virtual DOM, and restructure your entire application to toggle a class.”

For a dropdown menu.

Alpine.js looked at this and said “what if we kept the simplicity of jQuery but made it reactive and modern?”

<div x-data="{ open: false }">
  <button @click="open = !open">Toggle</button>
  <div x-show="open" class="menu">
    Menu content
  </div>
</div>

No build step. No npm. No webpack. Just a script tag and attributes. Like the good old days, but not terrible.

No Build Step, No npm install, No Tears

React project setup:

npx create-react-app my-app
cd my-app
npm install
# Wait 5 minutes
# Get coffee
# 200MB of node_modules later
npm start
# Wait for webpack
# Finally ready

Alpine project setup:

<!DOCTYPE html>
<html>
<head>
  <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</head>
<body>
  <!-- You're ready -->
</body>
</html>

That’s it. You’re done. Start coding.

15KB. No build step. No dependency hell. Just a script tag like it’s 2010, except actually good.

Reactivity in HTML Attributes

React approach:

function Dropdown() {
  const [open, setOpen] = useState(false)

  return (
    <div>
      <button onClick={() => setOpen(!open)}>
        Toggle
      </button>
      {open && (
        <div className="menu">
          <a href="/profile">Profile</a>
          <a href="/settings">Settings</a>
          <a href="/logout">Logout</a>
        </div>
      )}
    </div>
  )
}

// Now configure babel, webpack, the runtime...

Alpine approach:

<div x-data="{ open: false }">
  <button @click="open = !open">
    Toggle
  </button>

  <div x-show="open" class="menu">
    <a href="/profile">Profile</a>
    <a href="/settings">Settings</a>
    <a href="/logout">Logout</a>
  </div>
</div>

Same functionality. Zero JavaScript files. Zero build configuration. Just HTML with superpowers.

The Directives

Alpine uses Vue-inspired directives:

x-data: Component Scope

<div x-data="{ count: 0, name: 'Alice' }">
  <!-- count and name are available here -->
</div>

Everything inside has access to that data. It’s like a tiny component.

x-show / x-if: Conditional Rendering

<!-- x-show: toggles visibility (display: none) -->
<div x-data="{ visible: true }">
  <div x-show="visible">I can be toggled</div>
</div>

<!-- x-if: removes from DOM entirely -->
<template x-if="user.loggedIn">
  <div>Welcome back!</div>
</template>

x-for: Loops

<div x-data="{ items: ['one', 'two', 'three'] }">
  <template x-for="item in items">
    <li x-text="item"></li>
  </template>
</div>

x-model: Two-Way Binding

<div x-data="{ message: '' }">
  <input type="text" x-model="message">
  <p>You typed: <span x-text="message"></span></p>
</div>

No onChange handlers. No controlled components. Just works.

x-on (@): Event Handling

<button @click="count++">Increment</button>
<button @click="doSomething($event)">Click me</button>

<!-- Modifiers work too -->
<form @submit.prevent="handleSubmit">
  <input @keydown.enter="search">
  <input @click.outside="close">
</form>

x-bind (:): Attribute Binding

<div x-data="{ color: 'red' }">
  <div :class="color">Colored div</div>
  <input :disabled="loading">
  <img :src="imageUrl">
</div>

When React Is 300KB of Overkill

You know that project where you used React for:

And it felt like using a nuclear reactor to make toast?

Alpine is the toaster.

Example: Modal

React version:

// Modal.jsx
import { useState } from 'react'
import ReactDOM from 'react-dom'

function Modal({ children, trigger }) {
  const [isOpen, setIsOpen] = useState(false)

  return (
    <>
      <button onClick={() => setIsOpen(true)}>
        {trigger}
      </button>

      {isOpen && ReactDOM.createPortal(
        <div className="modal-overlay" onClick={() => setIsOpen(false)}>
          <div className="modal" onClick={e => e.stopPropagation()}>
            {children}
            <button onClick={() => setIsOpen(false)}>Close</button>
          </div>
        </div>,
        document.body
      )}
    </>
  )
}

// Now bundle it, ship it, pray it doesn't break

Alpine version:

<div x-data="{ open: false }">
  <button @click="open = true">Open Modal</button>

  <div x-show="open"
       @click="open = false"
       class="modal-overlay">
    <div @click.stop class="modal">
      <h2>Modal Content</h2>
      <button @click="open = false">Close</button>
    </div>
  </div>
</div>

No components. No portals. No build step. Just HTML that works.

Example: Tabs

<div x-data="{ tab: 'home' }">
  <nav>
    <button @click="tab = 'home'" :class="{ active: tab === 'home' }">
      Home
    </button>
    <button @click="tab = 'profile'" :class="{ active: tab === 'profile' }">
      Profile
    </button>
    <button @click="tab = 'settings'" :class="{ active: tab === 'settings' }">
      Settings
    </button>
  </nav>

  <div x-show="tab === 'home'">Home content</div>
  <div x-show="tab === 'profile'">Profile content</div>
  <div x-show="tab === 'settings'">Settings content</div>
</div>

Try building this in React without wanting to cry about component architecture.

<div x-data="{
  query: '',
  results: [],
  async search() {
    const response = await fetch(`/api/search?q=${this.query}`)
    this.results = await response.json()
  }
}">
  <input
    type="search"
    x-model="query"
    @input.debounce.500ms="search"
    placeholder="Search...">

  <ul>
    <template x-for="result in results">
      <li x-text="result.name"></li>
    </template>
  </ul>
</div>

Built-in debouncing. Async handling. No hooks. No useEffect. No external dependencies.

Magic Properties

Alpine has some delightful shortcuts:

$el

<div x-data @click="$el.style.backgroundColor = 'red'">
  Click me
</div>

$el is the element itself. No refs needed.

$refs

<div x-data>
  <input x-ref="email" type="email">
  <button @click="$refs.email.focus()">Focus input</button>
</div>

Like React refs but without the ceremony.

$watch

<div x-data="{ count: 0 }" x-init="$watch('count', value => {
  console.log('Count changed to:', value)
})">
  <button @click="count++">8</button>
</div>

Watch for changes. Like useEffect but you don’t have to fight with dependency arrays.

$dispatch

<div @notify="alert($event.detail.message)">
  <button @click="$dispatch('notify', { message: 'Hello!' })">
    Notify
  </button>
</div>

Custom events. Simple communication without context hell.

Stores: Shared State Without the Drama

Need global state? Alpine has that:

<script>
  document.addEventListener('alpine:init', () => {
    Alpine.store('user', {
      name: 'Alice',
      loggedIn: false,

      login(name) {
        this.name = name
        this.loggedIn = true
      }
    })
  })
</script>

<!-- Use it anywhere -->
<div x-data>
  <div x-show="$store.user.loggedIn">
    Welcome, <span x-text="$store.user.name"></span>!
  </div>

  <button @click="$store.user.login('Bob')">
    Login
  </button>
</div>

No Context.Provider. No Redux. No anything. Just a global reactive store.

Plugins

Alpine’s plugin system is delightful:

Focus Plugin

<script src="https://cdn.jsdelivr.net/npm/@alpinejs/focus@3.x.x/dist/cdn.min.js"></script>

<div x-data="{ open: false }">
  <button @click="open = true">Open Dialog</button>

  <div x-show="open" x-trap.inert.noscroll="open">
    <!-- Focus is trapped here when open -->
    <input type="text">
    <button @click="open = false">Close</button>
  </div>
</div>

Accessibility handled. No fighting with focus management.

Mask Plugin

<script src="https://cdn.jsdelivr.net/npm/@alpinejs/mask@3.x.x/dist/cdn.min.js"></script>

<input x-mask="(999) 999-9999" placeholder="(555) 555-5555">
<input x-mask="99/99/9999" placeholder="MM/DD/YYYY">

Input masking without a library.

Migration from React: The De-escalation

Moving from React to Alpine isn’t really migration—it’s de-escalation. You’re choosing the right tool for the job.

Pattern 1: Progressive Enhancement

Start with HTML that works without JavaScript:

<!-- Works without JS -->
<details>
  <summary>Click to expand</summary>
  <div>Content here</div>
</details>

<!-- Enhanced with Alpine -->
<div x-data="{ open: false }">
  <button @click="open = !open">
    Click to expand
  </button>
  <div x-show="open" x-collapse>
    Content here with smooth animation
  </div>
</div>

Pattern 2: Replace React Islands

That React component you loaded for one interactive widget? Alpine can do it:

Before:

// Heavy React component for a simple interaction
ReactDOM.render(<LikeButton postId={123} />, el)

After:

<div x-data="{ liked: false, count: 42 }">
  <button @click="liked = !liked; count += liked ? 1 : -1"
          :class="{ liked }">
    ❤️ <span x-text="count"></span>
  </button>
</div>

No JavaScript file. No build step. Just works.

Pattern 3: Forms

React forms are verbose. Alpine forms are not:

Before (React):

function ContactForm() {
  const [formData, setFormData] = useState({
    name: '',
    email: '',
    message: ''
  })
  const [errors, setErrors] = useState({})
  const [submitting, setSubmitting] = useState(false)

  const handleChange = (e) => {
    setFormData({
      ...formData,
      [e.target.name]: e.target.value
    })
  }

  const handleSubmit = async (e) => {
    e.preventDefault()
    setSubmitting(true)

    try {
      await fetch('/api/contact', {
        method: 'POST',
        body: JSON.stringify(formData)
      })
      alert('Success!')
    } catch (err) {
      setErrors({ submit: err.message })
    } finally {
      setSubmitting(false)
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        name="name"
        value={formData.name}
        onChange={handleChange}
      />
      {/* ... */}
    </form>
  )
}

After (Alpine):

<form
  x-data="{
    formData: { name: '', email: '', message: '' },
    submitting: false,
    async submit() {
      this.submitting = true
      try {
        await fetch('/api/contact', {
          method: 'POST',
          body: JSON.stringify(this.formData)
        })
        alert('Success!')
      } catch (err) {
        alert('Error: ' + err.message)
      } finally {
        this.submitting = false
      }
    }
  }"
  @submit.prevent="submit">

  <input type="text" x-model="formData.name">
  <input type="email" x-model="formData.email">
  <textarea x-model="formData.message"></textarea>

  <button :disabled="submitting">
    <span x-show="!submitting">Send</span>
    <span x-show="submitting">Sending...</span>
  </button>
</form>

Same functionality. No controlled component dance. No hooks.

When Alpine Is Perfect

Alpine excels at:

Alpine is not ideal for:

The Performance Conversation

“But isn’t putting JavaScript in HTML slow?”

Compared to what?

Alpine:

React:

For simple interactions, Alpine is significantly faster.

For complex SPAs, React’s architecture pays off.

Use the right tool.

Real Talk: Alpine vs React

React: “We’re a library for building user interfaces. You’ll need to configure a build system, choose state management, set up routing, handle forms, manage side effects, optimize re-renders…”

Alpine: “Here’s a script tag. Make things interactive.”

Different problems. Different solutions.

React is for building applications. Alpine is for adding interactivity.

If your “app” is really a website with some interactive bits, you don’t need React. You need Alpine.

And your users will thank you when the page loads in 100ms instead of waiting for 300KB of JavaScript to download, parse, and execute.


“I replaced a 50KB React bundle with 15KB of Alpine and users thought I fixed performance bugs. I just stopped over-engineering.” — A Developer Who Chose Simplicity

Up Next: Chapter 7: Lit - Web Components for Adults