JavaScript & Alpine.js Guide

This guide covers client-side interactivity in Varel applications using Alpine.js.

Table of Contents


Introduction

Varel includes Alpine.js 3.14 as its JavaScript framework for adding client-side interactivity. Alpine.js is:

  • Lightweight - Only 15KB minified/gzipped
  • No build step - Works directly in templates
  • Reactive - Automatic UI updates when data changes
  • Server-friendly - Perfect for server-rendered apps like Varel
  • Included locally - No CDN dependency, works offline

Why Alpine.js?

Alpine.js provides the reactivity of Vue/React with the simplicity of jQuery. It's perfect for adding interactive features to server-rendered Varel applications without the complexity of a full SPA framework.

Traditional approach (jQuery-style):

// Lots of manual DOM manipulation
$('#copy-btn').click(function() {
    navigator.clipboard.writeText($('#url').val());
    $(this).text('✓ Copied!');
    setTimeout(() => $(this).text('Copy'), 2000);
});

Alpine.js approach:

<!-- Declarative, reactive -->
<div x-data="{ copied: false }">
    <button @click="
        navigator.clipboard.writeText('${url}');
        copied = true;
        setTimeout(() => copied = false, 2000)
    ">
        <span x-show="!copied">Copy</span>
        <span x-show="copied">✓ Copied!</span>
    </button>
</div>

Alpine.js Basics

Installation

Alpine.js is automatically included in all new Varel projects. It's loaded in views/layouts/base.html.vtpl:

<!-- Alpine.js - Local copy for offline support -->
<script defer src="/static/js/alpine.min.js"></script>

The defer attribute ensures Alpine loads after the HTML is parsed.

Core Directives

Alpine uses HTML attributes (directives) to add interactivity:

x-data - Component State

Defines reactive data for a component:

<div x-data="{ count: 0, name: 'Alice' }">
    <p>Count: <span x-text="count"></span></p>
    <p>Name: <span x-text="name"></span></p>
</div>

x-show - Conditional Visibility

Toggles CSS display property:

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

Tip: Use x-cloak to hide content until Alpine loads:

<div x-show="open" x-cloak>Content</div>

x-if - Conditional Rendering

Adds/removes elements from DOM (more performant for heavy content):

<template x-if="loggedIn">
    <div>Welcome back!</div>
</template>

@click - Event Handlers

Shorthand for x-on:click:

<button @click="count++">Increment</button>
<button @click="handleSubmit()">Submit</button>

Other events: @submit, @input, @change, @keydown, @mouseenter, etc.

x-model - Two-Way Binding

Syncs input values with data:

<div x-data="{ search: '' }">
    <input x-model="search" placeholder="Search...">
    <p>Searching for: <span x-text="search"></span></p>
</div>

x-text - Text Content

Sets element's text content:

<span x-text="username"></span>
<!-- vs -->
<span>${username}</span> <!-- VeeMarker, server-side -->

x-html - HTML Content

Sets element's HTML content (be careful with XSS):

<div x-html="descriptionHtml"></div>

x-bind - Attribute Binding

Bind attributes reactively (shorthand is :):

<img :src="imageUrl" :alt="imageAlt">
<button :disabled="isProcessing">Submit</button>
<div :class="{ 'active': isActive, 'error': hasError }">...</div>

Reusable Components

Varel includes pre-built Alpine.js components as VeeMarker macros in views/shared/alpine_components.vtpl.

Using Components

Include the components file in your views:

<#include "shared/alpine_components.vtpl">

<!-- Now use any component -->
<#assign shortUrl = "http://localhost:8080/" + url.short_code>
<@copyButton text=shortUrl label="Copy URL" />

Passing Variables to Macros

Important: VeeMarker does NOT interpolate ${} expressions inside quoted macro attribute strings.

❌ Wrong - This will NOT work:

<@copyButton text="http://localhost:8080/${url.short_code}" />
<!-- Result: Copies literal "${url.short_code}" -->

<@copyButton text="${shortUrl}" />
<!-- Result: Copies literal "${shortUrl}" -->

✅ Correct - Two ways to pass variables:

Option 1: Pass variable by reference (no quotes)

