HTMX + Laravel: Xây Dựng Ứng Dụng Tương Tác Mà Không Cần JavaScript Framework
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?
- Server-side rendering first: Laravel Blade đã làm rất tốt việc render HTML
- Không cần API riêng: Trả về HTML fragments thay vì JSON
- SEO-friendly: Content được render từ server
- Đơn giản hóa stack: Không cần build step phức tạp
- 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ế
Ví dụ 1: Live Search
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
- HTMX Documentation
- HTMX Examples
- Laravel Blade Templates
- htmx.org Essays - Rất hay về philosophy