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
// no need to import React - it is automatically imported// import order should be kept consistent// library imports firstimport PropTypes from'prop-types';import { PureComponent } from'react';// absolute imports from other directories are secondimport TextPlaceholder from'Component/TextPlaceholder';import { PriceType } from'Type/ProductList';// relative imports from the same directory are lastimport'./ProductPrice.style';// ^ the .component file is responsible for importing the stylesheet// namespaces are necessary for the plugin mechanism to work/** @namespace Component/ProductPrice/Component */exportclassProductPriceextendsPureComponent {// ^ 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 classrenderPlaceholder() {return ( <pblock="ProductPrice"aria-label="Product Price"> <TextPlaceholderlength="custom" /> </p> ); }renderCurrentPrice() {...}renderOldPrice() {...}renderSchema() {...}render() {// [...]if (!final_price ||!regular_price) {returnthis.renderPlaceholder(); }return ( <pblock="ProductPrice"> { this.renderCurrentPrice() } { this.renderOldPrice() } { this.renderSchema() } </p> ); }}exportdefault 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:
// [...] copyright, imports/** @namespace Route/Checkout/Component */exportclassCheckoutextendsPureComponent {// (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 detailsrenderShippingStep() {...}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 datacomponentDidUpdate(prevProps) {const { checkoutStep } =this.props;const { checkoutStep: prevCheckoutStep } = prevProps;if (checkoutStep !== prevCheckoutStep) {this.updateHeader(); } }// updates the page title based on the current stepupdateHeader() {const {setHeaderState,// function to update the state of the headercheckoutStep,// 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 stepsreturn ( <h1block="Checkout"elem="Title"> { title } </h1> ); }renderStep() {const { checkoutStep } =this.props;const { render } =this.stepMap[checkoutStep];// call appropriate render function based on current stepreturnrender(); }// this only renders something if areTotalsVisible is// true for the current steprenderSummary() {...}render() {return ( <mainblock="Checkout"> <divblock="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.
// [...] copyright, importsimport CartOverlay from'./CartOverlay.component';exportconstCartDispatcher=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 */exportconstmapStateToProps= (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 */exportconstmapDispatchToProps= (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 */exportclassCartOverlayContainerextendsPureComponent {// we declare which values we expect to receive from redux,// as well as the parent component.// this helps catch bugs with warning messagesstatic 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 componentcontainerProps= () => {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 logichandleCheckoutClick(e) {const {showNotification,totals } =this.props;consthasOutOfStockProductsInCart=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 propsreturn ( <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 CartOverlayContainerexportdefaultconnect(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:
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 CategoryPaginationLinkfrom'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: