HTMX + Laravel: Xây Dựng Ứng Dụng Tương Tác Mà Không Cần JavaScript Framework

· 17 min read

Giới Thiệu

Bạn có bao giờ cảm thấy mệt mỏi với việc phải học thêm một JavaScript framework mới mỗi năm? React, Vue, Angular, Svelte... danh sách cứ dài thêm mãi. HTMX mang đến một cách tiếp cận hoàn toàn khác - sử dụng HTML thuần túy để tạo ứng dụng tương tác.

HTMX Là Gì?

HTMX là một thư viện JavaScript siêu nhẹ (~14KB gzipped) cho phép bạn truy cập AJAX, CSS Transitions, WebSockets và Server Sent Events trực tiếp trong HTML thông qua các attributes.

<!-- Trước đây với JavaScript -->
<button onclick="loadContent()">Load More</button>

<script>
function loadContent() {
    fetch('/api/content')
        .then(response => response.text())
        .then(html => {
            document.getElementById('content').innerHTML = html;
        });
}
</script>

<!-- Với HTMX - chỉ cần HTML -->
<button hx-get="/content" hx-target="#content" hx-swap="innerHTML">
    Load More
</button>

Tại Sao HTMX + Laravel Là Combo Hoàn Hảo?

  1. Server-side rendering first: Laravel Blade đã làm rất tốt việc render HTML
  2. Không cần API riêng: Trả về HTML fragments thay vì JSON
  3. SEO-friendly: Content được render từ server
  4. Đơn giản hóa stack: Không cần build step phức tạp
  5. Giữ nguyên Laravel conventions: Blade components, validation, sessions...

Cài Đặt HTMX

Cách 1: CDN (Nhanh nhất)

<!-- resources/views/layouts/app.blade.php -->
<!DOCTYPE html>
<html>
<head>
    <script src="https://unpkg.com/htmx.org@2.0.0"></script>
</head>
<body>
    @yield('content')
</body>
</html>

Cách 2: NPM (Khuyên dùng cho production)

npm install htmx.org
// resources/js/app.js
import htmx from 'htmx.org';
window.htmx = htmx;
// vite.config.js
export default defineConfig({
    plugins: [
        laravel({
            input: ['resources/css/app.css', 'resources/js/app.js'],
            refresh: true,
        }),
    ],
});

Các HTMX Attributes Cơ Bản

hx-get, hx-post, hx-put, hx-delete

<!-- GET request -->
<button hx-get="/users">Load Users</button>

<!-- POST request với form -->
<form hx-post="/users" hx-target="#user-list">
    <input name="name" required>
    <button type="submit">Add User</button>
</form>

<!-- DELETE request -->
<button hx-delete="/users/1" hx-confirm="Bạn chắc chắn?">
    Delete User
</button>

hx-target và hx-swap

<!-- Target: Chỉ định element sẽ được update -->
<button hx-get="/notifications" hx-target="#notification-panel">
    Refresh Notifications
</button>

<!-- Swap: Cách thức update content -->
<button hx-get="/items" hx-target="#list" hx-swap="beforeend">
    Load More (append)
</button>

Các giá trị hx-swap phổ biến:

Value Mô tả
innerHTML Thay thế inner HTML (mặc định)
outerHTML Thay thế toàn bộ element
beforeend Append vào cuối
afterbegin Prepend vào đầu
beforebegin Insert trước element
afterend Insert sau element
delete Xóa element
none Không swap (chỉ trigger events)

hx-trigger

<!-- Trigger khi click (mặc định cho buttons) -->
<button hx-get="/data" hx-trigger="click">Click me</button>

<!-- Trigger khi hover -->
<div hx-get="/preview" hx-trigger="mouseenter">Hover for preview</div>

<!-- Trigger khi input thay đổi với debounce -->
<input hx-get="/search" 
       hx-target="#results"
       hx-trigger="keyup changed delay:500ms"
       name="q">

<!-- Trigger khi element visible (lazy loading) -->
<div hx-get="/more-content" hx-trigger="revealed">
    Loading...
