Components

ScandiPWA React components are reusable pieces of UI logic

The ScandiPWA theme uses React class components to implement the user interface; these are defined in the component directory. To ensure consistency, all components follow the same structure.

Component names are always UpperCamelCase (also known as PascalCase). A component named <Component> will be defined in the directory component/<Component> containing the following files:

  • <Component>.component.js - exports a class named <Component> that implements rendering the component to the UI

  • <Component>.container.js (optional) - a class named <Component>Container that implements business logic of the component

  • CategoryFilterOverlay.config.js (optional) - defines any values needed for the component

  • <Component>.style.scss (optional) - defines the component's style in SCSS using the BEM methodology

  • index.js - exposes the "public" api of the component to make it easy to import

Feel free to browse the component directory of ScandiPWA as you read this article! You can also read this article about container components, a pattern ScandiPWA uses.

The .component File

This file is responsible only for the UI rendering implementation via JSX. Here's a simplified and annotated version of component/ProductPrice/ProductPrice.component.js

// no need to import React - it is automatically imported

// import order should be kept consistent
// library imports first
import PropTypes from 'prop-types';
import { PureComponent } from 'react';

// absolute imports from other directories are second
import TextPlaceholder from 'Component/TextPlaceholder';
import { PriceType } from 'Type/ProductList';

// relative imports from the same directory are last
import './ProductPrice.style';
// ^ the .component file is responsible for importing the stylesheet

// namespaces are necessary for the plugin mechanism to work
/** @namespace Component/ProductPrice/Component */
export class ProductPrice extends PureComponent {
    // ^ note that we exported the component's class in a named export.
    // only the default export will actually be used when rendering
    // the component, but we always export the class itself so that
    // it can be used when extending the component in a child theme.
    // this becomes important if the default export is wrapped in a HOC
    // such as withRouter, making it impossible to extend as a class

    renderPlaceholder() {
        return (
            <p block="ProductPrice" aria-label="Product Price">
                <TextPlaceholder length="custom" />
            </p>
        );
    }

    renderCurrentPrice() {...}

    renderOldPrice() {...}

    renderSchema() {...}

    render() {
        // [...]

        if (!final_price || !regular_price) {
            return this.renderPlaceholder();
        }

        return (
            <p block="ProductPrice">
                { this.renderCurrentPrice() }
                { this.renderOldPrice() }
                { this.renderSchema() }
            </p>
        );
    }
}

export default ProductPrice;

Note that this component is broken down by defining one function for each part. This is better than writing one long render method for several reasons:

  • Code is easier to understand, and sometimes more concise thanks to reusable functions

  • The class is easy to maintain - re-ordering these parts only affects 2 lines

  • The component is easier to extend via overrides or plugins (important if your theme will be used as a parent theme)

The .component file shouldn't be responsible for any business logic, such as fetching or manipulating data. For better separation of concerns, move all business logic to the .container file. (However, it is allowed maintain basic UI state e.g. to keep track of wether an accordion is open)

A Common .component Pattern: Render Maps

Ocasionally, a component needs to render different things depending on its state. Additionally, this state might affect an aspect of the component that is common between some states. To understand, consider an example:

The Checkout component has several steps to guide the user through checkout: shipping, billing, and the success step. Some of these have features in common - during both shipping and billing steps, the customer needs to see the cart items and totals, but we hide this at the success step. All 3 steps render a page title, but the title is different for each step. All 3 steps are associated with an URI, but it is different for each step.

Of course, we could handle these changes with several if and switch statements, but ScandiPWA offers a better approach: treating the possible steps as data, stored in a field named stepMap:

(Oversimplified and annotated code, taken from Checkout.component.js)

// [...] copyright, imports

/** @namespace Route/Checkout/Component */
export class Checkout extends PureComponent {
    // (1) first, each step is configured with the desired functionality
    stepMap = {
        [SHIPPING_STEP]: {
            title: __('Shipping step'),
            render: this.renderShippingStep.bind(this),
            areTotalsVisible: true
        },
        [BILLING_STEP]: {
            title: __('Billing step'),
            render: this.renderBillingStep.bind(this),
            areTotalsVisible: true
        },
        [DETAILS_STEP]: {
            title: __('Thank you for your purchase!'),
            render: this.renderDetailsStep.bind(this),
            areTotalsVisible: false
        }
    };

