← Back to blog

Magento 2 UI Components and Knockout.js: The Basics

Rodrigo Biazzi

The minicart, the checkout, the customer account dropdown and most admin grids and forms in Magento 2 are built with UI components on top of Knockout.js. If you work on a Luma based store, you cannot avoid them. The documentation is spread thin, and the code reads as if it was written by several teams with different ideas, which it was.

This article covers the parts you need to read, debug and write storefront UI components in Magento 2.4.8: the class hierarchy, how a component gets initialised, how templates and observables work, and how components talk to each other.

What a UI component is

On the storefront, a UI component is a JavaScript object created from a RequireJS module, bound to a piece of HTML with Knockout. Magento adds a layer on top of Knockout that handles configuration, inheritance and communication between components.

The base classes are:

  • uiElement (Magento_Ui/js/lib/core/element/element): the base for everything. It handles defaults, observables, links and the registry.
  • uiCollection (Magento_Ui/js/lib/core/collection): an element that holds child components and renders them in regions.
  • uiComponent: an alias for uiCollection configured in RequireJS. Most storefront components extend it.

In the admin, UI components are mostly declared in XML files under view/adminhtml/ui_component/, for grids (listing) and forms. The same JavaScript classes run underneath, but the configuration comes from XML instead of JSON in a template. This article focuses on the storefront, where you write more JavaScript yourself.

How a component gets created

Components are created by Magento_Ui/js/core/app, which receives a JSON configuration and instantiates each component through the layout processor. On the storefront you usually trigger it from a template with text/x-magento-init:

<div data-bind="scope: 'vendorGreeting'">
    <!-- ko template: getTemplate() --><!-- /ko -->
</div>
<script type="text/x-magento-init">
{
    "*": {
        "Magento_Ui/js/core/app": {
            "components": {
                "vendorGreeting": {
                    "component": "Vendor_Module/js/view/greeting",
                    "customerName": "Guest"
                }
            }
        }
    }
}
</script>

The scope binding tells Knockout which registered component to use as the view model for that piece of HTML. The key under components becomes the component name in the registry.

For bigger structures such as the checkout or minicart, the configuration lives in layout XML as the jsLayout argument of a block. The block outputs it as JSON through getJsLayout(), and PHP layout processors can change it before it reaches the browser. That is how most checkout customisations work.

Writing a component

define([
    'uiComponent'
], function (Component) {
    'use strict';

    return Component.extend({
        defaults: {
            template: 'Vendor_Module/greeting',
            customerName: '',
            visits: 0,
            tracks: {
                customerName: true,
                visits: true
            }
        },

        initialize: function () {
            this._super();

            return this;
        },

        increment: function () {
            this.visits++;
        },

        getMessage: function () {
            return 'Hello ' + this.customerName + ', clicks: ' + this.visits;
        }
    });
});

The file goes in view/frontend/web/js/view/greeting.js. Values in defaults are merged with the JSON configuration, so customerName above is replaced by "Guest" from the template. Call this._super() in every overridden method unless you really mean to skip the parent logic.

Observables: tracks vs observe

Knockout updates the HTML only when an observable changes. Magento gives you two ways to make properties observable.

  • tracks uses the knockout-es5 plugin. The property stays a normal property, so you read and write it as this.visits. This is the cleaner option for new code.
  • this.observe('visits') inside initObservable turns it into a classic Knockout observable, so you read it as this.visits() and write it as this.visits(5). Most core checkout code uses this style.

Mixing the two styles on the same property is a common bug. When you extend a core component, check how the parent declared the property before you read it.

Templates

The template path Vendor_Module/greeting resolves to view/frontend/web/template/greeting.html. Templates are plain HTML with Knockout bindings:

<div class="greeting">
    <p data-bind="text: getMessage()"></p>
    <button type="button" data-bind="click: increment">
        <span data-bind="i18n: 'Click me'"></span>
    </button>
    <!-- ko if: visits > 3 -->
    <p translate="'You like clicking.'"></p>
    <!-- /ko -->