</div>

<!-- Trigger từ event khác -->
<button hx-get="/refresh" hx-trigger="newMessage from:body">
    Messages
</button>

Xây Dựng Ứng Dụng Thực Tế

Controller:

// app/Http/Controllers/SearchController.php
namespace App\Http\Controllers;

use App\Models\Product;
use Illuminate\Http\Request;

class SearchController extends Controller
{
    public function index()
    {
        return view('search.index');
    }

    public function search(Request $request)
    {
        $query = $request->input('q', '');
        
        $products = Product::query()
            ->when($query, function ($q) use ($query) {
                $q->where('name', 'like', "%{$query}%")
                  ->orWhere('description', 'like', "%{$query}%");
            })
            ->limit(20)
            ->get();

        // Trả về HTML partial, không phải full page
        return view('search.partials.results', compact('products'));
    }
}

Main View:

{{-- resources/views/search/index.blade.php --}}
@extends('layouts.app')

@section('content')
<div class="container mx-auto px-4 py-8">
    <h1 class="text-2xl font-bold mb-6">Tìm Kiếm Sản Phẩm</h1>
    
    <div class="mb-6">
        <input type="text" 
               name="q"
               placeholder="Nhập tên sản phẩm..."
               class="w-full p-3 border rounded-lg"
               hx-get="{{ route('search') }}"
               hx-target="#results"
               hx-trigger="keyup changed delay:300ms"
               hx-indicator="#loading">
        
        <div id="loading" class="htmx-indicator">
            <span class="text-gray-500">Đang tìm kiếm...</span>
        </div>
    </div>
    
    <div id="results">
        {{-- Kết quả sẽ được load vào đây --}}
    </div>
</div>
@endsection

Partial View:

{{-- resources/views/search/partials/results.blade.php --}}
@if($products->isEmpty())
    <p class="text-gray-500">Không tìm thấy sản phẩm nào.</p>
@else
    <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
        @foreach($products as $product)
            <div class="border rounded-lg p-4 hover:shadow-lg transition">
                <h3 class="font-semibold">{{ $product->name }}</h3>
                <p class="text-gray-600 text-sm">{{ Str::limit($product->description, 100) }}</p>
                <p class="text-blue-600 font-bold mt-2">
                    {{ number_format($product->price) }}đ
                </p>
            </div>
        @endforeach
    </div>
@endif

Ví dụ 2: Infinite Scroll

Controller:

// app/Http/Controllers/PostController.php
public function index(Request $request)
{
    $page = $request->input('page', 1);
    $posts = Post::latest()
        ->paginate(10, ['*'], 'page', $page);

    if ($request->header('HX-Request')) {
        // HTMX request - trả về partial
        return view('posts.partials.list', compact('posts'));
    }

    // Full page load
    return view('posts.index', compact('posts'));
}

Main View:

{{-- resources/views/posts/index.blade.php --}}
@extends('layouts.app')

@section('content')
<div class="container mx-auto px-4 py-8">
    <h1 class="text-2xl font-bold mb-6">Bài Viết</h1>
    
    <div id="posts-container">
        @include('posts.partials.list', ['posts' => $posts])
    </div>
</div>
@endsection

Partial View với Infinite Scroll:

{{-- resources/views/posts/partials/list.blade.php --}}
@foreach($posts as $post)
    <article class="border-b py-4">
        <h2 class="text-xl font-semibold">
            <a href="{{ route('posts.show', $post) }}">{{ $post->title }}</a>
        </h2>
        <p class="text-gray-600 mt-2">{{ Str::limit($post->excerpt, 200) }}</p>
        <time class="text-sm text-gray-400">{{ $post->created_at->format('d/m/Y') }}</time>
    </article>
@endforeach

@if($posts->hasMorePages())
    {{-- Element này sẽ trigger load thêm khi visible --}}
    <div hx-get="{{ $posts->nextPageUrl() }}"
         hx-trigger="revealed"
         hx-swap="outerHTML"
         hx-indicator="#loading-more"
         class="py-4 text-center">
        <span id="loading-more" class="htmx-indicator">
            Đang tải thêm...
        </span>
    </div>
