Skip to content

SPAC: Stateful Pages and Components

By Sebastian Günther

Posted in Javascript, Framework, Spac_framework

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/.

SPAC's state management follows a clear line: State is kept inside pages, and components always access the state of their enclosing page. This simplifies state propagation between components. This article is an in-depth review of the state management functions in SPAC.

Ongoing Example: ApiBlaze Index Page

To better understand the concepts of SPAC, we will use an example: ApiBlaze, a tool for searching open API specifications. Read more about its features in the ApiBlaze blog series.

When you start ApiBlaze, it will show a page with a search bar and a (yet not visible) popup. The page object is implemented as follows:

import { Page } from 'spac'

export default class IndexPage extends Page {
  render = () => {
    return `
      <h1>ApiBlaze Explorer</h1>
      <section class='api-search-page'>
          <div id='api-search-spec' class='api-search-spec'></div>
          <div id="api-search-results" class="api-search-results"></div>
      </section>
    `
  }
}

As you see, it defines two <div> elements to which the components will be attached. And how is this done?

Initial State & Adding Components

Both the state and the components are defined in the mount() method of the page.

import ApiSearchBarComponent from '../components/ApiSearchBarComponent.js'
import ApiSearchResultsComponent from '../components/ApiSearchResultsComponent.js'

export default class IndexPage extends Page {

  //...

  constructor (rootDom) {
    super(rootDom)
    this.state = { apiSearchQuery: '', apiSearchResults: []}
    this.addComponents(
      new ApiSearchBarComponent('#api-search-spec'),
      new ApiSearchResultsComponent('#api-search-results')
    )
  }

As you see in Line 10, we define the initial state as having two variables: apiSearchQuery and apiSearchResults. In line 11 and 12, we add the search bar and the search result component, passing to each the query selector at which it will output its HTML.

Injecting State Management

In these few lines of code, shared state is already implemented. When an instance of the page object is created, the constructor triggers the addComponents() method. This method fulfills the convention that any page object contains the state for all its components, and state updates on component objects are passed to the page. We do this by injecting state handling methods of the page object into the component instances.

addComponents (...comps) {
  comps.forEach(component => {
    component.updateState = this.updateState.bind(this)
    component.getState = this.getState.bind(this)
    this.components.set(component.name, component)
  })
}

Lets go through this code:

  • Line 3 and 4: Define the fields updateState and getState which reference the components methods with the same name. By using bind, method calls on the component are actually executed in the context of this page
  • Line 5: The new component is added to the page’s component list

Reading and Updating State

Every component of a page uses the very same methods: getState() and updateState(). These methods are implemented as follows:

  getState () {
    return this.state
  }

  updateState (newState) {
    this.state = { ...this.state, ...newState }
  }

A page’s state is a shared object, and each component has full access to the state. Therefore, when the following statements are executed ...

searchComponent.updateState({ apiSearchQuery: 'Kubernetes' })

resultComponent.updateState({
  apiSearchResult: {
    Kubernetes: {
      info: {
        title: 'Kubernetes',
        version: 'unversioned',
        description:
          'The core of Kubernetes control plane is the API server. The API server exposes an HTTP API that lets end users, different parts of your cluster, and external components communicate with one another.'
      }
    }
  }
})
page.indexPage.updateState({ page: 'Index' })

... the state would result in this object.

state: {
  apiSearchQuery: 'Kubernetes',
  apiSearchResult: {
    Kubernetes: {
      info: {
        title: 'Kubernetes',
        version: 'unversioned',
        description:
          'The core of Kubernetes control plane is the API server. The API server exposes an HTTP API that lets end users, different parts of your cluster, and external components communicate with one another.'
      }
    }
  },
  page: 'Index'
}

Coupling Component State

Because of the shared state, it is simple for a component to be dependent on another components state. In the dependent components render() method, you use the getState() to read the state. And with this method, you can read any variable of the state.

Following our example, let’s assume that the ApiSearchResultsComponent also prints the current search value. Here is the HTML:

class ApiSearchResultsComponent extends Component {
  render = () => {
    return `
    <p>You searched for ${this.getState().apiSearchQuery}
     <div id='api-elements-search-results'>
     </div>
    `
  }
}

The next time that ApiSearchResultsComponent is rendered, it reflects the updated state.

Page Re-Rendering Details

Whenever a pages' state changes, it calls refresh() and triggers a re-rendering of itself and all registered components. To make things easy, I do not implement a complex event handling system, but simply call refresh() on all components.

class Page extends PageInterface {
  updateState (newState) {
    this.state = { ...this.state, ...newState }
    this.refresh()
  }

  refresh () {
    this.components && this.components.forEach(obj => obj.refresh())
  }
}

At the time of writing, component refreshes completely overwrite the current DOM, its HTML, possible inline styles and event handlers. This is a known limitation, especially when compared to Reacts approach where the shadow DOM only replaces the real DOM when changes occur, however there are no current plans to change this.

Conclusion

This article explained the details of state management by following an example from ApiBlaze. State management in SPAC is simplified by a convention: Components do not hold state by themselves, but use methods for reading and updating the state that are injected by their enclosing page. State is shared between the page and all its components. And by default, any state change in the page triggers a refresh of the pages and components DOM.