<#assign shortUrl = "http://localhost:8080/" + url.short_code>
<@copyButton text=shortUrl label="Copy URL" />

Option 2: Use literal strings (for static values)

<@copyButton text="https://example.com/static-url" label="Copy" />

Rule: If you need dynamic values, use <#assign> to build the value first, then pass the variable name without quotes.

Available Components

1. Copy Button

Copy text to clipboard with visual feedback:

<@copyButton
    text="https://example.com/abc123"
    label="Copy"
    successLabel="✓ Copied!"
    class="my-custom-class"
/>

Parameters:

  • text - Text to copy (required)
  • label - Button label (default: "Copy")
  • successLabel - Success message (default: "✓ Copied!")
  • class - Additional CSS classes (optional)

Example in URL shortener:

<#list urls as url>
    <#assign fullUrl = request.base_url + "/" + url.short_code>
    <tr>
        <td>${url.short_code}</td>
        <td>${url.original_url}</td>
        <td>
            <@copyButton text=fullUrl label="📋 Copy" />
        </td>
    </tr>
</#list>

2. Confirm Delete

Delete button with confirmation dialog:

<#assign deleteAction = "/products/" + product.id>
<@confirmDelete
    action=deleteAction
    itemName=product.name
    buttonText="Delete"
    confirmMessage="Are you sure?"
    buttonClass="btn-danger"
/>

Parameters:

  • action - Form action URL (required)
  • itemName - Item name for confirmation (optional)
  • buttonText - Button label (default: "Delete")
  • confirmMessage - Confirmation text (default: "Are you sure?")
  • buttonClass - Button CSS class (default: "btn-danger")

Features:

  • Modal overlay with backdrop
  • Click outside or ESC key to cancel
  • Form uses method override for DELETE

3. Toast Notifications

Global toast notification system:

Step 1: Add to layout (once):

<!-- In base.html.vtpl, before </body> -->
<#include "shared/alpine_components.vtpl">
<@toastContainer />

Step 2: Trigger from templates:

<#if success_message??>
<script>
window.dispatchEvent(new CustomEvent('toast', {
    detail: { message: '${success_message?js_string}', type: 'success' }
}));
</script>
</#if>

Step 3: Or trigger from JavaScript:

window.dispatchEvent(new CustomEvent('toast', {
    detail: { message: 'URL shortened!', type: 'success' }
}));

Toast types:

  • success - Green, for successful operations
  • error - Red, for errors
  • warning - Yellow, for warnings
  • info - Blue, for information (default)

4. Dropdown Menu

Simple dropdown menu:

<@dropdown buttonText="Actions" buttonClass="btn">
    <a href="/edit" class="dropdown-item">Edit</a>
    <a href="/delete" class="dropdown-item">Delete</a>
    <a href="/archive" class="dropdown-item">Archive</a>
</@dropdown>

Features:

  • Click outside to close
  • Smooth transitions
  • Keyboard accessible

5. Toggle Switch

Checkbox styled as toggle switch:

<@toggleSwitch
    name="published"
    checked=product.published
    label="Published"
    onColor="#27ae60"
    offColor="#95a5a6"
/>

Parameters:

  • name - Form field name (required)
  • checked - Initial state (default: false)
  • label - Label text (optional)
  • onColor - Color when on (default: green)
  • offColor - Color when off (default: gray)

6. Tabs

Tabbed interface:

<@tabs tabList=[
    {'id': 'details', 'label': 'Details'},
    {'id': 'reviews', 'label': 'Reviews'},
    {'id': 'related', 'label': 'Related Products'}
] defaultTab="details">
    <div data-tab="details">
        <h3>Product Details</h3>
        <p>${product.description}</p>
    </div>
    <div data-tab="reviews">
        <h3>Customer Reviews</h3>
        <!-- Reviews here -->
    </div>
    <div data-tab="related">
        <h3>Related Products</h3>
        <!-- Related products here -->
    </div>
</@tabs>

Integration with VeeMarker

Alpine.js and VeeMarker work together seamlessly:

VeeMarker Built-in Functions for Alpine

VeeMarker provides three built-in functions specifically for Alpine.js integration:

?js_string - Escape JavaScript Strings

Escapes strings for safe embedding in JavaScript code:

