Skip to main content
Version: 3.x

The configurator client store

Version
3.x
Updated
View markdown

Scope: configbox/store — the configurator page's client-side state: the tree, the actions, the selectors, the legacy bridge, and how customization JavaScript should read and watch it · Last reviewed: 2026-08-25

The configurator page's client state has a single owner: a small Redux-style store in the AMD module configbox/store (assets/javascript/store.js). This page is the reference for it. For the round-trip it participates in — how a click becomes a server call and a re-render — read com_configbox_configurator_questions.md first; this page is only about the state.


1. Why it exists

The state used to be a single mutable JSON blob in the jQuery .data('json') store of #configurator-data, poked directly through getConfiguratorData() / setConfiguratorDataItem() / replaceConfiguratorData().

It worked, and it drifted. The blob was effectively write-once: interactions mutated the DOM and fired events, but nothing owned the transitions, so the state object fell out of step with what the page actually showed — a required question got answered and missingProductSelections never changed. Every consumer then had a slightly different idea of the truth.

The store fixes that by making transitions the only way state changes:

  • One immutable tree. Reducers never mutate; they shallow-copy and return a new tree.
  • dispatch(action) is the only writer. Every transition is a named, inspectable action.
  • Reads go through getState() / select(selector).
  • subscribe(listener) fires after every committed change and returns an unsubscribe function.

It is a minimal Redux written by hand — plain ES5 AMD, no dependency and no bundler, to fit this codebase (see ../customization/com_configbox_assets_and_amd.md).

2. The state tree

The tree keeps every top-level key of the server-rendered seed blob (dateFormat, pageSequence, cartPositionId, …) so the legacy accessors resolve unchanged, and overlays the store-owned containers on top:

{
…every original blob key…,

entities: {
questions: { "413": {}, "414": {} } // normalised, by id
},
selections: { // per question, filled as selections happen
"413": { selection, outputValue, selectedBy }
},
missingPageSelections: [ …question ids… ], // guaranteed to exist
missingProductSelections: [ …question ids… ], // guaranteed to exist
pricing: {}
}

Two things are worth knowing about that shape. Questions are normalised by id, so a selector never has to scan an array or touch the DOM to find one. And the required-selection arrays are guaranteed to exist even when the seed omitted them, so selectors cannot trip on undefined.

3. The API

cbrequire(['configbox/store'], function (store) {
var s = store.getStore(); // the page's singleton store

s.getState(); // the current tree
s.select(store.selectors.pricing); // run a selector against it
s.dispatch(store.actions.setPricing(newPricing));

var off = s.subscribe(function () { /* fired after every committed change */ });
off(); // unsubscribe
});

The module also exports createStore(reducer, preloadedState), resetStore(), reducer, actions, selectors, ActionTypes, buildInitialState() and toLegacySnapshot() — the lower half exists for tests and for advanced use; day to day you want getStore(), the selectors and the actions.

Actions

Action typeCreatorPayload
configbox/INITinit(blob)Seed the whole tree from the server-rendered blob
configbox/REPLACEreplace(blob)Replace the whole tree (legacy replaceConfiguratorData)
configbox/SET_ITEMsetItem(key, value)One top-level key (legacy setConfiguratorDataItem)
configbox/SET_SELECTIONsetSelection(questionId, selection, outputValue, selectedBy)A question's current selection
configbox/SET_PRICINGsetPricing(pricing)The pricing block
configbox/SET_MISSING_PAGEsetMissingPageSelections(ids)Unanswered required questions on this page
configbox/SET_MISSING_PRODUCTsetMissingProductSelections(ids)Unanswered required questions in the product
configbox/MERGE_SERVER_RESPONSEmergeServerResponse(data)A whole configurator server response

MERGE_SERVER_RESPONSE is the one that matters. Every configurator round-trip folds into the tree through it, atomically — which is precisely what the old blob could not do, and why the state now stays in step with the page across interactions.

Selectors

questions(state) · question(state, id) · questionProp(state, id, prop) · questionHasProp(state, id, prop) · selection(state, id) · pricing(state) · missingPageSelections(state) · missingProductSelections(state)

questionProp() throws on an unknown question or property — deliberately, because that is the legacy accessor's contract and code depended on it. Use questionHasProp() when absence is a normal case rather than a bug.

4. The legacy bridge — and the one real gotcha

Nothing was broken to add this. The classic accessors — configurator.getConfiguratorData(), setConfiguratorDataItem(), replaceConfiguratorData(), getQuestionPropValue(), questionHasProperty() — are now thin shims over the store. And on every commit the store mirrors a flat snapshot back into #configurator-data's jQuery .data('json'), so code that still reads the blob directly keeps seeing an up-to-date object. The normalised entities / selections containers ride along under _store on that snapshot for anyone who wants them.

The gotcha: the DOM data-json attribute on #configurator-data is only the frozen initial seed. It is not updated. Read the store, a selector, or the jQuery .data('json') mirror — never the attribute — for current state. This has bitten both customization code and tests.

jQuery is AMD-scoped. Declare cbj in your module's define([...]) list; window.jQuery and $ are not the copies this component uses.

5. Using it from customization JavaScript

define(['cbj', 'configbox/store'], function (cbj, store) {
'use strict';

var s = store.getStore();

// React to price changes without polling or guessing at events.
var previous = s.select(store.selectors.pricing);
s.subscribe(function () {
var now = s.select(store.selectors.pricing);
if (now !== previous) {
previous = now;
// … update your own UI …
}
});
});

Two rules: subscribe, do not poll — the listener fires after every committed change; and never mutate what getState() hands back — dispatch an action instead, or the next reducer will build its new tree from a tree somebody else has already edited.

6. Testing

resetStore() drops the singleton so the next getStore() rebuilds from the DOM seed — which is what a test wants between cases. createStore(reducer, preloadedState) builds an isolated store with no DOM mirror at all, for reducer and selector unit tests.

When driving the configurator from a browser test, wait for the page's readiness marker before reaching into the store — see the readiness markers in com_configbox_kenedo_view.md, and guard the AMD global itself (typeof window.cbrequire === 'function') on cold loads.

7. See also