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.

This article originally appeared at my blog.

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 state
  • components: An internal map 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 page
  • removeComponent(): 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 from render() to the querySelector DOM element. Because of timing issues - the original page and its DOM needs to be fully visable - the contencte DOM element is determined at the moment the mount happens. Afterwards, a mount() 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 apply mount(), otherwise apply refresh() of these pages
  • destroy() - 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 from render() to the querySelector 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 variables
  • refresh(): 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.

IT Project Manager & Developer