Fragmented Thought

Redux and Vanilla JS

By

Published

Lance Gliser

Heads up! This content is more than six months old. Take some time to verify everything still works as expected.

Redux is a rather famous part of the React ecosystem, but it doesn't have to be. All of the logic arguments for Redux stand on their own apart from the rendering engine you pair with it. In the past, I've emulated Redux-like data stores in Angular 1.x apps, and hand rolled solutions. They do one thing, and they do it really well, manage state. There's always arguments for spawning multiple Redux stores of the best I've heard was one store per API consumed as a cache.

But no matter how you end up potentially using state based stores, their purpose remains applicable in almost any application. Redux moves the source of truth off the canvas it is meant to manipulate.

I've done some light experimentation for a project I may end up scrapping, but wanted to share the results just the same. It was entertaining, and it's awesome to know you can get all the power you need from Redux, without fancy tooling or even a build process. A few pieces that should be of keen interest to you if you're new to Redux:

  • settings.initialControlState vs defaultControlsState when creating the store. This is how you get program specific defaults, overridden by item settings on startup.
  • The various Redux.combineReducers object keys that map to the functions that reduce them. Managing your entire app's reducers through a single function is mind boggling.
  • store.subscribe(render); followed immediately by render();. Redux is not RxJS. You could setup a store object property as a stream, but it's not native. You need to fire that initial paint yourself most of the time.
  • handleUpdateFilters vs handleApplyFilters. You must think of every change to the state firing an action, even if the user sees only the results of the application of those previous actions.
  • The fanatical use of Object.assign({}, state, {key: value});. Never alter the state object directly.