<div x-data='{ name: "${product.name?js_string}" }'>
    <p x-text="name"></p>
</div>

Escapes:

  • Backslashes: \\\
  • Single quotes: '\'
  • Double quotes: "\"
  • Newlines: \n\\n
  • Carriage returns: \r\\r
  • Tabs: \t\\t

?html - Escape HTML Characters

Escapes HTML special characters (useful for displaying user content safely):

<div x-data='{ content: "${user_input?html}" }'>
    <p x-text="content"></p>
</div>

Escapes:

  • &&amp;
  • <&lt;
  • >&gt;
  • "&quot;
  • '&#39;

?alpine_json - Convert to JSON for x-data

Converts V data structures to JSON for Alpine's x-data attribute:

<!-- Simple usage with entire object -->
<div x-data='${product?alpine_json}'>
    <h2 x-text="name"></h2>
    <p>$<span x-text="price"></span></p>
</div>

<!-- With array of objects -->
<div x-data='{ products: ${products?alpine_json} }'>
    <template x-for="product in products" :key="product.id">
        <div x-text="product.name"></div>
    </template>
</div>

Handles all V types:

  • Strings: Escaped and quoted
  • Numbers (int, f64): Output as-is
  • Booleans: true/false
  • Maps: Converted to JSON objects
  • Arrays: Converted to JSON arrays

Example with controller:

// controllers/products.v
pub fn (c ProductsController) show(mut ctx varel.Context) varel.Response {
    product := c.get_product(ctx.param('id')!)!

    return ctx.render('products/show', {
        'product': veemarker.to_map(product)  // Convert struct to map
    })
}
<!-- views/products/show.vtpl -->
<div x-data='${product?alpine_json}'>
    <h1 x-text="name"></h1>
    <p class="price">$<span x-text="price"></span></p>
    <div x-show="in_stock">
        <button @click="addToCart()">Add to Cart</button>
    </div>
    <div x-show="!in_stock">
        <p class="out-of-stock">Out of Stock</p>
    </div>
</div>

Varel Context Helper: render_alpine()

Varel provides a specialized rendering method for Alpine.js templates that simplifies data handling and injects Alpine.js utilities.

Method Signature

pub fn (mut ctx Context) render_alpine(template_path string, data map[string]vm.Any) Response

What It Does

  1. Accepts VeeMarker Any data - Works with complex data structures (maps, arrays, structs)
  2. Injects Alpine.js version - Adds _alpine_version for debugging/feature detection
  3. Uses layout rendering - Automatically wraps template in layout (like render())
  4. Optimized for Alpine.js - Designed for templates using Alpine.js components

Basic Usage

// controllers/products.v
import leafscale.veemarker as vm

pub fn (c ProductsController) show(mut ctx varel.Context) varel.Response {
    product := c.get_product(ctx.param('id')!)!

    // Use render_alpine for templates with Alpine.js
    return ctx.render_alpine('products/show', {
        'product': vm.to_map(product)  // Convert struct to map
    })
}
<!-- views/products/show.vtpl -->
<div x-data='${product?alpine_json}'>
    <h1 x-text="name"></h1>
    <p class="price">$<span x-text="price"></span></p>
    <div x-show="in_stock">
        <button @click="addToCart()">Add to Cart</button>
    </div>
</div>

<!-- Alpine.js version available for debugging -->
<#if _alpine_version??>
<script>console.log('Alpine.js version: ${_alpine_version}');</script>
</#if>

With Multiple Data Items

// controllers/products.v
pub fn (c ProductsController) index(mut ctx varel.Context) varel.Response {
    products := c.get_all_products()!

    // Convert array of structs to array of maps
    mut product_maps := []vm.Any{}
    for product in products {
        product_maps << vm.to_map(product)
    }

    return ctx.render_alpine('products/index', {
        'products': product_maps
        'page_title': 'All Products'
    })
}
<!-- views/products/index.vtpl -->
<div x-data='{ items: ${products?alpine_json}, selected: [] }'>
    <h1>${page_title}</h1>

    <template x-for="product in items" :key="product.id">
        <div class="product-card">
            <h2 x-text="product.name"></h2>
            <p>$<span x-text="product.price"></span></p>
            <button @click="selected.push(product.id)">Add to Cart</button>
        </div>
    </template>

    <p>Cart: <span x-text="selected.length"></span> items</p>
