🚀 pbFetch: Asynchronous Requests via HTML Attributes
pbFetch is a native JavaScript class for handling AJAX requests, inspired by the HTMX approach but with its own pb- namespace. It sends GET, POST, PUT and DELETE requests without reloading the page and lets you update content dynamically through declarative attributes.
🔗 Installation
- Enable the
pageblocks_load_scriptssetting so the script is included automatically. - Or include it manually:
<script src="/assets/components/pageblocks/js/web/pb.fetch.v300.js"></script>
<script>
pbFetch.init();
</script>init() takes a MODX context key, which is then sent in the X-CONTEXT-KEY header:
pbFetch.init('web');With automatic inclusion the key is filled in from the current page context.
⚡️ Key Features
- Asynchronous loading of HTML or JSON responses.
- Content updates via
pb-targetandpb-swap. - Automatic message display via
pbMessage. - Action confirmation before the request (
pb-confirm). - Loading indicator support.
- Event handling at every stage.
- Support for multiple events in
pb-trigger. - Auto-loading with
pb-trigger="load". - Cancellation of "stale" requests via
AbortController.
🗂️ Core Attributes
| Attribute | Description |
|---|---|
pb-get | Sends a GET request. The attribute value is the URL. |
pb-post | Sends a POST request. |
pb-put | Sends a PUT request. |
pb-delete | Sends a DELETE request. |
pb-target | CSS selector of the element where the response will be inserted. |
pb-swap | Insertion method: innerHTML, beforeend, afterbegin, beforebegin, afterend, outerHTML. Special values for JSON: active, inactive, delete, hide |
pb-expect | Expected response format: html (default) or json. |
pb-trigger | Events that fire the request: click, submit, change, load (several can be listed, comma-separated). Defaults to click. |
pb-vals | JSON string with data for the request body. |
pb-include | CSS selector of the form fields to include in the request. |
pb-indicator | CSS selector of the loading indicator. |
pb-confirm | Confirmation text. The request is only sent once the user agrees. |
pb-redirect | Where to go after a successful JSON response. The value reload reloads the page. |
PATCH is JavaScript-only
There is no pb-patch HTML attribute: the delegated handler only picks up [pb-get], [pb-post], [pb-put], [pb-delete]. PATCH is available through the pbFetch.patch() JS method.
URL Placeholders
The value of pb-get / pb-post / pb-put / pb-delete may contain substitutions — they are taken from the element itself and escaped with encodeURIComponent:
{value}→ the current value of the element (el.value){id}→ theidattribute of the element{name}→ thenameattribute of the element
An empty URL usually means a server-side route() that found no matching route and returned an empty string. In that case pbFetch does not send the request and logs pbFetch: empty url on element to the console.
🔸 HTML (pb-expect="html")
Insertion happens only when pb-target is set. Without it the response is simply returned from the method and the DOM stays untouched.
1. innerHTML (default)
Replaces the content of the target element.
<div id="box">Old content</div>
<button pb-trigger="click"
pb-get="/get/new-content"
pb-target="#box"
pb-expect="html"
pb-swap="innerHTML">
Replace content
</button>➡ After the request, new HTML appears inside #box and the old content is removed.
2. outerHTML
Replaces the element completely, tag included.
<div id="box">Content</div>
<button pb-trigger="click"
pb-get="/get/full-box"
pb-target="#box"
pb-expect="html"
pb-swap="outerHTML">
Replace element
</button>➡ #box is replaced entirely (including the <div>).
3. beforebegin
Inserts HTML before the element.
<div id="item">Item</div>
<button pb-trigger="click"
pb-get="/get/row"
pb-target="#item"
pb-expect="html"
pb-swap="beforebegin">
Insert before element
</button>➡ The new HTML is inserted before #item; #item itself stays unchanged.
4. afterend
Inserts HTML after the element.
<div id="item">Item</div>
<button pb-trigger="click"
pb-get="/get/row"
pb-target="#item"
pb-expect="html"
pb-swap="afterend">
Insert after element
</button>➡ The new HTML is inserted immediately after #item.
5. beforeend
Appends HTML to the end of the element's content.
<ul id="list">
<li>Item 1</li>
</ul>
<button pb-trigger="click"
pb-get="/get/item"
pb-target="#list"
pb-expect="html"
pb-swap="beforeend">
Add item
</button>➡ The new item is added to the end of the list (<li> after Item 1).
6. afterbegin
Prepends HTML to the beginning of the element's content.
<ul id="list">
<li>Item 1</li>
</ul>
<button pb-trigger="click"
pb-get="/get/item"
pb-target="#list"
pb-expect="html"
pb-swap="afterbegin">
Add item
</button>➡ The new item appears at the beginning of the list, before Item 1.
{ } JSON (pb-expect="json")
When pb-expect="json" is set:
- the
Accept: application/jsonandX-Requested-With: XMLHttpRequestheaders are added to the request; - the response is parsed with
response.json(); pbFetchthen handles the response fields it knows about — in a strict order.
Processing Order for a Successful JSON Response
data.redirect— if the field is present and not empty, the browser navigates and processing stops.- The
pb-redirectattribute —reloadreloads the page, otherwise it navigates to the given address. Processing stops here too. data.message— displayed viapbMessage.success().pb-swapforpb-target: the special valuesactive,inactive,delete,hide, and in every other case — insertion ofdata.html.
This whole block is skipped if preventDefault() was called on the pb:success event.
1. Dynamic Parameters in the URL
<select id="country"
name="country"
class="form-select"
pb-get="/api/cities/{value}"
pb-trigger="change"
pb-expect="json"
pb-target="#city">
<option value="" selected disabled>Select a country</option>
</select>➡ Selecting a country with the value 2 sends a request to /api/cities/2.
2. data.html — Markup Inside a JSON Response
If pb-swap is not active / inactive / delete / hide and the response contains a non-empty data.html string, it is inserted into pb-target following the usual HTML insertion rules.
<div id="city"></div>
<select name="country"
pb-get="/api/cities/{value}"
pb-trigger="change"
pb-expect="json"
pb-target="#city"
pb-swap="innerHTML">
<option value="2">Moldova</option>
</select>{
"success": true,
"message": "Cities loaded",
"html": "<option value='5'>Chisinau</option>"
}➡ The markup from html goes into #city, and message goes to pbMessage. That way a single response both updates the block and reports the result — previously this took two requests.
3. data.redirect — Navigating from the Response
{ "success": true, "redirect": "/profile" }➡ The browser goes to /profile. Neither the message nor the markup swap is performed.
4. pb-swap="delete"
Removes the element from the DOM after a successful response.
<button type="button"
pb-delete="/cargo/{id}"
pb-expect="json"
pb-target="#order-123"
pb-swap="delete"
pb-confirm="Delete the order?"
id="123"
class="btn-control">
Delete order
</button>➡ First the confirmation is shown, then DELETE /cargo/123 is sent, and on success #order-123 is removed.
5. pb-swap="active"
Adds the active class to the element.
<button type="button"
pb-post="/user/{id}/activate"
pb-expect="json"
pb-target="#user-123"
pb-swap="active"
id="123">
Activate
</button>➡ After the JSON response, the active class is added to #user-123.
6. pb-swap="inactive"
Removes the active class.
<button type="button"
pb-post="/user/{id}/deactivate"
pb-expect="json"
pb-target="#user-123"
pb-swap="inactive"
id="123">
Deactivate
</button>➡ After the JSON response, the active class is removed from #user-123.
7. pb-swap="hide"
Hides the element (display: none).
<button type="button"
pb-post="/notifications/read/{id}"
pb-expect="json"
pb-target="#notif-55"
pb-swap="hide"
id="55">
Mark as read
</button>➡ After the JSON response, #notif-55 is hidden.
🧾 What Gets Sent to the Server
| What | When |
|---|---|
The X-CONTEXT-KEY header | Always. The value is the context key passed to init(). |
The X-CSRF-TOKEN header | For POST, PUT, PATCH, DELETE, if the page has a <meta name="csrf-token">. |
Accept and X-Requested-With | With expect: 'json'. |
| The fields of the nearest form | For non-GET requests the element looks up closest('form') itself. |
The data from pb-vals and pb-include | Always, if those attributes are set. |
For GET and HEAD the form is not attached; instead pb-vals, pb-include and the fields of a manually passed form are appended to the query string.
⏳ Loading Indicator
The element from pb-indicator is shown before the request (display: '') and hidden afterwards (display: 'none').
<span id="spinner" style="display:none">Loading…</span>
<button pb-get="/report"
pb-target="#report"
pb-indicator="#spinner">
Build report
</button>If pb-indicator is set, the pb:progress:start / pb:progress:end events are not fired: there is already an indicator, so a global progress bar would just get in the way.
⚙️ JavaScript API
Methods
pbFetch.get(options)
pbFetch.post(options)
pbFetch.put(options)
pbFetch.patch(options)
pbFetch.delete(options)
pbFetch.ajax(options) // the same thing, the method is set by the method option
pbFetch.cancel(key) // cancel a request by key; without an argument — all of themAll of them return a promise resolving to the parsed response: a string for html, an object for json. The promise resolves to undefined if the request was cancelled through pb:before, if a redirect happened, or if the request died with a network error.
Examples
// A simple GET request
pbFetch.get({
url: '/news',
target: '#news-block',
swap: 'beforeend'
});
// A POST request with a body
pbFetch.post({
url: '/send',
body: new URLSearchParams({ name: 'John' }),
expect: 'json'
});
// A PUT request with JSON
pbFetch.put({
url: '/user/5',
body: JSON.stringify({ name: 'Alice' }),
headers: { 'Content-Type': 'application/json' },
expect: 'json'
});
// A PATCH request
pbFetch.patch({
url: '/profile',
body: new FormData(document.querySelector('#profile-form')),
expect: 'json',
success: (response, data) => console.log('Updated:', data),
after: (response, data) => console.log('Request finished')
});
// A DELETE request
pbFetch.delete({
url: '/item/12',
expect: 'json',
success: (response, data) => alert(data.message),
error: (response, data) => alert('Deletion failed')
});
// Submitting a form
const form = document.querySelector('#form');
pbFetch.post({
url: form.action,
form,
expect: 'json'
});
// Cancelling previous requests
pbFetch.get({
url: '/search?q=test',
cancelKey: '/search',
cancelPrevious: true
});
// Cancelling manually
pbFetch.cancel('/search');Supported Options
| Option | Default | Description |
|---|---|---|
method | GET | HTTP method (GET, POST, PUT, PATCH, DELETE). |
url | '' | Request URL. |
target | null | Selector for inserting the response. |
swap | innerHTML | Insertion method (innerHTML, beforeend, etc.). |
expect | html | Response format: html or json. |
body | null | Request body (URLSearchParams, FormData, a JSON string). |
headers | {} | Custom headers. |
form | null | Form element (HTMLFormElement). |
redirect | null | Where to go after a successful JSON response; reload reloads. |
showProgress | true | Whether to fire the pb:progress:start/end events. |
cancelKey | URL without query string | Request key used for cancellation. |
cancelPrevious | true | Cancel the previous request with the same key. |
before | (form) => {} | Callback before the request. May be async — it will be awaited. |
success | (response, data) => {} | Callback on response.ok. |
error | (response, data) => {} | Callback on an HTTP error. |
after | (response, data) => {} | Callback after the request finishes (on both success and error). |
How to Cancel a Request
The value returned from before is not checked — returning false is not enough. The only thing that cancels the request is preventDefault() on the pb:before event:
document.addEventListener('pb:before', (e) => {
if (!window.confirm('Are you sure?')) e.preventDefault();
});A network error (server unreachable, request aborted) never reaches error — it goes to the pb:fail and pb:abort events. The error callback only fires when a response did arrive, but with a bad status.
🗨️ Events
All events are dispatched on document and are cancelable.
| Event | Fires When | detail |
|---|---|---|
pb:before | Before sending. preventDefault() cancels the request. | method, url, target, form |
pb:response | After the response arrives, before it is processed. | method, url, target, response, form |
pb:success | After a successful response. preventDefault() disables JSON processing: pbMessage, the redirect and the markup swap alike. | method, url, target, response, data, form |
pb:error | After an HTTP error. preventDefault() suppresses the pbMessage display. | method, url, target, response, data, form |
pb:progress:start | When loading starts (if showProgress). | url |
pb:progress:end | When loading ends (if showProgress). | url |
pb:after | After the request finishes (in any case). | method, url, target, response, data, form |
pb:abort | If the request is aborted. | method, url, target |
pb:fail | On a network error (e.g. no connection to the server). | method, url, target, error |
Example
document.addEventListener('pb:success', (e) => {
console.log('Success:', e.detail);
});
document.addEventListener('pb:error', (e) => {
console.log('Error:', e.detail);
e.preventDefault(); // suppress the automatic pbMessage display
});✅ Automatic pbMessage
The message is shown when three conditions line up:
expect: 'json';- the response contains a non-empty
data.message; - the
pb:success(orpb:error) event was not cancelled.
The context used to find the message block is the element's form, or, if there is none, the element from pb-target. When neither exists there is nowhere to show the message, and it silently disappears. See pbMessage for details.
🏆 Advantages
- Pure JavaScript with no dependencies.
- Simple — works out of the box.
- Supports loading on
loadand multiple events. - Control and flexibility through events and the JS API.
- Support for forms, indicators and request cancellation.
🟢 Conclusion
pbFetch is a lightweight yet powerful way to bring asynchronous requests into PageBlocks. All in plain HTML and JavaScript. No dependencies, with full control and extensibility.