Redux Stores

Redux stores are used to maintain global state

The term "store" in this article refers to a JavaScript object we can use to keep track of state. Not to be confused with Magento stores.

ScandiPWA uses Redux to keep track of global state. If used correctly, Redux is predictable and easy to debug.

If you need to keep a state that will be the same throughout the application, and possibly shared between multiple components, create a Redux store. Examples:

  • Breadcrumbs are stored in a Redux store, because multiple pages need to be able to set them

  • The Cart is stored in a Redux store, because both the cart overlay and the cart page need to use it, though they don't have a direct parent-child relationship. Also, it is guaranteed that you will need information about only 1 cart in the application.

Avoid using a global Redux store if it is component-specific, and you need to be able to store a different value in each component. Use the component's state instead.

Following Redux practices, the ScandiPWA theme contains 1 Redux store. However, since the application needs to maintain different kinds of global state, the top-level Redux store actually tracks an object containing multiple "sub-stores". Each of these sub-stores, has a dedicated subdirectory in store where it is defined.

The use of Redux stores is not ScandiPWA-specific, so it is best to learn about it from the oficial redux documentation. However, we will go though an example of how it is used in ScandiPWA to help you understand how it interacts with the application.

Example: Breadcrumbs

At the top of many pages in the ScandiPWA theme, you will see breadcrumbs. These are path-like indicators that help the user understand where in the app they currently are, and improve navigation:

Since we would never need to keep track of multiple different breacrumb paths at once, they are implemented in a global redux store. Once a component receives enough data to know what the breadcrumbs should be (such as a list of parent categories as above), it dispatches an update to the breadcrumbs. This state can now be read by the component responsible for rendering breadcrumbs.

1. The .reducer File: Defining the State

See store/Breadcrumbs/Breadcrumbs.reducer.js.

The first step in creating a redux store is defining what it's initial state should be. We need to keep track of two things - the breadcrumbs themselves and a boolean indicating wether they should be visible on the current page.

/** @namespace Store/Breadcrumbs/Reducer/getInitialState */
export const getInitialState = () => ({
    breadcrumbs: [],
    areBreadcrumbsVisible: true
});

Now, we need to describe how the state should update in response to certain actions. It might seem unintuitive at first, but Redux state cannot be updated directly. Instead, you are allowed to define reducers - functions that describe how the state should transition when an action is dispatched.

We want to handle two types of actions - one that updates the breadcrumbs, and one that updates their visibility:

// action types are actually defined in the .action file
export const UPDATE_BREADCRUMBS = 'UPDATE_BREADCRUMBS';
export const TOGGLE_BREADCRUMBS = 'TOGGLE_BREADCRUMBS';

All actions are simple JavaScript objects that carry information which the reducer can interpret to update the state. The UPDATE_BREADCRUMBS action would look like this:

{
    // all actions have a `type` field that indicates
    // what kind of action it is. it must be unique
    // among action types
    type: UPDATE_BREADCRUMBS,
    // the UPDATE_BREADCRUMBS type action also has a 
    // breadcrumbs field that carries information
    // about what the new breadcrumbs should be
    breadcrumbs: [...] // some array
}

The TOGGLE_BREADCRUMBS would be similar, but carry a different type of data, a boolean:

{
    type: TOGGLE_BREADCRUMBS,
    // the UPDATE_BREADCRUMBS type action also has a 
    // breadcrumbs field that carries information
    // about what the new breadcrumbs should be
    areBreadcrumbsVisible: true // or false
}

