Skip to content

Discussion: Custom @ Syntax for Events and HTMX Attributes #11

Description

@GliderGeek

When working with the library, i struggled a bit with the custom syntax for events and htmx attributes. I looked into the merits of this custom syntax and am wondering if it wouldn't be better to drop it and use default html/htmx syntax instead.

Below is a thorough analysis of the situation. Very curious to hear how you look at this.

Current Situation

This library currently uses a custom @ prefix syntax for event handlers and HTMX attributes:

<!-- Current syntax -->
<c-button @click="handleClick()">Click</c-button>
<c-button @hx-post="/api/data" @hx-target="#result">Submit</c-button>

<!-- Standard HTML/HTMX syntax -->
<button onclick="handleClick()">Click</button>
<button hx-post="/api/data" hx-target="#result">Submit</button>

What the Custom Syntax Currently Does

The @ prefix provides these benefits:

  1. Validation Bypass - Event attributes with @ skip strict attribute validation, so you don't need to define every possible event in each component's attribute list.

  2. Centralized Handling - All events go through the _event_mixin.j2 macro, providing consistent processing across components.

Important: The @ syntax does NOT add any runtime capabilities. It's purely a template preprocessing convenience that outputs standard HTML:

  • @click="..."onclick="..."
  • @hx-post="..."hx-post="..."