    // render ui specific to shipping, billing, and success details
    renderShippingStep() {...}
    renderBillingStep() {...}
    renderDetailsStep() {...}

    // (2) then, everything below this line uses the current step + stepMap
    // to determine what to do

    // React calls this after every render but the first
    // update the title and URL based on the current step data
    componentDidUpdate(prevProps) {
        const { checkoutStep } = this.props;
        const { checkoutStep: prevCheckoutStep } = prevProps;

        if (checkoutStep !== prevCheckoutStep) {
            this.updateHeader();
        }
    }

    // updates the page title based on the current step
    updateHeader() {
        const {
            setHeaderState, // function to update the state of the header
            checkoutStep, // one of SHIPPING_STEP, BILLING_STEP, DETAILS_STEP
        } = this.props;
        const { title = '' } = this.stepMap[checkoutStep];
        setHeaderState({ title });
    }

    renderTitle() {
        const { checkoutStep } = this.props;
        const { title = '' } = this.stepMap[checkoutStep];

        // same rendering logic for all steps
        return (
            <h1 block="Checkout" elem="Title">
                { title }
            </h1>
        );
    }

    renderStep() {
        const { checkoutStep } = this.props;
        const { render } = this.stepMap[checkoutStep];
        
        // call appropriate render function based on current step
        return render();
    }

    // this only renders something if areTotalsVisible is
    // true for the current step
    renderSummary() {...}

    render() {
        return (
            <main block="Checkout">
                <div block="Checkout" elem="Step">
                    { this.renderTitle() }
                    { this.renderStep() }
                </div>
                { this.renderSummary() }
            </main>
        );
    }
}

By configuring the different steps in a JavaScript object, we avoid duplicating code everywhere that needs to switch functionality depending on the current step. We also make it easier for plugins and child themes to augment the default functionality by simply changing the stepMap values.

In addition, we can treat the steps as data, and easily find what the next or previous step should be, without additional data.

Similar map objects are used throughout ScandiPWA, with similar uses.

The .container File

The container file is responsible for:

  • Using Higher Order Components (such as connect to get global state from Redux)

  • Performing all data fetching, mutations, and other business logic

  • Optionally manipulating data it receives and passing it on to the .component so that it needs to do as little work as possible

A Common .container Pattern: containerProps

Usually a container needs to pass on certain values to its corresponding component. These are all passed on in the render method, where the .component is given all the props it needs. However, for a shorter render method and better-organized code, it is a good practice to define a separate function for the values you want to pass (additionally, this is easier to extend in child themes and plugins). Then the render method merely needs to call this function, which will return some props coming from the containter, hence the name. Check the example to see how this works.

A Common .container Pattern: containerFunctions

Similarly, if a container implements certain business logic for its component, it may wish to pass this implementation as a prop so that it can be called from the .component. These functions need to be defined in the .container, then you can bind them to this so that they have access to the instance of the container, which they will need if they access this.props or this.state. The example below demonstrates how this works.

Example .container

Here's a simplified and annotated version of component/CartOverlay/CartOverlay.container.js:

// [...] copyright, imports
import CartOverlay from './CartOverlay.component';

export const CartDispatcher = import('Store/Cart/Cart.dispatcher');

// mapStateToProps is a function that receives a global `state` object
// from redux, and passes on some selected values from the state
// which will end up being received as the container's props.
// mapStateToProps needs a @namespace declaration as well for plugins to work
/** @namespace Component/CartOverlay/Container/mapStateToProps */
export const mapStateToProps = (state) => ({
    totals: state.CartReducer.cartTotals,
    device: state.ConfigReducer.device,
    currencyCode: state.CartReducer.cartTotals.quote_currency_code,
    activeOverlay: state.OverlayReducer.activeOverlay
});

// mapDispatchToProps accepts a dispatch function from redux
// and enables the container to make certain (async) updates
/** @namespace Component/CartOverlay/Container/mapDispatchToProps */
export const mapDispatchToProps = (dispatch) => ({
    updateTotals: (options) => CartDispatcher.then(
        ({ default: dispatcher }) => dispatcher.updateTotals(dispatch, options)
    ),
    showOverlay: (overlayKey) => dispatch(toggleOverlayByKey(overlayKey)),
    showNotification: (type, message) => dispatch(showNotification(type, message)),
});

