Delayed Cancelable Action Button in React

Guy coding

User experience (UX) is a fine art and many developers fail at it. Years ago I watched a basic UX course on PluralSight by Billy Hollis. Since that time I have tried to focus on UX. One thing I’ve learned over the years is how awful modal confirmations are for delete operations. Today we’ll look at another way to approach delete actions by introducing a delayed cancelable action button in React.

Code for today’s post is located on this codesandbox project.

Background

I’ve recently been creating a new “intranet” application that will eventually replace a WPF/WinForms hybrid rich client. This application, by necessity, will ultimately control all aspects of the business.

If there is one thing I’ve learned is how much users hate popups in general, but especially confirmations. “Of course I want to delete it, why else would I click the dang thing!?” is a phrase we frequently utter in frustration. For single items this isn’t terribly inconvenient but pretend you have a large list of items that will be modified.

There are multiple ways to skin that cat, of course, but we’re looking at singular items one-by-one. Each item I click after the previous my frustration level increases. By the end I want to throw the computer, smash the keyboard, spew curses upon the lives of the developer and their family, etc. Oh, that’s just me?

Our job as developers inevitably deals with UX. Maybe you backend guys not so much (though APIs… man… those are still a UX concern, trust me). The rest of us though we have a responsibility to the consumers of our applications. How do we resolve this click-frustration?

Proposed Solution

Well now, allow me to propose one potential solution. In the rich client we’re replacing we started switching delete buttons out to a button that would delay the desired action. Upon clicking it would change to a progress timer with a “cancel” button. You could let the time expire and fire the desired action or you could have an “oh crap!” moment and cancel that thing you didn’t mean to.

This subtle change has two effects:

  • 1) I can queue up multiple operations simultaneously
  • 2) The UX isn’t preventing me from completing tasks

Given this approach works so well in our rich client, we decided to carry the idea forward into our intranet application.

Getting Started

I’m pretending you have ran create-react-app already and that your application is running "react-scripts": "^2.0.3". This requirement is more so we can automatically include SCSS support than anything else. I’m using the Create React Sandbox template from codesandbox.io to generate my project. Let’s add the following packages to package.json:

  "dependencies": {
    "@fortawesome/fontawesome-svg-core": "1.2.14",
    "@fortawesome/free-brands-svg-icons": "5.7.1",
    "@fortawesome/free-regular-svg-icons": "5.7.1",
    "@fortawesome/free-solid-svg-icons": "5.7.1",
    "@fortawesome/react-fontawesome": "0.1.4",
    "bootstrap": "4.2.1",
    "debug": "4.1.1",
    "react": "16.8.0",
    "react-circular-progressbar": "1.0.0",
    "react-dom": "16.8.0",
    "react-scripts": "2.0.3"
  },

To explain. We’re going to use Bootstrap in my demo and I’m setting up some default classes on the button with it. Feel free to not install it. FontAwesome is how I’m getting icons onto the button. Again, feel free to not use it. Debug is a utility for writing out debug messages to the console. It works in nodeJS so if you were prerendering your app you might consider it. Again, feel free to not use it. React-Circular-ProgressBar, on the other hand, that’s how we’re going to get our progress magic. There are plenty of alternatives out there, that’s the one I chose for now.

Introducing the Button

Without further ado, how about we start creating some components. I added the components and utility folders to my sandbox. Inside the components folder we’ll start by creating the delayed-action-button.jsx file.

Whoa, I need to step aside for a second. I know that some people suggest React naming should be pascal case for the files. If I end up working somewhere that has that convention I’ll follow it but I have years of experience using other conventions that I’ll continue with thank you very much.

Ok, that aside, let’s stub out another file. We need progress-button.jsx. How about we stub out a couple SCSS files while we’re at it: _DelayedActionButton.scss and _ProgressButton.scss. Convention here is that the underscore marks this as a file that will never be referenced directly but is meant to be imported in another SCSS file.

ProgressButton

Button will end up similar to this
Button

