Table of contents
So how exactly do React & Vue work? It can be invaluable to understand the internals of a system, which is why in this article I'll be explaining one method to create a basic reactive framework (in just 40 lines of code).
What are we making?
We'll be making a JavaScript framework with reactive properties, slots, attributes, and custom elements. Here's an example of a working component using our new framework:
<click-counter start="50">Try me:</click-counter>
The component definition for click-counter
will look like this (don't worry if it doesn't make total sense yet!):
Component definition
export const name = 'click-counter'
export function setup ({ start }) {
return {
count: parseInt(start)
}
}
export function render () {
return `
${this._slot}
<button id="minus"> - </button>
${this.count}
<button id="plus"> + </button>
`
}
export function run () {
this._find('#plus').onclick = () => this.count++
this._find('#minus').onclick = () => this.count--
}
Right, let's get started!
Native custom elements
React and Vue allow you to make use of components with custom tags such as <CustomComponent />
, and then these are compiled into regular HTML elements such as <div>
.
In this framework we'll make use of a simpler and tidier approach—custom elements.
Defining elements
Autonomous custom elements allow
you to use customised tags natively in HTML, after a short class definition in JavaScript.
Simply extend HTMLElement
then call customElement.define()
:
class CustomComponent extends HTMLElement {
// Optional hook
connectedCallback () {
this.innerHTML = "I'm valid!"
}
}
customElements.define('custom-component', CustomComponent)
Voilà, custom-component
is now a valid custom HTML element and can hold any kind of custom attribute! I've added an optional connectedCallback
hook here, and this runs when
an instance of the element is added to the DOM:
<custom-component></custom-component>
Starting out
Our framework will be contained within a single createComponent
function that defines a custom element:
export default function createComponent (Component) {
class ReactiveElement extends HTMLElement {
connectedCallback () {
// We'll put our code here
...
}
}
// Define custom element
customElements.define(Component.name, ReactiveElement)
}
An object will be passed to the function with a name
property that will be used to define the custom element's custom name.
Other files
To test our framework we'll also have another file where we define the current component, for now we'll just give it a name:
export const name = 'click-counter'
And a final file where we create the component and add it to the page:
import createComponent from './createComponent.js'
import * as ClickCounter from './CustomElement.js'
createComponent(ClickCounter)
document.body.innerHTML = `
<click-counter>I am a custom element</click-counter>
`
You can follow along and build the framework yourself in CodeSandbox:
Defining state & props
Each of our components will have a state, a series of (soon to be reactive) properties that can be manipulated for use within individual component
instances. For example, in our click-counter
element, the state will hold the current click count.
Props are attributes
We'll also be making use of props
, which are essentially just HTML attributes passed to the object. We can retrieve HTML attributes in this way:
<custom-element title="Hello" text="I am a prop"></custom-element>
const element = document.querySelector('custom-element'}
const props = {}
Array.from(element.attributes).forEach(
attr => props[attr.nodeName] = attr.nodeValue
)
// { title: 'Hello', text: 'I am a prop' }
console.log(props)
Setup hook
We'll use state within our components by exporting a setup
method, and attaching our variable to this
. Props will be made available in the
setup argument:
<click-counter start="50"></click-counter>
export const name = 'click-counter'
// Setup called once on creation
export function setup ({ start }) {
this.count = parseInt(start)
}
// Use state later
...
Assigned a property to this
will be roughly equivalent to setting a property in Vue data
, or creating a React useState()
variable.
Implementing setup
To implement this in createComponent
we'll first get the element's props, then create an empty state object, before calling
the setup
hook.
// Get element's props
const props = {}
Array.from(this.attributes).forEach(
attr => (props[attr.nodeName] = attr.nodeValue)
)
// State object
let state = {}
// Call setup method
Component.setup.call(state, props)
Component.set.call(state, props)
is used here instead of simply Component.setup(props)
because it allows us to
set state
as the context for this
.
Helper methods
We can also add a few helper methods, to make accessing the element (and it's children) easier. We'll create state
using Object.create
to pass some non-enumerable methods to its prototype:
// State object
let state = Object.create({
_elem: this,
_find: sel => this.querySelector(sel),
_slot: this.innerHTML
})
Helper method table
Method | Use | Example |
---|---|---|
_elem | Return the current HTMLElement | this._elem.style.color = 'red' |
_find | Shorthand for selecting child elements | const div = this._find('div') |
_slot | Get initial slot/children | this.text = 'Hi ' + this._slot |
We'll be making use of these after we've added some reactivity.
Putting it together
If we put it all together, our function is currently looking like this:
export default function CreateComponent (Component) {
class ReactiveElement extends HTMLElement {
connectedCallback () {
// Get element's props
const props = {}
Array.from(this.attributes).forEach(
attr => (props[attr.nodeName] = attr.nodeValue)
)
// State object, with helper methods
let state = Object.create({
_elem: this,
_find: sel => this.querySelector(sel),
_slot: this.innerHTML
})
// Call setup method
Component.setup.call(state, props)
}
}
// Define custom element
customElements.define(Component.name, ReactiveElement)
}
Rendering to the DOM
To render our component to the page, we'll make a render hook available:
export const name = 'click-counter'
export function setup ({ start }) {
this.count = parseInt(start)
}
// Render return value to page
export function render () {
return `
Current count: ${this.count}
`
}
<click-counter start="50"></click-counter>
For this, we'll use a simple function that simply places the result into the innerHTML
of the element, though we'll come to that in a minute.
Virtual DOM
React and Vue both use virtual DOMs, which are essentially abstractions of the DOM within JavaScript.
Building a virtual DOM would more than double the length of this guide, so we're skipping it for today! We're replacing the
entire innerHTML
instead, which will reset the internal DOM on every render.
Post-render hook
Because there's no virtual DOM, and the DOM won't keep it's state,
we need a way to affect the body (e.g., place event listeners) after rendering. To do this, we'll enable a final hook,
the run
hook.
export const name = 'click-counter'
export function setup ({ start }) {
this.count = parseInt(start)
}
export function render () {
return `
Current count: ${this.count}
`
}
// Run after each render
export function run () {
// Add event listeners, etc
...
}
Render function
The render function is very basic, it simply updates the component innerHTML
to the return value of render, calls run
and finishes.
Again, it uses call()
to pass state
as the context:
// Render to DOM
const renderElement = () => {
this.innerHTML = Component.render.call(state, props)
Component.run.call(state, props)
}
The code so far
Here's the entire function so far. Note that we're also calling the new render method at the end of connectedCallback
, to
display the initial render:
export default function CreateComponent (Component) {
class ReactiveElement extends HTMLElement {
connectedCallback () {
// Get element's props
const props = {}
Array.from(this.attributes).forEach(
attr => (props[attr.nodeName] = attr.nodeValue)
)
// State object, with helper methods
let state = Object.create({
_elem: this,
_find: sel => this.querySelector(sel),
_slot: this.innerHTML
})
// Render to DOM
const renderElement = () => {
this.innerHTML = Component.render.call(state, props)
Component.run.call(state, props)
}
// Run component
Component.setup.call(state, props)
renderElement()
}
}
// Define custom element
customElements.define(Component.name, ReactiveElement)
}
Adding reactivity
The final step in our framework is, of course, to add reactivity. There are a few different ways to do this,
React uses useState
, an object that creates a setter function, though I prefer Vue's (in my opinion!) more elegant method; Proxy
.
JavaScript Proxy
JavaScript Proxies work
by attaching a handler to a target. Certain handler functions are called at various times in the proxy's lifecycle, for example set
is
called when a proxy's property changes.
// The target of the proxy
const target = {
fruit: 'apple'
}
// Functions called by proxy, `set` is used to monitor property changes
const handler = {
// When a property is changed on `target`, this function is called
set: (obj, prop, value) => {
console.log(`${prop} changed to ${value}`)
}
}
// Create proxy
const proxy = new Proxy(target, handler)
// 'fruit changed to banana'
proxy.fruit = 'banana'
Following on from the example above, if you haven't used proxies before, you may be surprised to see this:
// 'apple'
console.log(proxy.fruit)
But didn't we change that to 'banana'
? Not quite. Handler functions override the default behaviour; 'banana'
was never actually set, and this must be accounted for:
set: (obj, prop, value) => {
console.log(`${prop} changed to ${value}`)
// Run default set behaviour, and return
return Reflect.set(obj, prop, value)
}
The Reflect object
provides a series of functions that emulate the default behaviour of certain parts of JavaScript. Reflect.set()
emulates setting a property, and
this is then returned to provide the default functionality.
Reactive state
We can add reactivity to our framework by creating a similar proxy method for our state object, which then calls the render function and updates the component's DOM.
Note that we're running renderElement()
after setting the object; the body shouldn't update until after the new property value updates.
// State instantiated above
// Add proxy to state and watch for changes
state = new Proxy(state, {
set: (obj, prop, value) => {
const result = Reflect.set(obj, prop, value)
renderElement()
return result
}
})
We can now test this out using a helper method from before:
export const name = 'click-counter'
export function setup ({ start }) {
this.count = parseInt(start)
}
export function render () {
return `
Current count: ${this.count}
`
}
export function run () {
// Testing reactive `count`
this._elem.onclick = () => this.count++
}
_elem
returns the current HTMLElement
object, and here we attach a simple event method to it.
<click-counter start="50"></click-counter>
Clicking on the element will now increment count
and reactively update the body!
Preventing loops
Currently, an infinite loop could occur quite easily within the run
hook, if the state updates synchronously (don't try this!):
// Runs after body update
export function run () {
// Forces body to update
this.count++
}
We need to modify our render method to prevent this occurring, and only allow asynchronous reactivity. Adding a quick if
statement
looks a little untidy, but will do the job:
// Render to DOM
let rendering = false
const renderElement = () => {
if (rendering === false) {
rendering = true
this.innerHTML = Component.render.call(state, props)
Component.run.call(state, props)
rendering = false
}
}
The problem has now been fixed:
export function run () {
// Synchronous, won't re-render
this.count++
// Asynchronous, will re-render
setTimeout(() => this.count++, 1000)
this._elem.onclick = () => this.count++
}
The final code
If we make our modifications to createComponent
one last time, it looks like this:
export default function CreateComponent (Component) {
class ReactiveElement extends HTMLElement {
connectedCallback () {
// Get element's props
const props = {}
Array.from(this.attributes).forEach(
attr => (props[attr.nodeName] = attr.nodeValue)
)
// Attach helper methods to state
let state = Object.create({
_elem: this,
_find: sel => this.querySelector(sel),
_slot: this.innerHTML
})
// Add proxy to state and watch for changes
state = new Proxy(state, {
set: (obj, prop, value) => {
const result = Reflect.set(obj, prop, value)
renderElement()
return result
}
})
// Render to body method
let rendering = false
const renderElement = () => {
if (rendering === false) {
rendering = true
this.innerHTML = Component.render.call(state, props)
Component.run.call(state, props)
rendering = false
}
}
// Run component
Component.setup.call(state, props)
renderElement()
}
}
// Define custom element
customElements.define(Component.name, ReactiveElement)
}
You can find the working code on CodeSandbox, along with a few examples:
Summary
Ta-da! A reactive JavaScript framework in just 40 lines of code (34, to be precise). I hope you've enjoyed reading, you can reach out to me on Twitter if you'd like to say hi, and I'll leave you with a few more component examples.
A few components
<live-clock>The time is</live-clock>
Definition
// Component name
export const name = 'live-clock'
const getDate = () => new Date().toLocaleTimeString()
// Runs once on creation
export function setup () {
this.time = getDate()
}
// Runs on body update
export function render () {
return `${this._slot} <strong>${this.time}</strong>`
}
// Runs after body update
export function run () {
setTimeout(() => this.time = getDate(), 1000)
}
<simple-counter></simple-counter>
Definition
// Component name
export const name = 'simple-counter'
// Runs once on creation
export function setup() {
this.count = 0
}
// Runs on body update
export function render() {
return `<button>${this.count || this._slot}</button>`
}
// Runs after body update
export function run() {
this._find('button').onclick = () => this.count++
}
<reactive-counter start="50"></reactive-counter>
Definition
export const name = 'click-counter'
export function setup ({ start }) {
this.count = parseInt(start)
}
export function render () {
return `
${this._slot}
<button id="minus"> - </button>
${this.count}
<button id="plus"> + </button>
`
}s
export function run () {
this._find('#plus').onclick = () => this.count++
this._find('#minus').onclick = () => this.count--
}
<country-flag country="ca"></country-flag>
<country-flag country="mk"></country-flag>
<country-flag country="sc"></country-flag>
Definition
// Component name
export const name = 'country-flag'
async function getCountry (code) {
return fetch('https://restcountries.eu/rest/v2/alpha/' + code).then(
response => response.json()
)
}
// Runs once on creation
export function setup ({ country = 'gb' }) {
getCountry(country).then(({ flag }) => this.flagUrl = flag)
this.count = 0
this.flagUrl = ''
}
// Runs on body update
export function render () {
const flag = this.flagUrl
? `<img src="${this.flagUrl}" />`
: 'no flag'
return `
<div>${flag}</div>
`
}
// Runs after body update
export function run () {}