Project
When richer interactivity and dynamic state are needed, a single-page application is often a better fit than a multi-page website.
The suggested project layout for single-page applications is the same as for multi-page sites, except:
- /public/pages
- As there is only
index.html
there is no need for the pages folder. - /public/app
- All of the views and routes for the application are in this folder, each implemented as a web component, and registered in
index.js
. - /public/app/App.js
- As in the major frameworks, the application is bootstrapped from an App component. See the example below.
Routing
Without the assistance of a server to do routing, the only option is hash-based routing:
- The current route is in
window.location.hash
, e.g.#/about
. - The route's changes are detected by listening to the Window hashchange event.
- Each web component is shown or hidden based on the active route.
This behavior can be encapsulated in a routing web component:
An example single-page vanilla application that uses this routing component:
It makes use of the template pattern to avoid showing a broken application if scripting is disabled.
Adding additional route components to the /app
folder is left as an exercise for the reader.
Entity encoding
A real-world application will often have complex markup in the web components, filled with variables based on user input.
This creates a risk for Cross-Site Scripting.
In fact, eagle-eyed readers may have noticed in the Passing Data example of the Components page that a XSS bug snuck in.
By entering the following as a name, the output of list.js
would have code injected:<button onclick="alert('gotcha')">oops</button>
Go ahead and return to that page to try it ...
To solve this we need to encode dangerous HTML entities while plugging variables into HTML markup, something frameworks often do automatically in their templating layer.
This html``
literal function can be used to do this automatically in a vanilla codebase:
The reworked list.js
that uses this:
To learn more on using this function, check the html-literal documentation.
Managing state
Where state lives
State is the source of truth of what the application should show. It is the data that gets turned into markup on the page by the application's logic.
In web frameworks state is often carefully managed so that it lives outside of the DOM, and then rendered into the DOM using a view layer. Every time the state is modified, the view layer rerenders the current view based on the new state, updating the DOM behind the scenes to match the new view. In this design the DOM is just a view on the state, but does not actually contain the state.
In vanilla web development however, state and view are merged together inside of a web component. The component carries its state in attributes and properties, making it part of the DOM, and it updates its appearance based on changes in that state in a self-contained way. In this design the DOM ends up being the owner of the state, not just a view on that state.
Take for example a simple counter:
The <x-counter>
component carries its state in the #count
property,
it provides an API for safely changing the state with the increment()
method,
and it always updates its appearance when the state is changed using the update()
method.
Lifting state up
Putting state inside of web components as attributes and properties at first can seem simple, but when scaling this up to more complex hierarchies of components it quickly becomes difficult to manage. This means care must be taken to organize state across the component hierarchy in the right way.
Generally speaking, the state management principles laid out in the React documentation are sound and should be followed even for vanilla web development. Here they are once again:
- Group related state. If you always update two or more state variables at the same time, consider merging them into a single state variable.
- Avoid contradictions in state. When the state is structured in a way that several pieces of state may contradict and “disagree” with each other, you leave room for mistakes. Try to avoid this.
- Avoid redundant state. If you can calculate some information from the component's attributes or properties during rendering, you should not put that information into that component's state.
- Avoid duplication in state. When the same data is duplicated between multiple state variables, or within nested objects, it is difficult to keep them in sync. Reduce duplication when you can.
- Avoid deeply nested state. Deeply hierarchical state is not very convenient to update. When possible, prefer to structure state in a flat way.
Let's look at these principes in action by porting the React lifting state up tutorial example application to vanilla code:
The implementation is divided across two web components: <x-accordion>
and <x-panel>
.
The state is "lifted up" from the panels onto the accordion, so that the accordion carries the state for both panels in a single central place.
Each of the two panels is stateless. It receives its state through the title
and active
properties.
When it is active, it shows its children (inside of a slot). When it is not active, it shows a button labeled "Show". It always shows the title.
By contrast, the accordion is where the state for the panels actually lives:
What to pay attention to:
-
The accordion's
activeIndex
property carries the state, and everything else is derived from that. This property becomes the single source of truth for the application, avoiding redundant state. -
An event listener for the
show
event sent by a panel will setactiveIndex
to the right value. The property setter foractiveIndex
explicitly calls theupdate()
method to bring the rest of the DOM in sync with the property's new state.
Finally, take a look at the original implementation of Accordion and Panel in React's tutorial:
Take note of how the state is organized the same across the React and vanilla implementations. The differences are in implementation details for state and rendering, not in how the application is structured.
Passing data deeply
While passing state deep into a hierarchy by handing it from parent components to child components via attributes or properties works, it can quickly become verbose and inconvenient. This is especially the case if you have to pass those through many components in the middle which have no need for that state aside from passing it to their child components, an anti-pattern colloquially known as "prop drilling".
Again we can take inspiration from how popular frameworks like React organize state, by adapting the concept of a context. A context holds state at a high level in the component hierarchy, and it can be accessed directly from anywhere in that hierarchy. The whole concept of a context is explained in the React passing data deeply tutorial.
To understand how to apply this concept in vanilla web development let us look at the ThemeContext example from the useContext documentation page. Here is an adapted vanilla version that uses a central context to keep track of light or dark theme, toggled by a button.
In this example a special web component <x-theme-context>
is created, whose only job is to keep track of state, provide setters to update that state, and dispatch events when the state changes.
Some key take-aways:
-
The
x-theme-context
component applies the context protocol, a convention for how web components can implement a context pattern. It does this by making use of a minimal implementation of the protocol provided intiny-context.js
. -
The context component also uses
display: contents
to avoid impacting the layout. It exists in the DOM hierarchy, but it becomes effectively invisible. -
Instead of
useContext
every component obtains the nearest theme context by dispatching acontext-request
event, whose callback will be answered by the nearest provider higher up in the DOM that provides the asked for context. By passingtrue
as the subscribe parameter the components can subscribe to updates. -
The
theme-toggle
function is also provided to the button by the context component. This mechanism can be used for dependency injection across web components.
The needs more context article does a deeper dive into the context protocol and the tiny-context implementation of it.
Up next
Go build something vanilla!
(Or keep reading on the blog.)