Skip to content

🚀 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

  1. Enable the pageblocks_load_scripts setting so the script is included automatically.
  2. Or include it manually:
html
<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:

js
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-target and pb-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

AttributeDescription
pb-getSends a GET request. The attribute value is the URL.
pb-postSends a POST request.
pb-putSends a PUT request.
pb-deleteSends a DELETE request.
pb-targetCSS selector of the element where the response will be inserted.
pb-swapInsertion method: innerHTML, beforeend, afterbegin, beforebegin, afterend, outerHTML. Special values for JSON: active, inactive, delete, hide
pb-expectExpected response format: html (default) or json.
pb-triggerEvents that fire the request: click, submit, change, load (several can be listed, comma-separated). Defaults to click.
pb-valsJSON string with data for the request body.
pb-includeCSS selector of the form fields to include in the request.
pb-indicatorCSS selector of the loading indicator.
pb-confirmConfirmation text. The request is only sent once the user agrees.
pb-redirectWhere 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} → the id attribute of the element
  • {name} → the name attribute 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.

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

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

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

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

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

html
<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/json and X-Requested-With: XMLHttpRequest headers are added to the request;
  • the response is parsed with response.json();
  • pbFetch then handles the response fields it knows about — in a strict order.

Processing Order for a Successful JSON Response

  1. data.redirect — if the field is present and not empty, the browser navigates and processing stops.
  2. The pb-redirect attribute — reload reloads the page, otherwise it navigates to the given address. Processing stops here too.
  3. data.message — displayed via pbMessage.success().
  4. pb-swap for pb-target: the special values active, inactive, delete, hide, and in every other case — insertion of data.html.

This whole block is skipped if preventDefault() was called on the pb:success event.

1. Dynamic Parameters in the URL

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

html
<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>
json
{
  "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

json
{ "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.

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

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

html
<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).

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

WhatWhen
The X-CONTEXT-KEY headerAlways. The value is the context key passed to init().
The X-CSRF-TOKEN headerFor POST, PUT, PATCH, DELETE, if the page has a <meta name="csrf-token">.
Accept and X-Requested-WithWith expect: 'json'.
The fields of the nearest formFor non-GET requests the element looks up closest('form') itself.
The data from pb-vals and pb-includeAlways, 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').

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

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

All 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

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

OptionDefaultDescription
methodGETHTTP method (GET, POST, PUT, PATCH, DELETE).
url''Request URL.
targetnullSelector for inserting the response.
swapinnerHTMLInsertion method (innerHTML, beforeend, etc.).
expecthtmlResponse format: html or json.
bodynullRequest body (URLSearchParams, FormData, a JSON string).
headers{}Custom headers.
formnullForm element (HTMLFormElement).
redirectnullWhere to go after a successful JSON response; reload reloads.
showProgresstrueWhether to fire the pb:progress:start/end events.
cancelKeyURL without query stringRequest key used for cancellation.
cancelPrevioustrueCancel 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:

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

EventFires Whendetail
pb:beforeBefore sending. preventDefault() cancels the request.method, url, target, form
pb:responseAfter the response arrives, before it is processed.method, url, target, response, form
pb:successAfter a successful response. preventDefault() disables JSON processing: pbMessage, the redirect and the markup swap alike.method, url, target, response, data, form
pb:errorAfter an HTTP error. preventDefault() suppresses the pbMessage display.method, url, target, response, data, form
pb:progress:startWhen loading starts (if showProgress).url
pb:progress:endWhen loading ends (if showProgress).url
pb:afterAfter the request finishes (in any case).method, url, target, response, data, form
pb:abortIf the request is aborted.method, url, target
pb:failOn a network error (e.g. no connection to the server).method, url, target, error

Example

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

  1. expect: 'json';
  2. the response contains a non-empty data.message;
  3. the pb:success (or pb: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 load and 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.

© PageBlocks 2019-present