@endif

Ví dụ 3: Modal CRUD

Routes:

// routes/web.php
Route::resource('tasks', TaskController::class);
Route::get('tasks/{task}/edit-modal', [TaskController::class, 'editModal'])
    ->name('tasks.edit-modal');

Controller:

// app/Http/Controllers/TaskController.php
namespace App\Http\Controllers;

use App\Models\Task;
use Illuminate\Http\Request;

class TaskController extends Controller
{
    public function index()
    {
        $tasks = Task::latest()->get();
        return view('tasks.index', compact('tasks'));
    }

    public function store(Request $request)
    {
        $validated = $request->validate([
            'title' => 'required|string|max:255',
            'description' => 'nullable|string',
        ]);

        $task = Task::create($validated);

        return view('tasks.partials.task-row', compact('task'));
    }

    public function editModal(Task $task)
    {
        return view('tasks.partials.edit-modal', compact('task'));
    }

    public function update(Request $request, Task $task)
    {
        $validated = $request->validate([
            'title' => 'required|string|max:255',
            'description' => 'nullable|string',
        ]);

        $task->update($validated);

        // Đóng modal và refresh row
        return response()
            ->view('tasks.partials.task-row', compact('task'))
            ->header('HX-Trigger', 'closeModal');
    }

    public function destroy(Task $task)
    {
        $task->delete();

        // Trả về empty response với swap delete
        return response('', 200);
    }
}

Main View:

{{-- resources/views/tasks/index.blade.php --}}
@extends('layouts.app')

@section('content')
<div class="container mx-auto px-4 py-8">
    <div class="flex justify-between items-center mb-6">
        <h1 class="text-2xl font-bold">Tasks</h1>
        <button hx-get="{{ route('tasks.create') }}"
                hx-target="#modal-container"
                hx-swap="innerHTML"
                class="bg-blue-500 text-white px-4 py-2 rounded">
            + Thêm Task
        </button>
    </div>

    {{-- Task list --}}
    <div id="task-list" class="space-y-2">
        @foreach($tasks as $task)
            @include('tasks.partials.task-row', ['task' => $task])
        @endforeach
    </div>

    {{-- Modal container --}}
    <div id="modal-container"></div>
</div>

<script>
    // Đóng modal khi nhận được trigger
    document.body.addEventListener('closeModal', function() {
        document.getElementById('modal-container').innerHTML = '';
    });
</script>
@endsection

Task Row Partial:

{{-- resources/views/tasks/partials/task-row.blade.php --}}
<div id="task-{{ $task->id }}" class="flex items-center justify-between p-4 bg-white rounded-lg shadow">
    <div>
        <h3 class="font-semibold">{{ $task->title }}</h3>
        @if($task->description)
            <p class="text-gray-600 text-sm">{{ $task->description }}</p>
        @endif
    </div>
    
    <div class="flex gap-2">
        <button hx-get="{{ route('tasks.edit-modal', $task) }}"
                hx-target="#modal-container"
                hx-swap="innerHTML"
                class="text-blue-500 hover:text-blue-700">
            Sửa
        </button>
        
        <button hx-delete="{{ route('tasks.destroy', $task) }}"
                hx-target="#task-{{ $task->id }}"
                hx-swap="outerHTML"
                hx-confirm="Xóa task này?"
                class="text-red-500 hover:text-red-700">
            Xóa
        </button>
    </div>
</div>

Modal Partial:

