Over the past year at AutoScout24, we worked hard within our team to modernise our entire frontend stack from a jQuery-based vanillaJS frontend to a React / TypeScript / ImmutableJS / Redux / Redux Observable one, together with SSR on NodeJS in the context of a Scala Play backend. While this has brought much joy to our everyday frontend work, it’s painful to see other teams still relying on “hand-rolled” vanillaJS solutions for even new microservices.
So how can we bring some of the improvements from the React world to these vanillaJS microservices?
Most developers will take the path of least resistance when implementing a feature, so it’s essential that we provide an architecture they can rely on even in non-React cases. We noticed that a lot of the benefits provided by a React app don’t have anything to do with React itself but with the companion Flux architecture most React codebases rely on.
State management is at the heart of the problem of all these vanillaJS frontends,
and this is what Redux solves so elegantly (and at 2kB gzipped, with minimal bundle overhead).
Common drawbacks of vanillaJS frontends
But first let’s review the usual problems of backend-generated pages in terms of JS code.
Spaghetti event logic with listeners being registered in different places and reactive logic that is hard to track.
State is spread throughout the DOM (usually as data attributes, coupling state to views) or in-memory inside functions, making it difficult to analyse how it looks like at any point in time.
DOM manipulation is done in different places, usually along the event listeners.
Mixed responsibility, where analytics tracking, logging and such are performed alongside regular event handlers.
Lack of architecture makes it hard for newcomers to understand application logic and flow.
Redux to the rescue
The problems listed above disappear if we mix Redux plus coding and usage conventions. You can exploit Redux, together with its middleware capabilities, to achieve all this without writing a single line of React:
State is king. We will visualise it and track changes with the Redux devtools we all know and love. This will give us clear state transformations that can be easily testable. The initial state will also be clearly wired from static configuration plus backend data.
Isolate the DOM by bridging all events to Redux actions and handling mutations in one place. These actions will clearly describe all possible events the application cares about.
Pluggable architecture via Redux middleware will let us push functionality such as two-way communication with outside scripts, analytics, metrics, tracking, and logging to the edge of the application without polluting the core.
The following assumes you know Redux, if you don’t, please take a look at the excellent online documentation. We are using TypeScript because we want the compiler to work for us but you can use plain JS as well.
Let’s assume we have a backend-generated view. In this example we have a Scala Play template but it could be anything (even PHP 😃).
This example displays a few images randomly and provides a dropdown selector to apply filters to them. A real codebase would pass configuration to the frontend via JSON-encoded data attributes or some other mechanism, plus JS/CSS script references.
We will have a few files that we structure our application around:
- main.ts is the usual Webpack entrypoint where we wire up configuration reading and the Redux store.
- configureStore.ts is the standard Redux store wiring script, where the middleware is registered.
- reducer.ts/actions.ts are standard Redux implementations which define the state of the component and the “events” (actions) associated with it.
- gallery.scss are some CSS styles for our components.
The other three files are the meat of the implementation. Let’s start with the DOM bridge, which is encapsulated in the following code:
Here we bridge the DOM world to the Redux one by converting DOM events into Redux actions. The rest encapsulates a lot of the “dirty” aspects of typical vanillaJS applications, namely DOM manipulation.
Isolate DOM interaction by bridging it to Redux.
Now that we have a layer to interact with the DOM, let’s see how we can wire up the application flow via Redux’s middleware.
There is a lot going on here, so let’s break it down by parts.
The first thing we want to do is register the DOM event listeners we defined previously. We do this on the “outside” of the middleware, so event registration only runs once. For the inner part,
React on certain actions: This is similar to how things work in the React world, where using the switch(action.type) statement, we can perform async side effects such as http calls, re-firing an action with the response if we need to. In plain React, one normally uses libraries like redux-thunk to deal with async IO, here we don’t need anything extra since we already have middleware power at hand.
Reconcile state-changes with the DOM: Here we apply DOM transformations based on state changes. This is one of the big differences to the React world, where Redux’s connect component would bridge Redux’s state to the connected views, and React then recomputes the whole DOM tree for us based on that. Note that we use triple-equals for shallow object comparison and that’s why it’s important that the Redux state be immutable.
Manual state reconciliation is the big difference with React
We have defined a clear way for DOM transformations occurrences and can perform side effects cleanly. We can scale our frontend by defining separate middleware for each component so they can work in isolation, and only share state if necessary. Tracking and other non-core concerns can also be moved into middleware, providing a layered architecture where code follows single-responsibility principle and works reactively.
Data flow is strictly one way. Starting from the original backend view, we have the following flow:
1. UI interaction or external events dispatch actions (via event listeners).
2. Redux actions are used to update state.
3. State changes are used to update views.
4. Go back to 1
React, especially in combination with additional side-effect libraries such as redux-observable and TypeScript for proper type-safety is an awesome combo. However, there are cases where old-school backend-generated vanillaJS pages find their way into production, due to inexperienced developers, resource constraint, or acts of God. In these cases, all is not lost, we can still leverage the power of Redux to flatten the jungle and bring some sanity to our codebases.
One can only hope that the benefits of one-way data flow and controlled side effects should convince wary developers to leverage React and Redux during their next refactoring experience. So in a way,
Redux in a vanillaJS context is a gateway drug to full-fledged React solutions.
and that can only be a good thing…