Skip to main content Accessibility Feedback

<ajax-form>

Progressively add Ajax support to <form> elements.

Source Code

Basic Usage

Wrap <form> elements in the <ajax-form> custom element. The <form> must have method and action attributes for the <ajax-form> component to work properly.

<ajax-form>
    <form method="post" action="/newsletter">
        <!-- Form fields... -->
    </form>
</ajax-form>

By default, the <ajax-form> Web Component will add an accessible status notification element, show a Submitting... message, and prevent the user from submitting the form twice while waiting for a server response.

Options & Settings

The <ajax-form> element can be customized with a handful of attributes…

  • prevent-default - Stop the form from redirecting/reloading the page. Use this if you want to keep the user on the current page after the form submits.
  • msg-submitting - Customize the “submitting” message.
  • msg-success - Customize the “success” message.
  • msg-error - Customize the “error” message.
  • target - If the server returns HTML and you want to update the current UI after the form successfully submits, provide one or more selector strings (comma-separated) for the element to update. Each selector should be identical in the current UI and the returned HTML from the server.

With the msg-submitting, msg-success, and msg-error attributes, you can include ${field_name} in the attribute string to include the value of the matching field in your message.

<!-- 
    This form includes some customizations.
    After submitting, it will update the [data-list="todos"] element with new HTML.
-->
<ajax-form 
    prevent-default 
    msg-submitting='Adding the "${todo}" todo item...' 
    msg-success='The "${todo}" todo item was added.' 
    target='[data-list="todos"]'
>
    <form method="post" action="/add-todo">
        <label for="todo">Add a todo item</label>
        <input id="todo" name="todo" type="text" required>
        <button>Add Todo</button>
    </form>
</ajax-form>

<!-- 
    This gets updated after the form submits.
    It expects HTML to be returned by the server.
-->
<div data-list="todos">
    <h2>Your Todos</h2>
    <p>You don't have any todos yet.</p>
</div>

Styling

The <ajax-form> element receives a [form-submitting] attribute when the form is actively awaiting a response from the server.

You can hook into the attribute in your CSS to style your form and visually indicate submitting status.

/**
 * Decrease button opacity when submitting
 */
[form-submitting] button {
    opacity: 0.8;
}

Events

The <ajax-form> component emits a few custom events to let you extend it’s functionality beyond what it offers out-of-the-box.

  • ajax-form:submit is emitted when the form is submitted, but before it sends data to the server. The event.detail property is an object of form field values. Running event.preventDefault() will cancel the form submission.
  • ajax-form:success is emitted when the form successfully returns data from the server. The event.detail property is the Response object from the server.
  • ajax-form:error is emitted when the server returns an error. The event.detail property is the Response object from the server.

You can listen for events with the Element.addEventListener() method.

document.addEventListener('ajax-form:success', function (event) {

    // The `<ajax-form>` element
    console.log(event.target);

    // The event details
    console.log(event.detail);

});

The Web Component