{{-- resources/views/tasks/partials/edit-modal.blade.php --}}
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
     id="modal-backdrop"
     hx-on:click="if(event.target === this) this.remove()">
    <div class="bg-white rounded-lg p-6 w-full max-w-md" 
         hx-on:click="event.stopPropagation()">
        <h2 class="text-xl font-bold mb-4">Sửa Task</h2>
        
        <form hx-put="{{ route('tasks.update', $task) }}"
              hx-target="#task-{{ $task->id }}"
              hx-swap="outerHTML">
            @csrf
            @method('PUT')
            
            <div class="mb-4">
                <label class="block text-sm font-medium mb-1">Tiêu đề</label>
                <input type="text" 
                       name="title" 
                       value="{{ $task->title }}"
                       class="w-full p-2 border rounded"
                       required>
            </div>
            
            <div class="mb-4">
                <label class="block text-sm font-medium mb-1">Mô tả</label>
                <textarea name="description" 
                          rows="3"
                          class="w-full p-2 border rounded">{{ $task->description }}</textarea>
            </div>
            
            <div class="flex gap-2 justify-end">
                <button type="button" 
                        onclick="document.getElementById('modal-container').innerHTML = ''"
                        class="px-4 py-2 border rounded">
                    Hủy
                </button>
                <button type="submit" 
                        class="px-4 py-2 bg-blue-500 text-white rounded">
                    Lưu
                </button>
            </div>
        </form>
    </div>
</div>

Xử Lý Validation Errors

HTMX hoạt động rất tốt với Laravel validation. Khi có lỗi, chỉ cần trả về form với errors:

// Controller
public function store(Request $request)
{
    $validator = Validator::make($request->all(), [
        'email' => 'required|email|unique:users',
        'password' => 'required|min:8',
    ]);

    if ($validator->fails()) {
        return response()
            ->view('auth.partials.register-form', [
                'errors' => $validator->errors(),
                'old' => $request->all(),
            ])
            ->setStatusCode(422); // Quan trọng: trả về 422 để HTMX biết là error
    }

    // Success logic...
}
{{-- Form partial --}}
<form hx-post="{{ route('register') }}" 
      hx-target="this" 
      hx-swap="outerHTML">
    
    <div class="mb-4">
        <input type="email" 
               name="email" 
               value="{{ old('email', $old['email'] ?? '') }}"
               class="w-full p-2 border rounded @error('email') border-red-500 @enderror">
        @error('email')
            <p class="text-red-500 text-sm mt-1">{{ $message }}</p>
        @enderror
    </div>
    
    <button type="submit">Register</button>
</form>

Loading States và Indicators

<!-- CSS cho indicators -->
<style>
    .htmx-indicator {
        display: none;
    }
    .htmx-request .htmx-indicator {
        display: inline;
    }
    .htmx-request.htmx-indicator {
        display: inline;
    }
</style>

<!-- Button với loading state -->
<button hx-post="/process" 
        hx-indicator="#spinner"
        class="relative">
    <span>Submit</span>
    <span id="spinner" class="htmx-indicator absolute right-2">
        <svg class="animate-spin h-5 w-5" viewBox="0 0 24 24">
            <!-- Spinner SVG -->
        </svg>
    </span>
</button>

<!-- Disable button trong khi loading -->
<button hx-post="/process" 
        hx-disabled-elt="this"
        class="disabled:opacity-50">
    Submit
</button>

HTMX với Laravel Middleware

Tạo middleware để xử lý HTMX requests:

// app/Http/Middleware/HtmxMiddleware.php
namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;

class HtmxMiddleware
{
    public function handle(Request $request, Closure $next)
    {
        // Kiểm tra xem có phải HTMX request không
        $request->macro('isHtmx', function () use ($request) {
            return $request->hasHeader('HX-Request');
        });

        // Lấy target element
        $request->macro('htmxTarget', function () use ($request) {
            return $request->header('HX-Target');
        });

        // Lấy trigger element
        $request->macro('htmxTrigger', function () use ($request) {
            return $request->header('HX-Trigger');
        });

        $response = $next($request);

        // Thêm HTMX headers nếu cần
        if ($request->isHtmx()) {
            // Có thể redirect trong HTMX bằng header
            // $response->header('HX-Redirect', '/new-url');
            
            // Hoặc refresh toàn bộ page
            // $response->header('HX-Refresh', 'true');
        }

        return $response;
    }
}
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
    $middleware->web(append: [
        \App\Http\Middleware\HtmxMiddleware::class,
    ]);
})

HTMX Extensions Hữu Ích

1. json-enc - Gửi JSON thay vì form data

