I have a confession to make. At the end of the Plain Vanilla tutorial's Applications page a challenge was posed to the reader: port react.dev's final example Scaling Up with Reducer and Context to vanilla web code. Here's the confession: until today I had never actually ported over that example myself.
That example demonstrates a cornucopia of React's featureset. Richly interactive UI showing a tasks application, making use of a context to lift the task state up, and a reducer that the UI's controls dispatch to. React's DOM-diffing algorithm gets a real workout because each task in the list can be edited independently from and concurrently with the other tasks. It is an intricate and impressive demonstration. Here it is in its interactive glory:
But I lied. That interactive example is actually the vanilla version and it is identical. If you want to verify that it is in fact identical, check out the original React example. And with that out of the way, let's break apart the vanilla code.
Project setup
The React version has these code files that we will need to port:
- public/index.html
- src/styles.css
- src/index.js: imports the styles, bootstraps React and renders the App component
- src/App.js: renders the context's TasksProvider containing the AddTask and TaskList components
- src/AddTask.js: renders the simple form at the top to add a new task
- src/TaskList.js: renders the list of tasks
To make things fun, I chose the same set of files with the same filenames for the vanilla version. Here's index.html:
The only real difference is that it links to index.js and styles.css. The stylesheet was copied verbatim, but for the curious here's a link to styles.css.
Get to the code
index.js is where it starts to get interesting. Compare the React version to the vanilla version:
Bootstrapping is different but also similar. All of the web components are imported first to load them,
and then the <tasks-app>
component is rendered to the page.
The App.js code also bears more than a striking resemblance:
What I like about the code so far is that it feels React-like. I generally find programming against React's API pleasing, but I don't like the tooling, page weight and overall complexity baggage that it comes with.
Adding context
The broad outline of how to bring a React-like context to a vanilla web application is already explained in the passing data deeply section of the main Plain Vanilla tutorial, so I won't cover that again here. What adds spice in this specific case is that the React context uses a reducer, a function that accepts the old tasks and an action to apply to them, and returns the new tasks to show throughout the application.
Thankfully, the React example's reducer function and initial state were already vanilla JS code, so those come along for the ride unchanged and ultimately the vanilla context is a very straightforward custom element:
The actual context component is very bare bones, as it only needs to store the tasks, emit change events for the other components to subscribe to, and provide a dispatch method for those components to call that will use the reducer function to update the tasks.
Adding tasks
The AddTask component ends up offering more of a challenge. It's a stateful component with event listeners that dispatches to the reducer:
The main wrinkle this adds for the vanilla web component is that the event listener on the button element cannot be put inline with the markup. Luckily the handling of the input is much simplified because we can rely on it keeping its state automatically, a convenience owed to not using a virtual DOM. Thanks to the groundwork in the context component the actual dispatching of the action is easy:
Fascinating to me is that index.js, App.js, TasksContext.js and AddTask.js are all fewer lines of code in the vanilla version than their React counterpart while remaining functionally equivalent.
Hard mode
The TaskList component is where React starts really pulling its weight. The React version is clean and straightforward and juggles a lot of state with a constantly updating task list UI.
This proved to be a real challenge to port. The vanilla version ended up being a lot more verbose
because it has to do all the same DOM-reconciliation in explicit logic managed by the update() methods
of <task-list>
and <task-item>
.
Some interesting take-aways:
-
The
<task-list>
component's update() method implements a poor man's version of React reconciliation, merging the current state of the tasks array into the child nodes of the<ul>
. In order to do this, it has to store a key on each list item, just like React requires, and here it becomes obvious why that is. Without the key we can't find the existing<li>
nodes that match up to task items, and so would have to recreate the entire list. By adding the key it becomes possible to update the list in-place, modifying task items instead of recreating them so that they can keep their on-going edit state. - That reconciliation code is very generic however, and it is easy to imagine a fully generic repeat() function that converts an array of data to markup on the page. In fact, the Lit framework contains exactly that. For brevity's sake this code doesn't go quite that far.
-
The
<task-item>
component cannot do what the React code does: create different markup depending on the current state. Instead it creates the union of the markup across the various states, and then in the update() shows the right subset of elements based on the current state.
That wraps up the entire code. You can find the ported example on Github.
Some thoughts
A peculiar result of this porting challenge is that the vanilla version ends up being roughly the same number of lines of code as the React version. The React code is still overall less verbose (all those querySelectors, oy!), but it has its own share of boilerplate that disappears in the vanilla version. This isn't a diss against React, it's more of a compliment to how capable browsers have gotten that vanilla web components can carry us so far.
If I could have waved a magic wand, what would have made the vanilla version simpler?
- All of those querySelector calls get annoying. The alternatives are building the markup easily with innerHTML and then fishing out references to the created elements using querySelector, or building the elements one by one verbosely using createElement, but then easily having a reference to them. Either of those ends up very verbose. An alternative templating approach that makes it easy to create elements and get a reference to them would be very welcome.
- As long as we're dreaming, I'm jealous of how easy it is to add the event listeners in JSX. A real expression language in HTML templates that supports data and event binding and data-conditional markup would be very neat and would take away most of the reason to still find a framework's templating language more convenient. Web components are a perfectly fine alternative to React components, they just lack an easy built-in templating mechanism.
-
Browsers could get a little smarter about how they handle DOM updates during event handling.
In the logic that sorts the
<li>
to the right order in the list, the if condition before insertBefore proved necessary because the browser didn't notice that the element was already placed where it needed to be inserted, and click events would get lost as a consequence. I've even noticed that assigning a textContent to a button mid-click will make Safari lose track of that button's click event. All of that can be worked around with clever reconciliation logic, but that's code that belongs in the browser, not in JavaScript.
All in all though, I'm really impressed with vanilla JS. I call it unreasonably effective because it is jarring just how capable the built-in abilities of browsers are, and just how many web developers despite that still default to web frameworks for every new project. Maybe one day...