The configurator client store
- Version
- 4.0 preview
- Updated
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 type | Creator | Payload |
|---|---|---|
configbox/INIT | init(blob) | Seed the whole tree from the server-rendered blob |
configbox/REPLACE | replace(blob) | Replace the whole tree (legacy replaceConfiguratorData) |
configbox/SET_ITEM | setItem(key, value) | One top-level key (legacy setConfiguratorDataItem) |
configbox/SET_SELECTION | setSelection(questionId, selection, outputValue, selectedBy) | A question's current selection |
configbox/SET_PRICING | setPricing(pricing) | The pricing block |
configbox/SET_MISSING_PAGE | setMissingPageSelections(ids) | Unanswered required questions on this page |
configbox/SET_MISSING_PRODUCT | setMissingProductSelections(ids) | Unanswered required questions in the product |
configbox/MERGE_SERVER_RESPONSE | mergeServerResponse(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-jsonattribute on#configurator-datais 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
cbjin your module'sdefine([...])list;window.jQueryand$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
com_configbox_configurator_questions.md— the whole configurator loop, server and clientcom_configbox_frontend_requests.md— how the page talks to the servercom_configbox_runtime_api.md— the same round-trip without a browser../customization/com_configbox_assets_and_amd.md— the AMD loader and view initializationassets/javascript/store.js— the module itself, fully documented inline