</div>

Magento adds its own bindings and a shorter attribute syntax. i18n and the translate attribute translate static text. afterRender runs a callback when the element is in the DOM. Bindings such as if, foreach and visible can also be written as attributes, for example <div if="isVisible">, which Magento converts to normal bindings before Knockout sees them.

HTML templates are cached in the browser and in static files. After changing one in production mode, you need to deploy static content again. In developer mode, clear pub/static for the theme if the browser keeps showing the old version.

How components talk to each other

The registry

Every component registers itself in uiRegistry under its full name, which is the path in the configuration tree joined with dots, such as checkout.steps.shipping-step.shippingAddress. You can fetch one with:

define(['uiRegistry'], function (registry) {
    'use strict';

    registry.get('checkout.steps.shipping-step.shippingAddress', function (component) {
        console.log(component);
    });
});

The callback form waits until the component exists, which avoids timing bugs. In the browser console, require('uiRegistry').get('vendorGreeting') is the quickest way to inspect a live component.

imports, exports, links and listens

These defaults wire properties between components without writing subscription code:

  • imports: copy a value from another component into this one whenever it changes.
  • exports: push a value from this component to another one.
  • links: two way binding between the two.
  • listens: call a method of this component when a property somewhere changes.
defaults: {
    imports: {
        shippingMethod: 'checkout.steps.shipping-step.shippingAddress:selectedMethod'
    },
    listens: {
        '${ $.provider }:data.reset': 'onReset'
    }
}

The ${ $.name } and ${ $.provider } strings are template literals that Magento resolves against the component's own configuration, so you do not hardcode paths that change when the component moves.

Shared models

For the cart, customer and checkout state, Magento uses plain RequireJS modules that return Knockout observables, such as Magento_Checkout/js/model/quote or Magento_Customer/js/customer-data. Any component that requires them sees the same object. For per customer data on cached pages, read the article on customer data sections.

Debugging UI components

Most problems with UI components fall into a few groups, and each has a quick check.

  • The component never renders. Open the browser console first. A RequireJS error such as a 404 on the component file or the template stops everything after it. Check the path in component and the template value against the real file names, including case.
  • The component renders with old data. The value is probably read once in initialize and never subscribed to. Use imports or subscribe to the observable instead of copying its value.
  • The configuration is not what you expect. Inspect the JSON in the page source or in the jsLayout after layout processors ran. A layout processor in another module may have removed or renamed your node.
  • Bindings fail with an undefined property. The template is bound to a different scope than you think. A child template inherits the context of its parent, so a property of the parent is not available as $data in a child without $parent.

Running the store in developer mode makes this loop much faster, because static files are published on demand as symlinks to the module folders, so most edits show after a browser refresh.

Extending core components

Do not copy a core component into your module to change one method. Use a RequireJS mixin that extends the original and overrides only what you need. For layout changes in the checkout, change jsLayout in layout XML or a layout processor. The custom checkout step article shows both in a real case.

FAQ

What is the difference between uiComponent and uiElement?

uiElement is the base class with configuration, observables and linking. uiComponent is an alias for uiCollection, which extends uiElement and adds support for child components and regions. Most storefront components extend uiComponent.

Why does my Knockout template not update when a value changes?

The property is not observable. Add it to tracks or call this.observe for it, and make sure you read it in the style the component declared it, as a property or as a function.

How do I find a component name in the checkout?

Look at the jsLayout in checkout_index_index.xml. In the browser console, require('ko').contextFor(element).$data.name returns the full registry name of the component bound to that element.

Do I need UI components on a Hyvä store?

Not on the storefront. Hyvä replaces RequireJS, Knockout and UI components with Alpine.js. The admin still uses UI components on every Magento store.

I am available for freelance Magento and Adobe Commerce work, including checkout and frontend JavaScript. If you need help with this, get in touch.

Related articles