/* globals Redux, settings */ jQuery(document).ready(function () { reduxSearch(settings); }); function reduxSearch(settings) { /** * Constants */ var ACTION_CLICK = "click"; var ACTION_APPLY_ELEMENT_FILTER = "update_fliter"; var ACTION_RESET_FILTERS = "reset_filters"; var ACTION_APPLY_CONTROLS = "apply_controls"; var ACTION_CONTROLS_AJAX_STARTED = "controls_ajax_started"; var ACTION_CONTROLS_AJAX_SUCCESS = "controls_ajax_success"; var ACTION_CONTROLS_AJAX_FAILURE = "controls_ajax_failure"; var VIEW_MODE_TILES = "tiles"; var VIEW_MODE_LIST = "list"; var ACTION_APPLY_VIEW_MODE_TILES = "tile_view"; var ACTION_APPLY_VIEW_MODE_LIST = "list_view"; var previousControlSignature; var previousResultsState; /** * {HTMLElement} search results pane */ var resultsElement; /** {NodeList} */ var filterElements; var store; // You may use this format when creating an initialState // in settings to pass filtering and sorting data var defaultControlsState = { filters: { primary: {}, location_proximity: 20, }, sorts: {}, }; init(); function init() { // Define UI elements resultsElement = document.querySelector("#search-results"); // Create a redux state data store // Define the multiple reducers var combinedReducer = Redux.combineReducers({ controls: controls, viewMode: viewMode, interactions: interactions, ajax: ajax, }); // Define the initial state (see defaultControlsState) var initialState; if (settings.initialControlsState) { initialState = { controls: Object.assign( {}, settings.initialControlsState, defaultControlsState ), }; } // Create the store store = Redux.createStore(combinedReducer, initialState); // Setup to repaint when the state changes store.subscribe(render); // Handle our first paint manually render(); bindEvents(); applyFilters({ isLocked: true, // We don't have proper filters to event display isForced: true, // Initial state is assured to match, but we need the results to render dynamic filters }); } function bindEvents() { // Bind events to make things happen! document.addEventListener("click", clickHandler); // Rebind to any filters we may have pained onto the screen in our previous render rebindFilters(); } /** * Called multiple times to handle filters that have been introduced later */ function rebindFilters() { // For setting filters into the the store, but not firing the apply action filterElements = document.querySelectorAll("[data-filter]"); filterElements.forEach(function (element) { // We don't want to listen for apply-filter elements // Those call their own update if required to ensure ordering if (element.dataset.hasOwnProperty("applyFilter")) { return true; } if (element.filterEventBound) { return true; } element.addEventListener("change", handleUpdateFilters); element.filterEventBound = true; }); // For applying the store's new filters var applyFilterElements = document.querySelectorAll("[data-apply-filter]"); applyFilterElements.forEach(function (element) { if (element.applyEventBound) { return true; } element.addEventListener("change", handleApplyFiltersChange); element.applyEventBound = true; }); } /** * Returns the portions of the redux store relevant to the controls * @returns {object} */ function getControlsState() { var state = store.getState(); return { controls: state.controls, viewMode: state.viewMode, ajax: state.ajax, }; } /** * Returns the portions of the redux store relevant to the results * @returns {object} */ function getResultsState() { var state = store.getState(); return { viewMode: state.viewMode, ajax: state.ajax, results: undefined, }; } /** * Event handlers */ /** * @param {Event} event */ function clickHandler(event) { store.dispatch({ type: ACTION_CLICK, event: event }); } /** * @param {Event} event */ function handleUpdateFilters(event) { updateFilters(event); } /** * @param {Event} event */ function handleApplyFiltersChange(event) { if (event.target.dataset.hasOwnProperty("filter")) { updateFilters(event); } applyFilters({ action: ACTION_APPLY_CONTROLS, }); } /** * @param {Event} event */ function updateFilters(event) { store.dispatch({ type: ACTION_APPLY_ELEMENT_FILTER, event: event }); } /** * Fires an ajax call to get new filters, results, etc. * It updates the state based on the state of the ajax request. * * @param {object} options */ function applyFilters(options) { options.isLocked = options.isLocked || false; options.isForced = options.isForced || false; var state = store.getState(); var parameters = { action: options.action, filters: state.controls.filters, sorts: state.controls.sorts, }; store.dispatch({ type: ACTION_CONTROLS_AJAX_STARTED, event: event, areControlsLocked: options.isLocked, }); jQuery.ajax(settings.ajaxUrl, { method: "POST", data: parameters, dataType: "json", error: _applyFiltersError, success: _applyFiltersSuccess, }); } /** * @param jqXHR * @param {string} textStatus * @param {string} errorThrown */ function _applyFiltersError(jqXHR, textStatus, errorThrown) { store.dispatch({ type: ACTION_CONTROLS_AJAX_FAILURE, jqXHR: jqXHR, textStatus: textStatus, errorThrown: errorThrown, }); } /** * @param data */ function _applyFiltersSuccess(data) { store.dispatch({ type: ACTION_CONTROLS_AJAX_SUCCESS, data: data }); } /** * Primary 'something has changed' render again please starting point. * This is the end of Redux, and the start of React, Vue, or your own special handling. */ function render() { // React would normally handle this kind of differential calculation and selective rendering // You really should look into that someday if you think the below is as mad as I do. var controlsState = getControlsState(); var controlSignature = JSON.stringify(controlsState); if (previousControlSignature !== controlSignature) { previousControlSignature = controlSignature; renderControls(controlsState); } var resultsState = getResultsState(); resultsElement.innerHTML = JSON.stringify(resultsState); } /** * * @param state */ function renderControls(state) { document.querySelector("#controls-state").innerHTML = JSON.stringify(state); var filterElements = document.querySelectorAll("[data-filter]"); // Locking (due to market changes and other conditions) filterElements.forEach(function (element) { element.disabled = state.ajax.areControlsLocked; }); filterElements.forEach(function (element) { _updateElementByFilters(state.controls.filters, element); }); /** * @param {object} filters * @param {HTMLElement} element * @private */ function _updateElementByFilters(filters, element) { var filterType = element.dataset.filter; var property, value; // Figure out where we stored it switch (element.type) { case "checkbox": case "radio": property = element.value; value = element.checked; break; case "select": // I Sure did not do this for this demo break; default: property = element.name; if (!!element.value) { value = element.value; } } // Get the value out switch (filterType) { case "": value = filters[property] || null; break; default: value = filters[filterType][property] || null; } // Set it // console.debug('Setting element by filter', filterType, element.name, value); switch (element.type) { case "checkbox": case "radio": element.checked = value; break; case "select": // I Sure did not do this for this demo break; default: element.value = value; } return filters; } } /** * Redux state reducers */ /** * A composing reduce function. It's handed state controls affecting data * @param state * @param action */ function controls(state, action) { // If state is undefined, return the initial application state if (typeof state === "undefined") { return Object.assign({}, defaultControlsState); } switch (action.type) { case ACTION_CONTROLS_AJAX_SUCCESS: return Object.assign({}, state, { filters: _updateFiltersByMetadata( state.filters, action.data.metadata ), }); case ACTION_APPLY_ELEMENT_FILTER: return Object.assign({}, state, { filters: _updateFiltersByElement(state.filters, action.event), }); case ACTION_RESET_FILTERS: return Object.assign({}, state, { filters: {}, }); default: return state; } /** * @param {object} filters * @param {object} metadata * @return object * @private */ function _updateFiltersByMetadata(filters, metadata) { // debugger; return filters; } /** * @param {object} filters * @param {Event} event * @return object * @private */ function _updateFiltersByElement(filters, event) { var element = event.target; var filterType = element.dataset.filter; var property, value; switch (element.type) { case "checkbox": case "radio": property = element.value; value = element.checked; break; case "select": // I Sure did not do this for this demo break; default: property = element.name; if (!!element.value) { value = element.value; } } switch (filterType) { case "": filters[property] = value; break; default: filters[filterType][property] = value; } return filters; } } /** * A composing reduce function. It's handed state.viewMode only * @param viewMode * @param action */ function viewMode(viewMode, action) { if (typeof state === "undefined") { return VIEW_MODE_TILES; } switch (action.type) { case ACTION_APPLY_VIEW_MODE_TILES: return VIEW_MODE_TILES; case ACTION_APPLY_VIEW_MODE_LIST: return VIEW_MODE_LIST; default: return viewMode; } } /** * A composing reduce function. It's handed state.interactions only * @param state * @param action */ function interactions(state, action) { if (typeof state === "undefined") { return { event: undefined, clicksTotal: 0, }; } switch (action.type) { case ACTION_CLICK: return Object.assign({}, state, { event: action.event, clicksTotal: (state.clicksTotal += 1), }); default: return state; } } /** * A composing reduce function. It's handed only state.ajax * @param state * @param action */ function ajax(state, action) { if (typeof state === "undefined") { return { isFetchingControls: false, areControlsLocked: false, isFetchingResults: false, isError: false, }; } switch (action.type) { case ACTION_CONTROLS_AJAX_STARTED: return Object.assign({}, state, { isFetchingControls: true, areControlsLocked: action.areControlsLocked || false, isError: false, }); case ACTION_CONTROLS_AJAX_SUCCESS: return Object.assign({}, state, { isFetchingControls: false, areControlsLocked: false, isError: false, }); case ACTION_CONTROLS_AJAX_FAILURE: return Object.assign({}, state, { isFetchingControls: false, areControlsLocked: false, isError: true, }); default: return state; } } }