Using Redux with Vanilla JS
Redux is a popular state management library for JavaScript apps that is routinely paired with frameworks like React or Angular. What follows is an explanation of how I’ve used Redux to make a production vanilla JS app more maintainable.
I’m hoping this is useful for anyone out there who is looking for a real-world Redux + vanilla JS example beyond a button incrementer or to-do app.
Background
Let’s go back to late 2017/early 2018. The subject app is in production and has ~8 engineers actively developing. This ain’t your trendy universal React app. It’s purpose-built for managing content for our company.
The backend framework is Django and includes Wagtail as a CMS. The frontend is babel-transpiled vanilla JS with SCSS as a preprocessor. Templates are all handled by Django’s built-in templating system.
The frontend app has its roots in the Knockout JS framework, but this was reduced to vanilla JS in pursuit of faster page loads and script executions. Most of the frontend logic is meant to handle simple features like sharing to social networks, subscribing to a newsletter, and submitting forms.
Each page stands alone and references its own template CSS file for styles, and template JS file for logic. Since we don’t use a frontend framework at runtime, the pages are much faster than our competitors.
Identifying the problems
As our app grew, we had to support more CMS templates that were used for even more pages. Common bugs would appear and reappear as we worked. Problems fell into two categories.
1. Code reuse was arbitrary and difficult
Our frontend code had duplication stemming from over-eager classical inheritance patterns. There were multiple levels of inheritance that had to be traversed to understand how a certain page’s logic would differ from another, very similar page.
This got worse with time.
Template functionality requirements were fluid. It was hard for newer hires to conform to the intricate inheritance layers when making changes. A ticket would be raised to fix a bug on one page with the assumption that it would apply to all templates. Unfortunately, that wasn’t the case.
2. Asynchronous data, without any management
Some code would make asynchronous data requests to a separate server. The response data existed only within the context of the requesting function. This was fine until we ran into situations where we wanted to share data between separate functions.
Some features required data from multiple asynchronous functions and an engineer would have to manually invoke those functions and do it in the right order. It wasn’t immediately clear what that order was and why it was important until bugs cropped up in staging environments.
It was especially awful with the duplication and confusing inheritance patterns already pervasive in the code. Plus, unit test coverage wasn’t very good.
Composing a solution with Redux
I audited the entirety of the frontend application and reduced all logic into small building blocks called features. These features almost never interacted with each other. They simply wanted to run, process some template data, attach event handlers to the DOM, and had no need to exist after that.
Our app’s multilevel inheritance structure only existed to group features common to a template. I could accomplish the same thing and reduce cognitive load by simply composing our features together.
for this, I used the compose() utility that Redux provides. See what kind of effect it has on the code below.
Before: multilevel inheritance
1class BasePage {2 constructor() { console.log('Setting up base page') }3};45class HeaderPage extends BasePage {6 constructor() {7 super();8 this.initHeader();9 }10 initHeader() { console.log('Initializing header') }11};1213class FormPage extends HeaderPage {14 constructor() {15 super();16 this.initForm();17 }18 initForm() { console.log('Initializing form') }19};2021class FormNoHeader extends FormPage {22 constructor() { super() }23 // Empty method so that this.initHeader() in FormPage constructor does nothing24 initHeader() {}25};2627class DifferentFormNoHeader extends FormPage {28 constructor() { super() }29 initHeader() {}30 initForm() { console.log('Initializing different form') }31}3233const formPage = new FormPage();34// Output:35// Setting up base page36// Initializing header37// Initializing form3839const formNoHeader = new FormNoHeader();40// Output:41// Setting up base page42// Initializing form4344const differentFormNoHeader = new DifferentFormNoHeader();45// Output:46// Setting up base page47// Initializing different form
Most problems arose when we wanted to copy template functionality except for one or two pieces of logic.
AFTER: functional composition
1import { compose } from 'redux';23const setup = () => { console.log('Setting up base page') };4const initHeader = () => { console.log('Initializing header') };5const initForm = () => { console.log('Initializing form') };6const differentForm = () => { console.log('Initializing different form') };78const formPage = compose(initForm, initHeader, setup)();9// Output:10// Setting up base page11// Initializing header12// Initializing form1314const formNoHeader = compose(initForm, setup)();15// Output:16// Setting up base page17// Initializing form1819const differentFormNoHeader = compose(differntForm, setup)();20// Output:21// Setting up base page22// Initializing different form
Beautiful
Switching to functional composition proved to be highly beneficial. I was able to remove all logic related to individual templates since they are just comprised of features. This reduced the complexity of our frontend immensely!
Each feature comes with its own set of unit tests. These are easy to write, because of the isolated nature of the code. If unit tests are easy to write, they will get written more often, helping the team keep up with maintenance.
By using compose() we can also bundle features together and import that bundle all at once. This is helpful for when we create a new Django template X that needs to be like template Y, but with the addition of feature Z.
Bundled features
12import { compose } from 'redux';34// Features5const setup = () => { console.log('Setting up base page') };6const initHeader = () => { console.log('Initializing header') };7const initForm = () => { console.log('Initializing form') };8const initSlider = () => { console.log('Initializing slider') };910// Similar to the BasePage and HeaderPage classes11const basePage = setup();12const headerPage = initHeader(basePage);1314// We can take the headerPage bundle and compose off of that15const formSliderPage = compose(initSlider, initForm)(headerPage);16// Output:17// Setting up base page18// Initializing header19// Initializing form20// Initializing slider
Sharing data with Redux
I didn't bring Redux in as a dependency just for compose(). I also needed a way to share data between features and manage async calls. I needed to use a data store.
To be a feature, a function..
- must be unary (accept only the Redux store as an argument)
- must return our app’s store for the next function in the chain
- must not be async
A typical feature
1const makeLinksAlert = store => {2 const links = document.querySelector('a')3 Array.from(links).forEach(el => {4 el.addEventListener("click", () => { alert('alert!') });5 });6 return store;7};
A lot of our features do small things like this.
Each feature has access to the Redux store, which is powerful.
At the same time, it’s important to restrict features from blocking on async requests during the initial script evaluation. It would be disastrous if a form wasn’t responsive as soon as it was visible. So I only dispatch() and getState() when it makes sense (typically on user interaction).
The Redux store maintains the state of important async calls. I dispatch relevant actions as async calls are made inside features. Features can subscribe to the store and fire once a value is truthy. This provides a way to chain asynchronous calls between features without having to rely on bloated Promise chains.
I created a simple subscribe function to accomplish this. Note that it also unsubscribes once the callback fires.
Subscribe utility
1const passConditions = (conditions, state) =>2 conditions.every(condition => {3 const path = condition.split(".");4 // Check to see if condition values are truthy5 return !!path.reduce(6 (accum, curr) => (accum && accum[curr] ? accum[curr] : null),7 state8 );9 });1011// Conditions are an array of string paths to access store keys.12// ["quoteId.value"] would only fire if nested key value was truthy.13// A store like { quoteId: { value: true } } would suffice.14export default (conditions, callback) => store => {15 let fired = false;16 // The redux store subscribe returns an unsubscribe function17 const unsubscribe = store.subscribe(() => {18 const state = store.getState();19 if (!passConditions(conditions, state)) return;20 // This line is to pass tests21 if (unsubscribe) unsubscribe();22 // The unsubscribe call is not enough to ensure the callback only fires once23 if (!fired) callback(state, store);24 fired = true;25 });26 return store;27};
There are circumstances where a feature is waiting on multiple async calls to fire. Simply subscribe to both async values and wait for them both to be valid.
Using subscribe utility
1import subscribe from './subscribe'23// Store state4// {5// form: {6// loaded: true,7// emailInput: {8// value: 'email@domain.com'9// }10// }11// }1213const validateInput =14 subscribe(['form.loaded', 'form.emailInput.value'], state => {15 console.log('Validating input');16 });
The validateInput feature will fire once when both conditions are truthy. It will unsubscribe afterwards.
Conclusion
I’m pleased to have found a use for Redux outside of React and to have applied some of the functional programming techniques I’ve been studying. The refactors have been thriving in production for a year now and our frontend code is healthier than ever.
Benefits recap:
- Removal of template JS logic
- Smaller, focused units of code
- Easier to test
- Less async bugs
That said, I’m still working to improve on this concept.
We’ve had engineers import a feature, without realizing that it already exists in the compose chain via a feature bundle. This means that feature would execute twice. I’m working on a solution for that.
Since template JS files have become tacit compose functions, I can evaluate the Django template at compile time and come up with the features that should be composed. Tie that to the CMS blocks, deploy dynamic JS to a CDN, and now we can build features without worrying about templates at all.
Maybe I’ll have a follow-up next year. 😄