A stateful component helper function for vanilla JS
Yesterday, we talked about what state is and how to create state-based components with vanilla JS.
Today, I’m going to show you a small helper function that makes creating stateful components (and rendering them into the DOM) easier, without having to rely on a big framework or library. It weighs just 300 bytes (that’s 0.3kb) after minification and gzipping, vs 80kb or so for the lightest modern frameworks.
First, I’m going to show you how to use it. Then I’ll show you the function itself and break down how it works.
How to use it
The Component()
method accepts two arguments.
The first is a string selector for the element that you’ll be rendering your component into. You can optionally pass in the node itself.
// This works
var app1 = new Component('#app');
// So does this
var app2 = new Component(document.querySelector('#app2'));
Defining a template for your component
The second argument is an object of options. It requires a template
property, as either a string or an object that requires a string, to render into the DOM.
// Your template can be a string
var app1 = new Component('#app', {
template: 'Hello, world!'
});
// It can also be a function that returns a string
// Your template can be a string
var app2 = new Component('#app2', {
template: function () {
return 'Hello, world!'
}
});
Adding state to the component
As an optional property of the options
argument, you can include state for the component with the data
property.
The data
is automatically passed into your template
function, so that you can use it customize the template.
// Some data
var app = new Component('#app', {
data: {
greeting: 'Hello',
name: 'world'
},
template: function (props) {
return props.greeting + ', ' + props.name + '!';
}
});
Sanitizing state
If your state includes user-provided content, the Component()
function has a sanitize()
method built into it that you can use to strip out malicious code and avoid cross-site scripting attacks.
// Some data
var app = new Component('#app', {
data: {
greeting: 'Hello',
name: 'world'
},
template: function (props) {
return props.greeting + ', ' + Component.sanitize(props.name) + '!';
}
});
Rendering your component
To render a component, call the render()
method on it.
// Create a component
var app = new Component('#app', {
data: {
greeting: 'Hello',
name: 'world'
},
template: function (props) {
return props.greeting + ', ' + props.name + '!';
}
});
// Render the component
app.render();
Updating your state
The data
is a property of your component. You can access and update it directly on the component.
app.data.greeting = 'Bonjour';
app.data.name = 'Universe';
You can update your component in the DOM by calling the render()
method again.
app.render();
Here’s an updated demo. It renders the initial layout, and uses setTimeout()
to update the state and re-render the UI 2 seconds later.
window.setTimeout(function () {
app.data.greeting = 'Bonjour';
app.data.name = 'Universe';
app.render();
}, 2000);
The Component()
helper function
Here’s the full code for the Component()
helper function. You can also find it on the Vanilla JS Toolkit.
/*!
* A vanilla JS helper for creating state-based components
* (c) 2018 Chris Ferdinandi, MIT License, https://gomakethings.com
* @param {String|Node} elem The element to make into a component
* @param {Object} options The component options
*/
var Component = (function () {
'use strict';
/**
* Create the Component object
* @param {String|Node} elem The element to make into a component
* @param {Object} options The component options
*/
var Component = function (elem, options) {
if (!elem) throw 'ComponentJS: You did not provide an element to make into a component.';
this.elem = elem;
this.data = options ? options.data : null;
this.template = options ? options.template : null;
};
/**
* Sanitize and encode all HTML in a user-submitted string
* @param {String} str The user-submitted string
* @return {String} The sanitized string
*/
Component.sanitize = function (str) {
var temp = document.createElement('div');
temp.textContent = str;
return temp.innerHTML;
};
/**
* Render a template into the DOM
* @return {[type]} The element
*/
Component.prototype.render = function () {
// Make sure there's a template
if (!this.template) throw 'ComponentJS: No template was provided.';
// If elem is an element, use it.
// If it's a selector, get it.
var elem = typeof this.elem === 'string' ? document.querySelector(this.elem) : this.elem;
if (!elem) return;
// Get the template
var template = (typeof this.template === 'function' ? this.template(this.data) : this.template);
if (['string', 'number'].indexOf(typeof template) === -1) return;
// Render the template into the element
if (elem.innerHTML === template) return;
elem.innerHTML = template;
// Dispatch a render event
if (typeof window.CustomEvent === 'function') {
var event = new CustomEvent('render', {
bubbles: true
});
elem.dispatchEvent(event);
}
// Return the elem for use elsewhere
return elem;
};
return Component;
})();
Let’s look at what’s going on here.
Setting up our plugin
The Component()
helper method uses a revealing module pattern.
/**
* A vanilla JS helper for creating state-based components
* @param {String|Node} elem The element to make into a component
* @param {Object} options The component options
*/
var Component = (function () {
'use strict';
})();
Creating the Component
object
Next, we create a Component
object that will serve as the prototype for all of our components.
If no element selector (elem
) is provided, we’ll throw an error letting the developer know. Otherwise, we’ll assign the element selector, the template
, and any data
that was provided as properties of the component.
/**
* Create the Component object
* @param {String|Node} elem The element to make into a component
* @param {Object} options The component options
*/
var Component = function (elem, options) {
if (!elem) throw 'ComponentJS: You did not provide an element to make into a component.';
this.elem = elem;
this.data = options ? options.data : null;
this.template = options ? options.template : null;
};
Adding a sanitize method
It’s common for web apps to include user-provided data. Since this poses a risk for cross-site scripting attacks, the Component()
function includes a sanitize()
method that can be used to remove malicious code.
/**
* Sanitize and encode all HTML in a user-submitted string
* @param {String} str The user-submitted string
* @return {String} The sanitized string
*/
Component.sanitize = function (str) {
var temp = document.createElement('div');
temp.textContent = str;
return temp.innerHTML;
};
Rendering a component
Next, we’re going to assign a method to the prototype of our Component()
function.
Whenever we create a new component, it will refer to this original function instead of creating a new copy of it, reducing the load on our memory in the browser.
/**
* Render a template into the DOM
* @return {[type]} The element
*/
Component.prototype.render = function () {
// Make sure there's a template
if (!this.template) throw 'ComponentJS: No template was provided.';
// If elem is an element, use it.
// If it's a selector, get it.
var elem = typeof this.elem === 'string' ? document.querySelector(this.elem) : this.elem;
if (!elem) return;
// Get the template
var template = (typeof this.template === 'function' ? this.template(this.data) : this.template);
if (typeof template !== 'string') return;
// Render the template into the element
if (elem.innerHTML === template) return;
elem.innerHTML = template;
// Dispatch a render event
if (typeof window.CustomEvent === 'function') {
var event = new CustomEvent('render', {
bubbles: true
});
elem.dispatchEvent(event);
}
// Return the elem for use elsewhere
return elem;
};
In our render()
method, we’re going to make sure the component has a template
property. If not, we’ll throw an error.
If the elem
property is a string, we’ll use querySelector()
to get the element. If it’s a node, we’ll use that node directly.
Similarly, if the template
property is a string or number, we’ll use it outright. If it’s a function, we’ll run it to get a string (or number).
Next, we’ll run a check to make sure the existing innerHTML
of our elem
differs from the template
. If they’re the same, we won’t do anything since there’s nothing new to render. Otherwise, we’ll update the DOM.
Finally, we’ll dispatch a custom event that you can hook into with addEventListener()
, letting you trigger other actions whenever a piece of content is updated. Then we’ll return the elem
itself.
About custom events
When content is updated, you may need to reinitialize some JavaScript elsewhere in your code base or take additional actions.
The CustomEvent
API let’s you create a custom event type that you can listen for with addEventListener
. In the Component.render()
method, it’s dispatched on the element that was rendered, and bubbles so that you can use event delegation if you want.
document.addEventListener('render', function (event) {
if (event.target.matches('#app')) {
// Do something...
}
}, false);
An example
To make this all more tangible, let’s create a simple clock using a stateful component.
Here’s the HTML.
<div id="clock"></div>
Now, let’s create a component.
We’ll use the Date()
object and it’s toLocaleTimeString()
method to get the current time and assign it to the time
property in our state. Our template
will return The time is
followed by this property.
// Create a clock component
var clock = new Component('#clock', {
data: {
time: new Date().toLocaleTimeString()
},
template: function (props) {
return 'The time is ' + props.time;
}
});
Now we can render it into the DOM.
// Render the clock
clock.render();
Now we’ve got a simple app that shows you the time when you load the page. Here it is in action.
That’s a great start, but we want to update the clock in real time.
To make that happen, we’ll use the setInterval()
method to run a callback function every 1000
millseconds, or 1 second. In that function, we’ll update our state and render a fresh UI.
// Update the clock once a second
window.setInterval(function () {
clock.data.time = new Date().toLocaleTimeString();
clock.render();
}, 1000);
Here’s the finished clock app.
You could have done something like this instead.
// Get the clock element from the DOM
var clock = document.querySelector('#clock');
// Set the initial time
clock.innerHTML = new Date().toLocaleTimeString();
// Update the time once a second
window.setInterval(function () {
clock.innerHTML = new Date().toLocaleTimeString();
}, 1000);
And honestly, for really simple apps like this, that’s definitely the smarter and easier approach.
When you start building bigger web apps, though, that targeted manipulation becomes a lot harder to manage. Tomorrow, we’ll build a stopwatch app together, and see how building a fresh UI based on state can make maintaining a web app easier and simpler.