/** @namespace Component/CartOverlay/Container */
export class CartOverlayContainer extends PureComponent {
    // we declare which values we expect to receive from redux,
    // as well as the parent component.
    // this helps catch bugs with warning messages
    static propTypes = {
        totals: TotalsType.isRequired,
        showOverlay: PropTypes.func.isRequired,
        showNotification: PropTypes.func.isRequired,
        hideActiveOverlay: PropTypes.func.isRequired
    };

    // a function that returns values we want to pass to the component
    containerProps = () => {
        const { totals } = this.props;

        return {
            hasOutOfStockProductsInCart: hasOutOfStockProductsInCartItems(totals.items)
        };
    };

    // an object (initialized when constructing class) specifying functions we want
    // the component to be able to call. we `bind` them to `this` instance, so that
    // all of these functions can use `this` (container) instance. by default it would be
    // null, and we wouldn't be able to access values such as `this.props`
    containerFunctions = {
        handleCheckoutClick: this.handleCheckoutClick.bind(this)
    };

    // this functionality is implemented in the container and passed as a prop via
    // containerFunctions so that the component doesn't have to worry about business logic
    handleCheckoutClick(e) {
        const {
            showNotification,
            totals
        } = this.props;

        const hasOutOfStockProductsInCart = hasOutOfStockProductsInCartItems(totals.items);

        if (hasOutOfStockProductsInCart) {
            showNotification('error', 'Cannot proceed to checkout. Remove out of stock products first.');
            return;
        }

        hideActiveOverlay();
        history.push({ pathname: appendWithStoreCode(CHECKOUT_URL) });
    }

    render() {
        // the CartOverlay component will take care of all the rendering
        // we just need to pass on certain values and functions to it as props
        return (
            <CartOverlay
                { ...this.props }
                { ...this.containerFunctions }
                { ...this.containerProps() }
            />
        );
        // the three-dot syntax is the JavaScript spread operator
        // https://stackoverflow.com/a/31049016
        // it can be used to pass on all the values of an object to a function, another object, or JSX
        // in this case, it is equivalent to going though every key-value pair in the object
        // and passing [value] as a prop value for [key].
    }
}

// `connect` is a React-Redux HOC that takes (mapStateToProps, mapDispatchToProps)
// as defined below, and enables them to receive values from the global state,
// and passes their return values as props to the CartOverlayContainer
export default connect(mapStateToProps, mapDispatchToProps)(CartOverlayContainer);
// https://react-redux.js.org/api/connect

The .config File

Due to Webpack optimization limitations, it is more efficient to define constants you use in .component or .container in a separate file, and import them when you need them. This is what .config files are for. Example:

// component/Image/Image.config.js


export const IMAGE_LOADING = 0;
export const IMAGE_LOADED = 1;
export const IMAGE_NOT_FOUND = 2;
export const IMAGE_NOT_SPECIFIED = 3;

The .style File

The .style file defines styles for the component, in SCSS. It is imported in the .component file ScandiPWA uses the BEM methodology to define styles.

If you are overriding a parent theme, there may also be an .override.style file (see Overriding Styles).

The index.js File

When you need to import some components, you can use...

import Image from 'Component/Image';
import Link from 'Component/Link';
import CategoryPaginationLink from 'Component/CategoryPaginationLink';

...instead of...

import Image from 'Component/Image/Image.container.js';
import Link from 'Component/Link/Link.container.js';
import CategoryPaginationLink
  from 'Component/CategoryPaginationLink/CategoryPaginationLink.component.js';

Not only is the first option more concise, but you also don't need to worry about internal component details, such as wether you need to import the container or component (if no container is defined).

index.js is the file that enables this aliasing. When a directory such as Component/Image is imported, it resolves to the Component/Image/index.js file. Hence, the index has control over the "API" the component exposes to the other components.

The contents of the index.js file are very simple. If you need to wrap the component in a container, export the container:

// component/Image/index.js
export { default } from './Image.container';

Otherwise, you can export the component directly:

// component/CategoryPaginationLink/index.js
export { default } from './CategoryPaginationLink.component';

Avoid implementing anything in the index.js file. It is meant to be used only for exporting values defined elsewhere in the component.

Last updated