|
| 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