Let’s start out with the button that has a progress bar on it. This control is essentially a container that has a button absolutely positioned in the center and a circular progress bar will render around the button. This button will be our “cancel” action in the next component but let’s not get ahead of ourselves. This button has no state of it’s own so we’ll make it a functional component. I’m going basic here, feel free to expand it a bit (like make the stroke color customizable, etc).

import React, { Component } from "react";
import PropTypes from "prop-types";

import CircularProgressbar from "react-circular-progressbar";

const ProgressButton = props => {
  return (
    <div className={`progress-button ${props.cssClass || ""}`}>
      <CircularProgressbar
        percentage={props.percentage}
        styles={{ path: { stroke: "#f88" } }}
      />
      <button
        type="button"
        className="btn bare text-danger"
        onClick={props.onClick}
      >
        {props.children}
      </button>
    </div>
  );
};

ProgressButton.propTypes = {
  onClick: PropTypes.func.isRequired,
  percentage: PropTypes.number
};

ProgressButton.defaultProps = {
  percentage: 0
};

export default ProgressButton;

I also want some default CSS to accompany this and said CSS will be linked into our index.scss. This file is called _ProgressButton.scss. Mileage may vary with the absolute positioning but per the demo, this one works. See below:

.progress-button {
  position: relative;

  > button {
    position: absolute;
    left: calc(50% - 21px);
    top: calc(50% - 19px);
  }
}

.custom-content-progress-bar {
  position: relative;
  width: 100%;
  height: 100%;

  .progress-container {
    position: absolute;
  }

  .content-container {
    position: absolute;
    height: 100%;
    width: 100%;
    display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: center;
  }
}

DelayedActionButton

We now have a button that will update it’s progress and allow us to click it. By itself, however, it really doesn’t do anything. Let’s go ahead and create another component to wrap it. This component is where our magic really happens.

import React, { Component } from "react";
import PropTypes from "prop-types";

import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import logger from "debug";

import ProgressButton from "./progress-button";
import { isNullOrUndefined } from "../utility/object-utils";

const classNamesProps = {
  button: PropTypes.string,
  cancelButton: PropTypes.string,
  spinner: PropTypes.string
};

const defaultClassNames = {
  button: "btn btn-primary",
  cancelButton: "btn btn-danger",
  spinner: null
};

const debug = logger("demo");
export class DelayedActionButton extends Component {
  static displayName = DelayedActionButton.name;

  constructor(props) {
    super(props);

    this.state = {
      isActionPending: false,
      percentage: 0
    };
  }

  initializeTimeout() {
    this._timer = setTimeout(() => {
      //debug("delay completed...");
      this.stopTimeout(true);
    }, this.props.delay);
    this._timeElapsed = 0;
    this._interval = setInterval(() => {
      this._timeElapsed += this.props.updateInterval;
      let percentage = Math.round(100 * (this._timeElapsed / this.props.delay));
      //debug(percentage);
      /*debug(
        "delay: %d, elapsed: %d, percentage: %d%%",
        this.props.delay,
        this._timeElapsed,
        percentage
      );*/
      this.setState({ percentage });
    }, 250);
  }

  stopTimeout(performAction = false) {
    this._timeElapsed = 0;
    if (!isNullOrUndefined(this._interval)) clearInterval(this._interval);

    if (!isNullOrUndefined(this._timer)) clearTimeout(this._timer);

    this._interval = null;
    this._timer = null;

    this.setState({ isActionPending: false, percentage: 0 }, () => {
      if (performAction) this.props.action();
    });
  }

  onButtonClicked(event) {
    event.preventDefault();
    event.stopPropagation();
    //debug("delayed action began...");

    this.setState({ isActionPending: true }, this.initializeTimeout.bind(this));
  }

  onCancelClicked(event) {
    //debug("delayed action canceled...");
    this.stopTimeout();
  }