It was thought that Jinja variable resolution was a benefit of the @ syntax. However, code analysis reveals that Jinja variable resolution works for ANY attribute (with or without @). The preprocessor detects {{ in any attribute value and applies the same resolution logic:

# From html_parser.py lines 369-377
elif '{{' in value or '{%' in value:
    # Regular attribute with Jinja expressions
    # [Creates template variable and resolves it]

This means:

  • onclick="func('{{ var }}')" would get Jinja resolution if onclick was in passthrough list
  • hx-post="/api/{{ id }}" would get Jinja resolution if hx-post was in passthrough list
  • The @ prefix does not provide this feature - it's available to all attributes

Comparison with Other Server-Side Component Libraries

Click to expand: How other server-side component libraries handle event syntax

Django Components

django-components - Python/Django component library

Event handler syntax:

{% component "my_comp"
   date=date
   attrs:@click="(e) => onClick(e, 'from_parent')"
/ %}

Key features:

  • Uses attrs: prefix for HTML attributes including event handlers
  • Supports @click syntax for passing to Alpine.js/Vue.js components
  • Uses {% html_attrs %} tag to render passed attributes
  • Standard HTML passthrough - accepts onclick, data-*, any attribute
  • Special characters like @ . - _ are allowed in attribute names
  • Designed for integration with Alpine.js, HTMX, jQuery

Their approach: Accept any attribute, pass through to HTML. Let client frameworks handle their own syntax:

  • Alpine.js handles @click on the client side
  • HTMX uses standard hx-* attributes (NOT @hx-*)
  • Plain JavaScript uses standard onclick

Phoenix LiveView

Phoenix LiveView - Elixir/Phoenix server-rendered components with WebSocket updates

Event handler syntax:

<button phx-click="increment">+</button>
<button phx-click={JS.push("clicked", target: @myself)}>Advanced</button>

Key features:

  • Custom phx-* prefix for LiveView events (phx-click, phx-change, etc.)
  • Events sent to server over WebSocket, handled by handle_event/3 callbacks
  • Different from standard HTML - phx-click is NOT the same as onclick
  • phx-click triggers server-side logic, onclick triggers client-side JavaScript
  • Both can coexist: <button phx-click="server_action" onclick="clientFunction()">

Why custom syntax makes sense here:

  • phx-click provides technical value - server-side event handling over WebSocket
  • Different behavior than onclick - not just passthrough
  • Clear distinction between server events (phx-*) and client events (on*)

Rails ViewComponent + Stimulus

ViewComponent + Stimulus - Ruby on Rails component library with JavaScript controllers

Event handler syntax:

<!-- ViewComponent renders standard HTML -->
<div data-controller="toggle">
  <button data-action="click->toggle#toggle">Toggle</button>
  <button data-action="toggle#toggle">Toggle (shorthand)</button>
</div>

Key features:

  • ViewComponent outputs standard HTML - no custom event syntax
  • Stimulus uses data-action="click->controller#method" for event binding
  • Shorthand: omit click-> for button elements (implied)
  • No custom syntax in templates - pure HTML with data attributes
  • JavaScript controllers attach to HTML via data attributes

Their approach: Server-side components output standard HTML. Client-side framework (Stimulus) uses standard data-* attributes for progressive enhancement.

Laravel Blade + Alpine.js

Blade Components + Alpine.js - PHP/Laravel templating with Alpine.js

Event handler syntax:

<!-- Blade components use standard HTML + Alpine directives -->
<x-button x-on:click="handleClick()">Click</x-button>
<x-button @click="handleClick()">Click (Alpine shorthand)</x-button>

<!-- Pass through to component -->
<x-button ::@click="dynamicHandler">Click</x-button>

Key features:

  • Blade outputs standard HTML
  • Alpine.js @click syntax is client-side only
  • Blade's :: prefix prevents PHP evaluation, passes to Alpine
  • {{ $attributes }} passes through all HTML attributes
  • No custom server-side event syntax - it's all Alpine's client-side syntax

Their approach: Server renders HTML. Client framework (Alpine.js) handles the @click syntax at runtime.

Comparison with React and Vue

Click to expand: How React and Vue handle event syntax (client-side frameworks)

React's Approach

React uses camelCase event props (onClick, not onclick):

<button onClick={handleClick}>Click</button>

Why React does this:

  • React controls the entire runtime - events go through a synthetic event system
  • Cross-browser normalization (all events behave consistently)
  • Event delegation (single listener at root for performance)
  • Programmatic event handling (function references, not strings)
  • Technical necessity - React's JSX isn't HTML, it's JavaScript

You cannot use onclick in React - it won't work.

Vue's Approach

Vue uses @ prefix (@click or v-on:click):

<button @click="handleClick">Click</button>
<button @click.prevent.stop="handleSubmit">Submit</button>

Why Vue does this:

  • Event modifiers - .prevent, .stop, .once, .capture, .passive
  • Key modifiers - @keyup.enter, @keyup.ctrl.s
  • Mouse modifiers - @click.left, @click.right, @click.middle
  • Custom component events - Listen to child component events the same way
  • Method references - Intelligently handles both expressions and method names
  • Scoping - Methods are component-scoped, not global
  • Technical power - Vue compiles these into sophisticated runtime event handling

Vue's @click adds substantial runtime features that onclick cannot provide.

You can technically use onclick in Vue, but you lose all these features and pollute global scope.

This Library's Approach

This library uses @ syntax but only for template-time conveniences, not runtime features:

<c-button @click="handleClick()">Click</button>
<!-- Outputs: <button onclick="handleClick()">Click</button> -->

Key difference:

  • Vue's @ = Runtime compiler magic (modifiers, scoping, custom events)
  • This library's @ = Template preprocessing (passthrough)

Pros and Cons

Pros of Custom @ Syntax

  1. Pattern Familiarity - Developers from Vue/Alpine.js recognize the pattern
  2. Mental Model - "@" means dynamic behavior happens
  3. Visual Distinction - Clear signal that you're using component syntax, not raw HTML (if this leads to something different)
  4. Validation Bypass - Don't need to define every event in component definitions

Cons of Custom @ Syntax

  1. Progressive Adoption Friction

    • Copy-pasted examples need changes:
      • HTMX documentation (examples use hx-post, not @hx-post)
      • MDN/W3Schools (examples use onclick, not @click)
    • Updating existing code needs changes beyond c-* difference
  2. Steeper learnings from html

    • Need to learn custom syntax over normal html/htmx
    • onclick@click
    • hx-post@hx-post
  3. No Actual Benefits Over Passthrough - @hx-post is just passthrough to hx-post

    • No modifiers, no special handling
    • It's purely a syntactic transformation
    • Jinja variable resolution works already for all attributes (not just @)
    • Passthrough hx-post and onclick would have identical capabilities
  4. Looks like vue, but isnt - Looks like Vue but doesn't do what Vue does

  5. Opionated on htmx

    • it allows for use of htmx and explicitly maps the "@" syntax
    • not possible to use this in combination with alpinejs ("@click" syntax)

Alternative: Passthrough Attributes

Alternative to the custom syntaxt, the library could support standard HTML/HTMX syntax through a passthrough mechanism:

PASSTHROUGH_PATTERNS = {
    'hx-*',      # All HTMX attributes
    'on*',       # All event handlers (onclick, oninput, etc.)
    'data-*',    # Data attributes
    'aria-*',    # ARIA attributes
}

This would enable:

<!-- Standard syntax - copy-paste from docs -->
<c-button hx-post="/api/data" onclick="track()">Submit</c-button>

<!-- Current syntax -->
<c-button @hx-post="/api/data" @click="track()">Submit</c-button>

<!-- Both work, same output -->

Benefits:

  • Zero friction adoption - paste HTMX examples directly
  • Standard HTML event handlers work
  • Jinja variables work in both (same code path)
  • Simpler implementation (no hardcoded list of 14 HTMX attributes)
  • Future-proof (new HTMX attributes work automatically)
  • could also add passthrough for all "@" attributes to enable alpinejs

Technical Deep Dive: What About Vue-Style Modifiers?

Could this library implement Vue's .prevent, .stop, key modifiers, etc.?

Technically: Partial yes, practically: No.

What's possible:

# Could generate:
@click.preventonclick="event.preventDefault(); handleClick()"
@keyup.enteronkeyup="if(event.key==='Enter'){submit()}"
@click.leftonclick="if(event.button===0){leftClick()}"

What's not possible:

  • .capture, .passive (need addEventListener options, not inline handlers)
  • Component scoping (everything is global functions)
  • Custom component events (no runtime system)

Why not implement modifiers:

  1. CSP Incompatibility - Modern security best practices ban inline onclick:

    Content-Security-Policy: script-src 'self'

    Generated inline handlers won't work.

  2. Ugly Output - Generated code is verbose and hard to debug

  3. Still Inferior to Vue - No real scoping, no component events, no true reactivity

  4. Wrong Abstraction Layer - This is a server-side templating system, not a client-side framework

If users need Vue-style event handling, they should use Vue/Alpine.js/htmx for the client side. This library's strength is server-side component composition.

Questions for Discussion

  1. Given that @ provides currently no technical advantages, should we prioritize Vue/React-style familiarity or HTML/HTMX standard compatibility?
  2. Are there other benefits to the @ syntax I haven't considered?

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions