SPAC: Designing Pages and Components
SPAC is a custom JavaScript framework for client-side, single-page web applications. It stands for "Stateful Pages, Actions and Components". Its design goal is to provide robust and simple entities that help you to structure apps. Pages and components provide the HTML, JavaScript functions and UI interactions. Actions govern external API calls. You define these entities in plain JavaScript, load up the central controller, and your app is ready to be served. Read the development journey of SPAC in my series: https://admantium.com/category/spac-framework/.
In this article, I explain the design rationale and highlight implementation details about pages and components.
Pages and Components are First Class Citizens
In a web app, components are bundled, standalone HTML and JavaScript functions. Components provide UI interactions: Selecting, focusing, key-events, input handling. They give visual feedback to the user, and they update the application state. As a developer, you focus on one component and need to define all of its aspects. Therefore, a component is a first class citizen.
Pages form the container of components. First of all, they provide a DOM structure with dedicated nodes to which components are attached. Second, pages contain the application state, and allow components to read and update this state. Components are added to pages. When a page unloads, it also unloads all of its components.
Essentials: Interfaces
All of the core classes of SPAC.js rely on the concept of interface: An "abstract" class that defines the methods of "concrete" classes. Upon creation of the concrete class, it is automatically verified that all required methods are implemented - if not, an error is thrown.
Let’s take a look at the implementation of this interface itself.
const Interface = (...methods) => {
return class AbstractInterface {
constructor () {
methods.forEach(methodName => {
if (!this[methodName]) {
const constructorName = this.constructor.name
throw new (class ClassCreationError extends Error {
message = `Class ${constructorName} requires method '${methodName}' to be implemented`
})()
}
})
}
}
}
- Line 1: Defines the
Interface
method, receiving an array of method names, that returns a named class expression - Line 3,4: The classes
constructor()
runs a check for each of the passed method names - Line 5: If the created class has no property of the method name, then...
- Line 6: ... save the newly created classes name
- Line 7: ... and throw a custom error, detailing which method name is missing from the class
This interface is a simple tool to verify that all of the main entities, which are controller, pages, components and interfaces, have all required methods.
Now, let’s see how the page entity is implemented.
Implementing Pages
Interface
The interface definition of a page is this:
class PageInterface extends Interface(
'render',
'mount',
'refresh',
'getState',
'updateState',
'addComponents'
) {}
These methods are detailed in the next sections.
Constructor
Pages have four important properties:
querySelector
: A query-string that will be resolved to determine where in the DOM the rendered HTML should be attached.state
: The overall application statecomponents
: An internalmap
of all registered components for this page.
These properties are set in the constructor.
class Page extends PageInterface {
constructor (querySelector) {
super()
if (!querySelector) {
throw new (class ClassCreationError extends Error {
message = `Class ${this.constructor.name} requires a valid 'querySelector' element`
})()
}
this.querySelector = querySelector
this.state = {}
this.components = new Map()
}
}
The constructor also checks that a valid querySelector
element is provided, and throws an error otherwise.
Managing Components
A page manages its components with these methods:
addComponent()
: Adds a new component to this pageremoveComponent()
: Destroys a registered component, and also triggers a re-rendering of the page
Components are not passed as objects, but they are requested from the controller. Here is the how too:
addComponents (...comps) {
comps.forEach(name => {
try {
const component = Controller.component(name)
component.updateState = this.updateState.bind(this)
component.getState = this.getState.bind(this)
this.components.set(component.name, component)
} catch (e) {
console.error(`Could not load ${component}`)
}
})
this.refresh()
}
Rendering a Page
When a page
instance is loaded from the controller, the following methods are used:
render()
: Renders the dynamic created HTML. SPAC relies on quoted templates in which you can attach e.g. variables contained in the state.mount()
: Attaches the output fromrender()
to thequerySelector
DOM element. Because of timing issues - the original page and its DOM needs to be fully visible - the concrete DOM element is determined at the moment the mount happens. Afterwards, amount()
to all registered components is send.
Once loaded, the following methods apply to handle the components life cycle:
refresh()
- Triggers a re-rendering of the page, for new components applymount()
, otherwise applyrefresh()
of these pagesdestroy()
- Destroy all registered components and associated JavaScript functions
The difference between mount()
and refresh()
are about a convention: mount()
should include all other JS that needs to be applied to the registered DOM, and refresh()
should only re-render the actual HTML, e.g. because of a new state, but not touching things like registering new event listeners.
Managing the State
The state is an object, it can contain any key-value pairs your application needs. There are two straightforward methods to use for managing the state:
updateState()
: Update the state of the page.getState()
: Reads the state of the page
The state itself is an object - you can store any key-value pairs that you need to store. SPAC does not provide any scoping for data stored inside the state, but you can invent your own schema, e.g. prefixing component-related data with the component name, like this: updateState( {myComponent: {searchQuery: 'API'}})
Implementing components
Component are the natural children objects of pages.
Interface
The interface definition of a component is this:
class ComponentInterface extends Interface(
'render',
'mount',
'refresh',
'getState',
'updateState'
) {}
Constructor
Components have only two properties:
querySelector
: The root DOM element to which they attach themselves.name
: The unique name of the component
Similar to a page, the querySelector
needs to be passed during initialization, or otherwise no instance will be created.
class Component extends ComponentInterface {
constructor (querySelector) {
super()
if (!querySelector) {
throw new (class ClassCreationError extends Error {
message = `Class ${this.constructor.name} requires a valid 'querySelector' element`
})()
}
this.querySelector = querySelector
this.name = this.constructor.name
}
}
Rendering HTML
Components offer the same methods to render their HTML content:
mount()
: Attaches the output fromrender()
to thequerySelector
DOM element, which is evaluated at runtime. Also, this method should define any additional JavaScript code, e.g. for handling events.render()
: Render the template string, which can include references to state variablesrefresh()
: Re-applies the output of render to its DOM element.
State Management
As discussed earlier, a component does not have state on its own. Instead, during initialization, the page injects its methods getState()
and updateState()
.
Conclusion
Pages and Components form the essential entities of single-page apps written with SPAC. This article detailed the design and implementation, highlighting the interface, the constructor, the generation of HTML and state management.