Skip to content

SPAC: Promise-Based Actions

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

When implementing your single-page-apps, state and UI interactions happen inside the browser. But how do you organize complex functions that will search data, connect to external APIs and other similar tasks? You can ship them with the same code to the browser, and execute them in the browser. But you will reach the point where data complexity or functional costs become too high, and you need to implement these features in a backend. In SPAC, all complex functions and API interaction are handled by Action entities. Actions encapsulate pure functions through a standardized interface. When called, a Promise object is returned that can be consumed and further processed by the caller.

Action Interface

Action entities in SPAC provide these methods:

  • action(): The self-contained, pure function that is called in this action.
  • run(): The method to trigger an action instance, which receives arguments and passes them to the action

Action Definition

Actions are defined be creating a class with the parent Action. In its body, it just defines the action method.

Actions should be pure functions and loosely coupled, they should not use any externals data references, but should receive the data they operate on.

Here is an example for an action that will search for a keyword inside an object - the class is defined in Line 2, the function in Line 3.

import { Action } from 'spac'

export default class SearchApiAction extends Action {
  action (keyword, inventory) {
    var res = []
    for (let [name, definition] of Object.entries(inventory)) {
      const occurences = JSON.stringify(definition).match(
        new RegExp(keyword, 'ig')
      )
      const score = (occurences && occurences.length) || 0
      res.push({ name, score, definition })
    }
    return res.sort((a, b) => b.score - a.score)
  }
}

Running an Action

The run() method executes the defined action. It will return a new Promise object (Line 5), in which the defined action will be tried to be resolved (Line 7). All arguments passed to run will be passed to the action with apply. If there is an error, the action execution is rejected and an error is thrown (Line 10).

class Action extends Interface('action') {
  run (...args) {
    const className = this.name

    return new Promise((resolve, reject) => {
      try {
        resolve(this.action.apply(this, args))
      } catch (e) {
        reject(
          new (class ActionError extends Error {
            message = `Action ${className} failed`
          })()
        )
      }
    })
  }
}

When an action is called, the returned Promise object can be consumed by using the .then function and adding a callback, or the async/await keywords to resolve the action synchronously.

Example: Calling external APIs

In cases where you call external APIs, the action is a simple wrapper for the function call.

For example, in my app ApiBlaze, the frontend and backend communicate via web sockets. The action emits a web socket message, and it defines an event listener for the answer, which executes a callback function to process the result.

import { Action } from 'spac'
import websocket from '../globals/connect.js'

export default class SearchApiAction extends Action {
  action (keyword, cb) {
    websocket.emit('app:api-search-action', keyword)
    websocket.on('app:api-search-action', json => {
      cb(json)
    })
  }
}

The search is triggered on the index page when any key is pressed inside the search bar. The DOM event onkeyup is processed by the handleKeyUp. This handler calls a method (Line 4), which in turn calls the action (Line 9). The action call receives a callback function, which takes the action result to update the state. If there is in error instead, the state will not change, but a message is shown (Line 11).

handleKeyUp (e) {
  e.preventDefault()
  this.updateState({ apiSearchQuery: e.target.value })
  this.triggerSearch(e.target.value)
}

async triggerSearch (keyword) {
  try {
    new SearchApiAction().run(keyword, data => this.updateState({ apiList: data }))
  } catch (e) {
    console.error("Could not execute SearchApiAction")
  }
}

Conclusion

Actions enable pages and components to call functions that communicate with a backend server or an external API. Actions are objects with an action() method. When executed with run(), all arguments are passed, preserved in type and order, and a Promise object is returned. This Promise can be consumed by the called to receive the result and process them further.