</div>

Comparison: render() vs render_alpine()

Standard render() method:

// Takes map[string]string - simple string data only
return ctx.render('products/show', {
    'product_name': product.name
    'product_price': product.price.str()
})

Alpine-friendly render_alpine() method:

// Takes map[string]vm.Any - complex data structures supported
return ctx.render_alpine('products/show', {
    'product': vm.to_map(product)  // Entire struct as map
})

When to Use render_alpine()

Use render_alpine() when:

  • ✅ Your template uses Alpine.js components (x-data, x-for, etc.)
  • ✅ You need to pass complex data structures (arrays, nested objects)
  • ✅ You want Alpine.js version info injected automatically

Use regular render() when:

  • Template doesn't use Alpine.js
  • Only need simple string interpolation
  • No client-side interactivity needed

Best Practices

  1. Always convert structs to maps explicitly:

    // ✅ Good - explicit conversion
    return ctx.render_alpine('products/show', {
        'product': vm.to_map(product)
    })
    
    // ❌ Bad - struct won't work directly
    return ctx.render_alpine('products/show', {
        'product': product  // Type error
    })
    
  2. Use VeeMarker built-ins in templates:

    <!-- Combine render_alpine with ?alpine_json -->
    <div x-data='${product?alpine_json}'>
        <h1 x-text="name"></h1>
    </div>
    
  3. Check Alpine version when needed:

    <#if _alpine_version??>
    <!-- Alpine.js is available, version ${_alpine_version} -->
    </#if>
    

Server Data → Client State

VeeMarker (server-side) can inject data into Alpine.js (client-side):

<!-- VeeMarker injects server data -->
<div x-data='{
    id: ${product.id},
    name: "${product.name?js_string}",
    price: ${product.price},
    inStock: ${product.in_stock?c}
}'>
    <!-- Alpine uses the data -->
    <h2 x-text="name"></h2>
    <p>$<span x-text="price"></span></p>
    <button x-show="inStock">Add to Cart</button>
</div>

Key techniques:

  • Use ?js_string to escape JavaScript strings safely
  • Use ?c for booleans (outputs "true"/"false")
  • Numbers can be injected directly

Loops with VeeMarker + Alpine

Use VeeMarker for initial render, Alpine for interactivity:

<div x-data="{ selected: [] }">
    <#list products as product>
    <div>
        <input
            type="checkbox"
            :value="${product.id}"
            x-model="selected">
        <label>${product.name}</label>
    </div>
    </#list>

    <p>Selected: <span x-text="selected.length"></span> items</p>
</div>

Forms with Validation

VeeMarker renders form, Alpine adds client-side validation:

<form method="POST" action="/shorten"
      x-data="{ url: '', isValid: false }"
      x-init="$watch('url', value => isValid = value.startsWith('http'))"
      @submit="if (!isValid) { $event.preventDefault(); alert('Invalid URL'); }">

    <input
        type="url"
        name="url"
        x-model="url"
        placeholder="https://example.com"
        required>

    <button
        type="submit"
        :disabled="!isValid"
        :class="{ 'btn-primary': isValid, 'btn-disabled': !isValid }">
        Shorten URL
    </button>

    <p x-show="!isValid && url.length > 0" class="error" x-cloak>
        URL must start with http:// or https://
    </p>
</form>

Best Practices

1. Use VeeMarker for Initial Render

Good:

<!-- VeeMarker renders list server-side -->
<#list products as product>
    <div class="product">${product.name}</div>
</#list>

Avoid:

<!-- Don't fetch data with Alpine on page load -->
<div x-data="{ products: [] }"
     x-init="fetch('/api/products').then(r => r.json()).then(data => products = data)">
    <template x-for="product in products">
        <div x-text="product.name"></div>
    </template>
</div>

Why: Server-side rendering is faster and better for SEO.

2. Keep Alpine for Interactivity

Use Alpine for user interactions, not data fetching:

Good use cases:

  • Toggle visibility
  • Form validation
  • Copy to clipboard
  • Dropdown menus
  • Modal dialogs
  • Search filtering (on already-loaded data)