  render() {
    const classNames = Object.assign(
      {},
      defaultClassNames,
      this.props.classNames
    );

    let contents = this.state.isActionPending ? (
      <div className="delayed-action-button">
        <ProgressButton
          percentage={this.state.percentage}
          onClick={this.onCancelClicked.bind(this)}
        >
          <FontAwesomeIcon icon="times-circle" />
        </ProgressButton>
      </div>
    ) : (
      <button
        type="button"
        className={classNames.button}
        onClick={this.onButtonClicked.bind(this)}
      >
        {this.props.children}
      </button>
    );

    return <React.Fragment>{contents}</React.Fragment>;
  }
}

DelayedActionButton.propTypes = {
  classNames: PropTypes.shape(classNamesProps),
  delay: PropTypes.number,
  action: PropTypes.func.isRequired,
  updateInterval: PropTypes.number
};

DelayedActionButton.defaultProps = {
  classNames: defaultClassNames,
  delay: 3000,
  updateInterval: 250
};

Ok, let’s go back and explain a little what we’re doing here. I have a few classNames that I want us to be able to define and some default values for them. Those are in classNamesProps and defaultClassNames respectively.

Next, we have a couple state values this button needs to track. It needs to know what percentage it is currently at (of it’s delay) and whether or not an action is even pending firing. Pretty basic stuff.

Let’s skip to the render() method and then come back for the meat. We will render either our unclicked button (by default) or our ProgressButton. We take in classNames overrides, if any, and apply them to our controls. Again, nothing really ground-breaking.

Magic starts to happen, however, once we click either button. Our “unclicked” state will set up a couple of timers. One is a timeout to represent our entire delay and the other is an interval to update the progress bar. I have found in my particular environment that 250ms works great for an update interval but codesandbox.io seems really “chunky” with pretty much any value I throw at it. Experiment freely but for our purposes today we’ll keep it at 250ms which is the default interval.

Let’s add some CSS to our _DelayedActionButton.scss file. This just ensures it remains the same size as the original button. Quite clearly this isn’t going to match all use-cases so it is good to start with a default but override in other situations as necessary.

.delayed-action-button {
  width: 30px;
  margin: 0 4px;
}

Index.js

I made a couple modifications in my demo application but the primary ones I don’t want to gloss over are that I swapped out index.scss for index.css, inserted import "../node_modules/bootstrap/dist/css/bootstrap.min.css";, and added import "./utility/fontawesome";. Let’s look at fontawesome.js.

// https://fontawesome.com/how-to-use/on-the-web/using-with/react
import { library } from "@fortawesome/fontawesome-svg-core";

import { fab } from "@fortawesome/free-brands-svg-icons";
import { far } from "@fortawesome/free-regular-svg-icons";
import { fas } from "@fortawesome/free-solid-svg-icons";

library.add(fab);
library.add(far);
library.add(fas);
//library.add(faAlicorn);

You by no means have to (and should probably not) add every icon library. Realistically you’d want to build up the library of the icons you are using so you can keep your package sizes down. I just went quick-and-dirty.

Other Files

You may at some point have noticed that our delayed-action-button.jsx file also references object-utils. I’ve previously included this in other blog posts. It just has some friendly utilities to check whether a reference is null or undefined as well as some to check an array or string value. I’m not going into details on it, just know it is there.

My demo also includes a demo-container.jsx and info-marquee.jsx. Both of those are simply for the demo purposes.

Demo

Without further ado, you can open the full codesandbox project or view just a demo window instead. The demo is pretty basic. It contains a single button styled with a trashcan icon from fontawesome. When you click the button it puts a status block up after it completes. Next, it waits a couple seconds and removes the status block.

A current implementation I’m working on looks something like this with the buttons:

Current implementation
Current implementation in a list view

Conclusion

We’ve looked at one way to address UX-related stress through the use of a delayed cancelable action button. This button allows the end-user to queue up actions without the burden of the GUI preventing them from completing tasks.

The component as presented is still pretty basic. There are probably use-cases where you want to know when the button was initially clicked or canceled. Consider those options to extend the button.

Remember that code for today’s post is located at codesandbox.

Credits

Photo by David Rangel on Unsplash