Note that, by themselves, actions do not do anything - they are just objects with some fields. However, when an action is dispatched ("sent" to Redux, we'll get to that later), Redux passes it on to all reducers (functions we define). Each reducer can look at the action and update its state by returning a new value.

/** @namespace Store/Breadcrumbs/Reducer */
export const BreadcrumbsReducer = (
    state = getInitialState(), // previous state, or the initial state if none
    action // we get the action that was dispatched
) => {
    // we are only interested in certain types of actions
    switch (action.type) {
    
    // if this is an UPDATE_BREADCRUMBS action
    case UPDATE_BREADCRUMBS:
        // we know that it will have some data in action.breadcrumbs
        const { breadcrumbs } = action;

        // we update the state
        return {
            ...state, // to keep the same value
            breadcrumbs // except with a new breadcrumbs value
        };

    // similarly we want to update the state for TOGGLE_BREADCRUMBS actions
    case TOGGLE_BREADCRUMBS:
        const { areBreadcrumbsVisible } = action;

        return {
            ...state,
            areBreadcrumbsVisible
        };

    default:
        // it is possible the action was not related to breadcrumbs.
        // then we can just return the original state unchanged.
        return state;
    }
};

Now that the reducer is defined, we need to include it in our single global Redux state.

src/app/store/index.js (simplified & annotated)
// copyright

import {
    combineReducers,
    createStore
} from 'redux';

import BreadcrumbsReducer from 'Store/Breadcrumbs/Breadcrumbs.reducer';
// [...] import the other reducers

/** @namespace Store/Index/getReducers */
export const getStaticReducers = () => ({
    BreadcrumbsReducer,
    // [...] include the other reducers
});

// a bunch of Redux API calls essentially creating a store from the above

2. The .action File: Defining Possible Actions

The reducer we created can respond to certain actions described above, but creating those action object manually would get repetitive and error-prone. Hence, we create functions that can create these action objects for us:

store/Breadcrumbs/Breadcrumbs.action.js (simplified)
export const UPDATE_BREADCRUMBS = 'UPDATE_BREADCRUMBS';
export const TOGGLE_BREADCRUMBS = 'TOGGLE_BREADCRUMBS';

export const updateBreadcrumbs = (breadcrumbs) => ({
    type: UPDATE_BREADCRUMBS,
    breadcrumbs
});

export const toggleBreadcrumbs = (areBreadcrumbsVisible) => ({
    type: TOGGLE_BREADCRUMBS,
    areBreadcrumbsVisible
});

Redux strongly discourages creating side effects in the reducer, or action creators. Avoid making requests, mutating non-Redux state, or other changes in these functions. This will make them less predictable and harder to debug.

3. The .dispatcher File: Dispatching Helpers

It can be convenient to have a file that defines helpers for dispatching actions. That's what the .dispatcher file is for:

store/Breadcrumbs/Breadcrumbs.dispatcher.js (simplified, annotated)
import { toggleBreadcrumbs, updateBreadcrumbs }
  from 'Store/Breadcrumbs/Breadcrumbs.action';

/** @namespace Store/Breadcrumbs/Dispatcher */
export class BreadcrumbsDispatcher {
    // utility method for updating breadcrumbs
    // given a category,
    // and a dispatch function (from Redux)
    updateWithCategory(category, dispatch) {
        const breadcrumbs = this._getCategoryBreadcrumbs(category);
        dispatch(toggleBreadcrumbs(true));
        dispatch(updateBreadcrumbs(breadcrumbs));
    }
    
    _getCategoryBreadcrumbs(category) {...}

    updateWithProduct(product, dispatch) {...}

    updateWithCmsPage(cmsPage, dispatch) {...}
}

export default new BreadcrumbsDispatcher();

Unlike the reducer or action creators, you are free to have side effects in the dispatcher. For example, in store/Cart/Cart.dispatcher.js, addProductToCart makes a GraphQl mutation request before updating the store by dispatching a cart data update.

4. Usage in Components

Any component's container can read and dispatch to the Redux state by using the connect higher-order component.

Reading the state: Example

import { connect } from 'react-redux';

import Breadcrumbs from './Breadcrumbs.component';

// given the global state, need to return an object
// containing the values of the state that we need
// these will be passed as props to Breadcrumbs
/** @namespace Component/Breadcrumbs/Container/mapStateToProps */
export const mapStateToProps = (state) => ({
    breadcrumbs: state.BreadcrumbsReducer.breadcrumbs,
    areBreadcrumbsVisible: state.BreadcrumbsReducer.areBreadcrumbsVisible
});

// we specify mapDispatchToProps even though we don't need it
// so that ScandiPWA plugins can use it if necessary
/** @namespace Component/Breadcrumbs/Container/mapDispatchToProps */
export const mapDispatchToProps = () => ({});

// Breadcrumbs will get breadcrumbs and areBreadcrumbsVisible as props
export default connect(mapStateToProps, mapDispatchToProps)(Breadcrumbs);

Dispatching to the state: Example

route/CategoryPage/CategoryPage.container.js (simplified, annotated)
// we use a lazy import for better performance
export const BreadcrumbsDispatcher = import(
    /* webpackMode: "lazy", webpackChunkName: "dispatchers" */
    'Store/Breadcrumbs/Breadcrumbs.dispatcher'
    );

/** @namespace Route/CategoryPage/Container/mapStateToProps */
export const mapStateToProps = (state) => {...};

/** @namespace Route/CategoryPage/Container/mapDispatchToProps */
export const mapDispatchToProps = (dispatch) => ({
    updateBreadcrumbs: (breadcrumbs) => ((Object.keys(breadcrumbs).length)
            ? BreadcrumbsDispatcher.then( // promise to load BreadcrumbsDispatcher
                ({ default: dispatcher }) =>
                  dispatcher.updateWithCategory(breadcrumbs, dispatch)
            )
            : BreadcrumbsDispatcher.then(
                ({ default: dispatcher }) =>
                  dispatcher.update([], dispatch)
            )
    ),
    // [...]
});

/** @namespace Route/CategoryPage/Container */
export class CategoryPageContainer extends PureComponent {
    // updateBreadcrumbs will be passed as props when
    // react-redux wraps the component
    static propTypes = {
        updateBreadcrumbs: PropTypes.func.isRequired,
        // [...]
    };

    componentDidMount() {
        this.updateBreadcrumbs();
    }

    componentDidUpdate(prevProps) {
        this.updateBreadcrumbs();
    }

    updateBreadcrumbs(isUnmatchedCategory = false) {
        // we can simply get the function from props and call it
        const { updateBreadcrumbs, category } = this.props;
        const breadcrumbs = isUnmatchedCategory ? {} : category;
        updateBreadcrumbs(breadcrumbs);
    }
    
    render() {...}
}

export default connect(mapStateToProps, mapDispatchToProps)
    (CategoryPageContainer);

Last updated