<script src="https://unpkg.com/htmx.org/dist/ext/json-enc.js"></script>

<form hx-post="/api/data" hx-ext="json-enc">
    <input name="title" value="Hello">
    <button>Submit as JSON</button>
</form>

2. loading-states - Quản lý loading state tốt hơn

<script src="https://unpkg.com/htmx.org/dist/ext/loading-states.js"></script>

<button hx-get="/data" 
        hx-ext="loading-states"
        data-loading-class="opacity-50 cursor-wait"
        data-loading-disable>
    Load Data
</button>

3. response-targets - Target khác nhau theo status code

<script src="https://unpkg.com/htmx.org/dist/ext/response-targets.js"></script>

<form hx-post="/submit" 
      hx-ext="response-targets"
      hx-target="#success-message"
      hx-target-422="#error-message"
      hx-target-500="#server-error">
    <!-- form fields -->
</form>

So Sánh HTMX vs JavaScript Frameworks

Tiêu chí HTMX React/Vue
Learning curve Thấp Cao
Bundle size ~14KB 100KB+
Build step Không cần Bắt buộc
SEO Tốt (SSR mặc định) Cần cấu hình
State management Server-side Client-side
Real-time updates Server Sent Events WebSocket
Offline support Hạn chế Tốt (PWA)
Complex interactions Có thể khó Linh hoạt hơn

Best Practices

1. Sử dụng Blade Components

{{-- resources/views/components/htmx-button.blade.php --}}
@props([
    'method' => 'get',
    'url',
    'target' => 'body',
    'swap' => 'innerHTML',
    'confirm' => null,
])

<button 
    {{ $attributes->class(['btn']) }}
    hx-{{ $method }}="{{ $url }}"
    hx-target="{{ $target }}"
    hx-swap="{{ $swap }}"
    @if($confirm) hx-confirm="{{ $confirm }}" @endif
>
    {{ $slot }}
</button>
<x-htmx-button url="/tasks" target="#task-list">
    Load Tasks
</x-htmx-button>

<x-htmx-button 
    method="delete" 
    url="/tasks/1" 
    target="#task-1" 
    swap="outerHTML"
    confirm="Xóa task này?">
    Delete
</x-htmx-button>

2. Organize Partials

resources/views/
├── tasks/
│   ├── index.blade.php          # Full page
│   ├── partials/
│   │   ├── list.blade.php       # Task list
│   │   ├── task-row.blade.php   # Single task
│   │   ├── create-form.blade.php
│   │   └── edit-modal.blade.php

3. CSRF Token

<!-- Global CSRF setup -->
<meta name="csrf-token" content="{{ csrf_token() }}">

<script>
    document.body.addEventListener('htmx:configRequest', (event) => {
        event.detail.headers['X-CSRF-TOKEN'] = document.querySelector('meta[name="csrf-token"]').content;
    });
</script>

Kết Luận

HTMX + Laravel là một combo cực kỳ mạnh mẽ cho việc xây dựng ứng dụng web tương tác mà không cần đến sự phức tạp của JavaScript frameworks. Với approach "HTML over the wire", bạn có thể:

  • Giữ nguyên Laravel conventions - Blade templates, server-side validation, sessions
  • Giảm complexity - Không cần API riêng, không cần state management phức tạp
  • Tăng productivity - Ít code hơn, ít bug hơn
  • SEO-friendly - Content render từ server

HTMX không phải để thay thế hoàn toàn React/Vue trong mọi trường hợp, nhưng cho đa số ứng dụng CRUD và content-focused, nó là lựa chọn tuyệt vời.

Khi Nào Nên Dùng HTMX?

  • CRUD applications
  • Admin panels
  • Content websites
  • Forms-heavy applications
  • Server-rendered apps cần thêm interactivity

Khi Nào Nên Cân Nhắc React/Vue?

  • Complex client-side state
  • Offline-first applications
  • Real-time collaboration
  • Heavy animations/transitions
  • Mobile apps (React Native)

Tài Liệu Tham Khảo

Bình luận