Skip to content

Commit 8a4a1fb

Browse files
authored
Fix #395: Add htmx partial reloads recipe (#445)
1 parent 403f801 commit 8a4a1fb

3 files changed

Lines changed: 376 additions & 0 deletions

File tree

‎src/.vitepress/config.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ export default {
188188
items: [
189189
{text: 'Preface', link: '/cookbook/preface'},
190190
{text: 'Making HTTP Requests', link: '/cookbook/making-http-requests'},
191+
{text: 'Using htmx for Partial Page Reloads', link: '/cookbook/using-htmx-for-partial-reloads'},
191192
{text: 'Disabling CSRF Protection', link: '/cookbook/disabling-csrf-protection'},
192193
{text: 'Sentry Integration', link: '/cookbook/sentry-integration'}
193194
]

‎src/cookbook/index.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ This book conforms to the [Terms of Yii Documentation](https://www.yiiframework.
1414

1515
- [Preface](preface.md)
1616
- [Making HTTP requests](making-http-requests.md)
17+
- [Using htmx for partial page reloads](using-htmx-for-partial-reloads.md)
1718
- [Disabling CSRF protection](disabling-csrf-protection.md)
1819
- [Sentry integration](sentry-integration.md)
1920
- [Working on Windows](working-on-windows.md)
Lines changed: 374 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,374 @@
1+
# Using htmx for partial page reloads
2+
3+
[htmx](https://htmx.org/) lets links and forms request HTML fragments and swap them into the current page. In a Yii
4+
application, this works well for grids: sorting, filtering, and pagination can update only the grid while the rest of
5+
the page stays in place.
6+
7+
This recipe uses `GridView` as an example because it has links and forms that naturally benefit from partial reloads.
8+
The same pattern works for any widget or page fragment: render the fragment separately, add htmx attributes to the
9+
controls that should refresh it, and return only that fragment for htmx requests.
10+
11+
## Install GridView
12+
13+
Install the data view package if the application doesn't use it yet:
14+
15+
```shell
16+
composer require yiisoft/yii-dataview
17+
```
18+
19+
## Load htmx
20+
21+
The simplest setup is loading htmx from a CDN. Create `src/Web/Shared/Layout/Main/HtmxAsset.php`:
22+
23+
```php
24+
<?php
25+
26+
declare(strict_types=1);
27+
28+
namespace App\Web\Shared\Layout\Main;
29+
30+
use Yiisoft\Assets\AssetBundle;
31+
32+
final class HtmxAsset extends AssetBundle
33+
{
34+
public bool $cdn = true;
35+
36+
public array $js = [
37+
'https://cdn.jsdelivr.net/npm/htmx.org@2.0.10/dist/htmx.min.js',
38+
];
39+
40+
public array $jsOptions = [
41+
'integrity' => 'sha384-H5SrcfygHmAuTDZphMHqBJLc3FhssKjG7w/CeCpFReSfwBWDTKpkzPP8c+cLsK+V',
42+
'crossorigin' => 'anonymous',
43+
];
44+
}
45+
```
46+
47+
When updating the htmx version, update the URL and `integrity` value together.
48+
49+
For production applications, consider serving htmx from your application assets to avoid depending on a third-party CDN.
50+
Download `htmx.min.js` into `assets/main/js/htmx.min.js` and use a local asset bundle:
51+
52+
```php
53+
<?php
54+
55+
declare(strict_types=1);
56+
57+
namespace App\Web\Shared\Layout\Main;
58+
59+
use Yiisoft\Assets\AssetBundle;
60+
61+
final class HtmxAsset extends AssetBundle
62+
{
63+
public ?string $basePath = '@assets/main';
64+
public ?string $baseUrl = '@assetsUrl/main';
65+
public ?string $sourcePath = '@assetsSource/main';
66+
67+
public array $js = [
68+
'js/htmx.min.js',
69+
];
70+
}
71+
```
72+
73+
Add the bundle to the main layout asset:
74+
75+
```php
76+
<?php
77+
78+
declare(strict_types=1);
79+
80+
namespace App\Web\Shared\Layout\Main;
81+
82+
use Yiisoft\Assets\AssetBundle;
83+
84+
final class MainAsset extends AssetBundle
85+
{
86+
public ?string $basePath = '@assets/main';
87+
public ?string $baseUrl = '@assetsUrl/main';
88+
public ?string $sourcePath = '@assetsSource/main';
89+
90+
public array $css = [
91+
'site.css',
92+
];
93+
94+
public array $depends = [
95+
BootstrapAsset::class,
96+
HtmxAsset::class,
97+
];
98+
}
99+
```
100+
101+
## Create the reloadable part
102+
103+
Start with a regular page that renders a grid. In a clean `yiisoft/app` project, this means adding an action, a view
104+
template, and a widget that renders the grid. The first version doesn't need htmx. It should work as a normal full page.
105+
106+
Create `src/Web/Users/Index/UsersGrid.php`:
107+
108+
```php
109+
<?php
110+
111+
declare(strict_types=1);
112+
113+
namespace App\Web\Users\Index;
114+
115+
use Yiisoft\Data\Paginator\PaginatorInterface;
116+
use Yiisoft\Data\Reader\Iterable\IterableDataReader;
117+
use Yiisoft\Widget\Widget;
118+
use Yiisoft\Yii\DataView\Column\DataColumn;
119+
use Yiisoft\Yii\DataView\GridView\GridView;
120+
use Yiisoft\Yii\DataView\Pagination\OffsetPagination;
121+
use Yiisoft\Yii\DataView\Pagination\PaginationWidgetInterface;
122+
123+
final class UsersGrid extends Widget
124+
{
125+
public function render(): string
126+
{
127+
/** @var PaginationWidgetInterface<PaginatorInterface> */
128+
$pagination = OffsetPagination::widget();
129+
130+
return GridView::widget()
131+
->containerAttributes([
132+
'id' => 'UsersGridView',
133+
'class' => 'mt-4 position-relative',
134+
])
135+
->dataReader($this->dataReader())
136+
->pageSizeConstraint(5)
137+
->paginationWidget($pagination)
138+
->columns(
139+
new DataColumn('id', header: 'ID', filter: true),
140+
new DataColumn('login', header: 'Login', filter: true),
141+
new DataColumn('name', header: 'Name', filter: true),
142+
new DataColumn('status', header: 'Status', filter: true),
143+
)
144+
->render();
145+
}
146+
147+
private function dataReader(): IterableDataReader
148+
{
149+
return new IterableDataReader([
150+
['id' => 1, 'login' => 'admin', 'name' => 'Alice Adams', 'status' => 'Active'],
151+
['id' => 2, 'login' => 'editor', 'name' => 'Eve Editor', 'status' => 'Active'],
152+
['id' => 3, 'login' => 'author', 'name' => 'Arthur Author', 'status' => 'Active'],
153+
['id' => 4, 'login' => 'reviewer', 'name' => 'Rita Reviewer', 'status' => 'Inactive'],
154+
['id' => 5, 'login' => 'support', 'name' => 'Sam Support', 'status' => 'Active'],
155+
['id' => 6, 'login' => 'guest', 'name' => 'Grace Guest', 'status' => 'Inactive'],
156+
]);
157+
}
158+
}
159+
```
160+
161+
For a real application, replace `dataReader()` with a reader backed by a database query or repository. Keep the widget
162+
responsible for rendering the grid fragment.
163+
164+
Create `src/Web/Users/Index/template.php`:
165+
166+
```php
167+
<?php
168+
169+
declare(strict_types=1);
170+
171+
use App\Web\Users\Index\UsersGrid;
172+
use Yiisoft\View\WebView;
173+
174+
/**
175+
* @var WebView $this
176+
*/
177+
178+
$this->setTitle('Users');
179+
?>
180+
<h1>Users</h1>
181+
182+
<?= UsersGrid::widget() ?>
183+
```
184+
185+
Create `src/Web/Users/Index/Action.php`:
186+
187+
```php
188+
<?php
189+
190+
declare(strict_types=1);
191+
192+
namespace App\Web\Users\Index;
193+
194+
use Psr\Http\Message\ResponseInterface;
195+
use Yiisoft\Yii\View\Renderer\WebViewRenderer;
196+
197+
final readonly class Action
198+
{
199+
public function __construct(
200+
private WebViewRenderer $viewRenderer,
201+
) {
202+
}
203+
204+
public function __invoke(): ResponseInterface
205+
{
206+
return $this->viewRenderer->render(__DIR__ . '/template');
207+
}
208+
}
209+
```
210+
211+
Add the action to `config/common/routes.php`:
212+
213+
```php
214+
<?php
215+
216+
declare(strict_types=1);
217+
218+
use App\Web;
219+
use Yiisoft\Router\Group;
220+
use Yiisoft\Router\Route;
221+
222+
return [
223+
Group::create()
224+
->routes(
225+
Route::get('/')
226+
->action(Web\HomePage\Action::class)
227+
->name('home'),
228+
Route::get('/users')
229+
->action(Web\Users\Index\Action::class)
230+
->name('users/index'),
231+
),
232+
];
233+
```
234+
235+
Open `/users` and make sure sorting, filtering, and pagination work with full page reloads first.
236+
237+
## Add htmx attributes to the grid
238+
239+
Now update `UsersGrid` so the grid controls request a fragment and swap it into `#UsersGridView`:
240+
241+
```php
242+
<?php
243+
244+
declare(strict_types=1);
245+
246+
namespace App\Web\Users\Index;
247+
248+
use Yiisoft\Data\Paginator\PaginatorInterface;
249+
use Yiisoft\Data\Reader\Iterable\IterableDataReader;
250+
use Yiisoft\Widget\Widget;
251+
use Yiisoft\Yii\DataView\Column\DataColumn;
252+
use Yiisoft\Yii\DataView\GridView\GridView;
253+
use Yiisoft\Yii\DataView\Pagination\OffsetPagination;
254+
use Yiisoft\Yii\DataView\Pagination\PaginationWidgetInterface;
255+
256+
final class UsersGrid extends Widget
257+
{
258+
public function render(): string
259+
{
260+
$htmxLoadAttributes = [
261+
'hx-indicator' => '#UsersGridView',
262+
'hx-target' => '#UsersGridView',
263+
'hx-replace-url' => 'true',
264+
'hx-swap' => 'outerHTML',
265+
];
266+
267+
/** @var PaginationWidgetInterface<PaginatorInterface> */
268+
$pagination = OffsetPagination::widget()->addLinkAttributes([
269+
'hx-boost' => 'true',
270+
...$htmxLoadAttributes,
271+
]);
272+
273+
return GridView::widget()
274+
->containerAttributes([
275+
'id' => 'UsersGridView',
276+
'class' => 'mt-4 position-relative',
277+
])
278+
->dataReader($this->dataReader())
279+
->pageSizeConstraint(5)
280+
->sortableLinkAttributes([
281+
'hx-boost' => 'true',
282+
...$htmxLoadAttributes,
283+
])
284+
->filterFormAttributes([
285+
'hx-boost' => 'true',
286+
...$htmxLoadAttributes,
287+
])
288+
->paginationWidget($pagination)
289+
->columns(
290+
new DataColumn('id', header: 'ID', filter: true),
291+
new DataColumn('login', header: 'Login', filter: true),
292+
new DataColumn('name', header: 'Name', filter: true),
293+
new DataColumn('status', header: 'Status', filter: true),
294+
)
295+
->render();
296+
}
297+
298+
private function dataReader(): IterableDataReader
299+
{
300+
return new IterableDataReader([
301+
['id' => 1, 'login' => 'admin', 'name' => 'Alice Adams', 'status' => 'Active'],
302+
['id' => 2, 'login' => 'editor', 'name' => 'Eve Editor', 'status' => 'Active'],
303+
['id' => 3, 'login' => 'author', 'name' => 'Arthur Author', 'status' => 'Active'],
304+
['id' => 4, 'login' => 'reviewer', 'name' => 'Rita Reviewer', 'status' => 'Inactive'],
305+
['id' => 5, 'login' => 'support', 'name' => 'Sam Support', 'status' => 'Active'],
306+
['id' => 6, 'login' => 'guest', 'name' => 'Grace Guest', 'status' => 'Inactive'],
307+
]);
308+
}
309+
}
310+
```
311+
312+
The important part is the shared `$htmxLoadAttributes` array:
313+
314+
- `hx-boost="true"` makes the grid links and filter form send htmx requests.
315+
- `hx-target="#UsersGridView"` tells htmx which element to replace.
316+
- `hx-swap="outerHTML"` replaces the whole grid container with the response.
317+
- `hx-replace-url="true"` keeps the browser URL in sync with the current sort, filter, and page.
318+
- `hx-indicator="#UsersGridView"` marks the grid as the loading indicator element while the request is active.
319+
320+
The target selector and the container `id` must match. In this example, both use `UsersGridView`.
321+
322+
## Return partial HTML for htmx requests
323+
324+
htmx sends the `HX-Request` header with its requests.
325+
326+
In the action, return only the widget when the header is present:
327+
328+
```php
329+
<?php
330+
331+
declare(strict_types=1);
332+
333+
namespace App\Web\Users\Index;
334+
335+
use Psr\Http\Message\ResponseInterface;
336+
use Psr\Http\Message\ServerRequestInterface;
337+
use Yiisoft\DataResponse\ResponseFactory\HtmlResponseFactory;
338+
use Yiisoft\Yii\View\Renderer\WebViewRenderer;
339+
340+
final readonly class Action
341+
{
342+
public function __construct(
343+
private WebViewRenderer $viewRenderer,
344+
private HtmlResponseFactory $htmlResponseFactory,
345+
) {
346+
}
347+
348+
public function __invoke(ServerRequestInterface $request): ResponseInterface
349+
{
350+
$response = $request->getHeaderLine('HX-Request') === 'true'
351+
? $this->htmlResponseFactory->createResponse(UsersGrid::widget())
352+
: $this->viewRenderer->render(__DIR__ . '/template');
353+
354+
return $response->withAddedHeader('Vary', 'HX-Request');
355+
}
356+
}
357+
```
358+
359+
The `Vary` header matters when a browser, proxy, or CDN caches responses. The same URL can return a full page without
360+
`HX-Request` and a fragment with `HX-Request: true`, so caches must keep these responses separate.
361+
362+
## Try it
363+
364+
Open the list page and use sorting, filtering, or pagination. The browser URL should change, and only the grid HTML
365+
should be replaced.
366+
367+
In the browser developer tools, the htmx requests should include the `HX-Request` header. Their responses should contain
368+
only the grid container.
369+
370+
Use the same pattern for other widgets or page fragments:
371+
372+
1. Put the reloadable HTML into a widget or another small renderer.
373+
2. Add htmx attributes to the links and forms that should refresh it.
374+
3. Return the fragment for htmx requests and the full page for regular requests.

0 commit comments

Comments
 (0)