Poor use cases:

  • Fetching initial page data
  • Complex business logic
  • Database queries

3. Escape Data Properly

When injecting server data into Alpine:

<!-- ✅ GOOD - Escaped -->
<div x-data='{ name: "${user.name?js_string}" }'>

<!-- ❌ BAD - Not escaped, XSS risk -->
<div x-data='{ name: "${user.name}" }'>

4. Use x-cloak for Flicker Prevention

<!-- Without x-cloak -->
<div x-show="open">
    <!-- User sees this briefly before Alpine hides it -->
</div>

<!-- With x-cloak -->
<div x-show="open" x-cloak>
    <!-- Hidden until Alpine is ready -->
</div>

5. Combine with Method Override

Alpine + Varel's method override for HTML forms:

<form method="POST" action="/products/${product.id}"
      x-data="{ deleting: false }"
      @submit="deleting = true">
    <input type="hidden" name="_method" value="DELETE">
    <button type="submit" :disabled="deleting">
        <span x-show="!deleting">Delete</span>
        <span x-show="deleting" x-cloak>Deleting...</span>
    </button>
</form>

Common Patterns

Search/Filter (Client-Side)

Filter already-loaded data:

<div x-data="{
    search: '',
    get filteredProducts() {
        return this.products.filter(p =>
            p.name.toLowerCase().includes(this.search.toLowerCase())
        )
    },
    products: [
        <#list products as product>
        { id: ${product.id}, name: "${product.name?js_string}" }<#sep>,</#sep>
        </#list>
    ]
}">
    <input x-model="search" placeholder="Search products...">

    <template x-for="product in filteredProducts" :key="product.id">
        <div x-text="product.name"></div>
    </template>

    <p x-show="filteredProducts.length === 0" x-cloak>
        No products found
    </p>
</div>

Pagination (Client-Side)

Paginate pre-loaded data:

<div x-data="{
    page: 1,
    perPage: 10,
    get paginatedItems() {
        const start = (this.page - 1) * this.perPage;
        return this.items.slice(start, start + this.perPage);
    },
    get totalPages() {
        return Math.ceil(this.items.length / this.perPage);
    },
    items: [
        <#list items as item>
        { id: ${item.id}, name: "${item.name?js_string}" }<#sep>,</#sep>
        </#list>
    ]
}">
    <template x-for="item in paginatedItems" :key="item.id">
        <div x-text="item.name"></div>
    </template>

    <div class="pagination">
        <button @click="page--" :disabled="page === 1">Previous</button>
        <span x-text="`Page ${page} of ${totalPages}`"></span>
        <button @click="page++" :disabled="page === totalPages">Next</button>
    </div>
</div>

AJAX Form Submission

Submit form via AJAX with Alpine:

<form
    x-data="{ submitting: false, result: null }"
    @submit.prevent="
        submitting = true;
        fetch($el.action, {
            method: 'POST',
            body: new FormData($el)
        })
        .then(r => r.json())
        .then(data => { result = data; submitting = false; })
        .catch(err => { alert('Error'); submitting = false; })
    "
    action="/api/shorten"
    method="POST">

    <input name="url" required>
    <button type="submit" :disabled="submitting">
        <span x-show="!submitting">Shorten</span>
        <span x-show="submitting" x-cloak>Shortening...</span>
    </button>

    <div x-show="result" x-cloak>
        <p>Short URL: <code x-text="result.short_url"></code></p>
    </div>
</form>

Summary

You've learned:

✅ How Alpine.js adds interactivity to Varel apps ✅ Core Alpine.js directives (x-data, x-show, @click, x-model) ✅ Using pre-built components (copyButton, confirmDelete, toast, etc.) ✅ Integrating Alpine with VeeMarker templates ✅ Best practices (server-render first, escape data, use x-cloak) ✅ Common patterns (search/filter, pagination, AJAX forms)

Continue to the Production Features Guide for deployment!


Additional Resources

  • Alpine.js Documentation: https://alpinejs.dev
  • VeeMarker Documentation: /home/ctusa/repos/veemarker/docs/veemarker.md
  • Component Source: views/shared/alpine_components.vtpl