What are Actions?

Actions are a JavaScript pattern unique to Pup. Though their name implies something special or "magical," actions are nothing more than a pattern. There's no library or package required to use them.

In your product, it's common to have a piece of code that involves a group of steps. For example, when signing up a new user you might:

  1. Create the user's account
  2. Create a trial subscription in your payments system
  3. Send the user a welcome email
  4. Add the user to your email marketing list
  5. Send a notification of the new user to Slack

Together, these five steps represent the action of signing up a new user.

An action, then, is a pattern for grouping together a set of related steps that get called together in sequence.

Steps can be dependent on or independent of the results of a previous step.

Why would I want to use Actions?

Actions help to solve a number of common problems:

  • They ensure your code stays organized and consistent.
  • They make implementing new features predictable for you and your team.
  • They make your code easier to debug if and when it fails.
  • They make your code easier to test.
  • They make your code easier to maintain with each step acting more like a car part (read: if your brakes don't work, replace the brakes, not the entire car).

Ultimately, actions give you long-term speed and more resilient code. You don't have to use them, but we highly recommend adopting the pattern.

That recommendation isn't arbitrary: this pattern was born out of frustration with existing options and has proven to be worth the investment repeatedly in both our open source work as well as our private work (internal applications and products like Hound).

How do I write actions?

There are two ways to write actions: with and without promises. The answer to "which to use" depends on the code you're writing and how it needs to behave. Actions without promises are outlined in this section, while usage with promises is explained here.

Here, we're going to outline the action pattern and explain the philosophy behind it. Reiterating, actions are nothing more than a group of related functions, for example, functions that work together to sign up a new user in our product.

More code, but more clarity

One of the first things folks notice about actions is that they add code. They do, but not without purpose. Because an action is a group of functions being called in sequence, we need a way to isolate each step in the sequence.

On the right, we can see a "skeleton" action with two steps (known as action methods). For each step, we define a function with the name of the step and inside, place a try/catch block. The try part of the block is where we run the step's code, while the catch is where we throw an error if that step fails.

Notice that the error has some structure to it. The error message is prepended with the string [actionName.actionMethodOne] or [actionName.actionMethodTwo]. The idea here is that if we call our action and it fails, these labels tell us exactly where the code failed. There's no need to guess. Even better, if we return this error to the client, our customers (or error logging service) can tell us, saving us the potential for hours wasted tracking down a bug.

Visualizing the sequence

After our action methods, at the bottom of our file we export a single function following the same pattern as our methods, however, omit a method name in favor of just having the action name in the error message. This function is the action caller. It's responsible for calling all of the action methods in sequence. It accepts a single options object as its argument.

Here, we can see that we want to call actionMethodOne and store its response in a variable and then call actionMethodTwo, passing the response from actionMethodOne as an argument.

This is the power of actions. You can clearly see how the calls inside of the action flow, what data each action method is dependent on, and where errors (if any) are occurring.

Ordering of action methods

One "quirk" of actions is the way that they're defined. The idea here is that because our action caller is defined at the bottom of our file and we're trying to maintain a sequence, action methods are stacked on top of each other in the order they're used. So, actionMethodOne is lower in the file than actionMethodTwo because it's called earlier in the sequence.

This makes writing and reviewing your code easy as each step is in plain sight as you review, reducing the need for excessive scrolling. It's important to note: you don't have to do this—it's just a recommendation based on what we've found to be helpful.

Validating inputs

Because an action has the potential to touch a lot of code and depend heavily on the quality of its inputs, the validateOptions method is essential. This is a simple function that validates the contents of the options object passed to the action. This ensures that before you perform any steps, you have access to the data you need/expect.

Example Action Without Promises
/* eslint-disable consistent-return */

const actionMethodTwo = (responseFromActionMethodOne) => {
  try {
    // Perform a single step in your action here.
  } catch (exception) {
    throw new Error(`[actionName.actionMethodTwo] ${exception.message}`);

const actionMethodOne = (someOption) => {
  try {
    // Perform a single step in your action here.
  } catch (exception) {
    throw new Error(`[actionName.actionMethodOne] ${exception.message}`);

const validateOptions = (options) => {
  try {
    if (!options) throw new Error('options object is required.');
    if (!options.someOption) throw new Error('options.someOption is required.');
  } catch (exception) {
    throw new Error(`[actionName.validateOptions] ${exception.message}`);

export default (options) => {
  try {
    const actionMethodOneResponse = actionMethodOne(options.someOption);
  } catch (exception) {
    throw new Error(`[actionName] ${exception.message}`);