customElements.define('ajax-form', class extends HTMLElement {

    /**
     * The class constructor object
     */
    constructor () {

        // Always call super first in constructor
        super();

        // Add a form status element
        let announce = document.createElement('div');
        announce.setAttribute('role', 'status');
        this.append(announce);

        // Set base properties
        this.announce = announce;
        this.form = this.querySelector('form');
        this.handler = this.createSubmitHandler();

        // Define options
        this.preventDefault = this.hasAttribute('prevent-default');
        this.msgSubmitting = this.getAttribute('msg-submitting') ?? 'Submitting...';
        this.msgSuccess = this.getAttribute('msg-success') ?? 'Success!';
        this.msgError = this.getAttribute('msg-error') ?? 'Something went wrong. Please try again.';
        let target = this.getAttribute('target');
        this.targets = target ? target.split(',').map(target => target.trim()) : null;

    }

    /**
     * Create a submit handler with the instance bound to the callback
     * @return {Function} The callback function
     */
    createSubmitHandler () {
        return async (event) => {

            // If the form is already submitting,
            // OR if default should be prevented
            // Stop form from reloading the page
            if (this.isDisabled() || this.preventDefault) {
                event.preventDefault();
            }

            // If the form is already submitting, do nothing
            // Otherwise, disable future submissions
            if (this.isDisabled()) return;
            this.disable();

            // Emit a submit event (useful for validations)
            if (!this.emit('submit', this.getData())) return;

            try {

                // Show status message
                this.showStatus(this.msgSubmitting);

                // If not preventing default behavior, end early
                if (!this.preventDefault) return;

                // Call the API
                let {action, method} = event.target;
                let response = await fetch(action, {
                    method,
                    body: this.serialize(),
                    headers: {
                        'Content-type': 'application/x-www-form-urlencoded'
                    }
                });

                // If there's an error, throw
                if (!response.ok) throw response;

                // If UI should be updated, do so
                if (this.targets) {
                    let str = await response.clone().text();
                    this.render(str);
                }

                // Emit a success event
                this.emit('success', response.clone());

                // Show success URL
                this.showStatus(this.msgSuccess);

                // Clear the form
                this.reset();

            } catch (error) {
                console.warn(error);
                this.showStatus(this.msgError);
                this.emit('error', error.clone());
            } finally {
                this.enable();
            }

        };
    }

    /**
     * Listen for form submissions when the form is attached in the DOM
     */
    connectedCallback () {
        this.form.addEventListener('submit', this.handler);
    }

    /**
     * Stop listening for form submissions when the form is attached in the DOM
     */
    disconnectedCallback () {
        this.form.removeEventListener('submit', this.handler);
    }

    /**
     * Emit a custom event
     * @param  {String} type   The event type
     * @param  {Object} detail Any details to pass along with the event
     */
    emit (type, detail = {}) {

        // Create a new event
        let event = new CustomEvent(`ajax-form:${type}`, {
            bubbles: true,
            cancelable: true,
            detail: detail
        });

        // Dispatch the event
        return this.dispatchEvent(event);

    }

    /**
     * Disable a form so I can't be submitted while waiting for the API
     */
    disable () {
        this.setAttribute('form-submitting', '');
    }

    /**
     * Enable a form after the API returns
     */
    enable () {
        this.removeAttribute('form-submitting');
    }

    /**
     * Check if a form is submitting to the API
     * @return {Boolean} If true, the form is submitting
     */
    isDisabled () {
        return this.hasAttribute('form-submitting');
    }

    /**
     * Get the value of a form field by its [name]
     * @param  {String} id The field name
     * @return {String}    The value
     */
    getFieldValue (id) {

        // Get the field
        let field = this.form.querySelector(`[name="${id}"]`);
        if (!field) return;

        // If select element, get selected element text
        if (field.tagName.toLowerCase() === 'select') {
            return field.options[field.selectedIndex].textContent;
        }

        // Otherwise, return value
        return field.value;

    }

    /**
     * Replace placeholders in message with field values
     * @param  {String} msg The message text
     * @return {String}     The message text with placeholders replaced
     */
    getMessageText (msg) {
        let instance = this;
        return msg.replace(/\$\{([^}]+)\}/g, function (match) {

            // Remove the wrapping curly braces
            match = match.slice(2, -1);

            // Get the field value
            let value = instance.getFieldValue(match);

            // Return the string
            if (!value) return '{{' + match + '}}';
            return value;

        });
    }

    /**
     * Update the form status in a field
     * @param  {String} msg The message to display
     */
    showStatus (msg) {
        this.announce.innerHTML = this.getMessageText(msg);
    }

    /**
     * Serialize all form data into an encoded query string
     * @return {String} The serialized form data
     */
    serialize () {
        let data = new FormData(this.form);
        let params = new URLSearchParams();
        for (let [key, val] of data) {
            params.append(key, val);
        }
        return params.toString();
    }

    /**
     * Serialize all form data into an object
     * @return {Object} The serialized form data
     */
    getData () {
        let data = new FormData(this.form);
        let obj = {};
        for (let [key, value] of data) {
            if (obj[key] !== undefined) {
                if (!Array.isArray(obj[key])) {
                    obj[key] = [obj[key]];
                }
                obj[key].push(value);
            } else {
                obj[key] = value;
            }
        }
        return obj;
    }

    /**
     * Render the updated UI into the DOM
     * @param  {String} str The HTML string for the updated UI
     */
    render (str) {

        // Parse returned string into HTML
        let parser = new DOMParser();
        let doc = parser.parseFromString(str, 'text/html');
        if (!doc.body) return;

        // Render each target
        for (let selector of this.targets) {

            // Find target element in the DOM
            let target = document.querySelector(selector);
            if (!target) continue;

            // Get the target element from the returned HTML
            let updated = doc.body.querySelector(selector);
            if (!updated) continue;

            // Update the UI
            target.replaceWith(updated);

        }

    }

    /**
     * Reset the form element values
     */
    reset () {
        this.form.reset();
    }

});