How to do loading spinners, the React way.

Note: This post walks you through how to build your own spinner management service from scratch. It is almost identical to what I have already built and packaged in the @chevtek/react-spinners package on npm. If all you want is access to the @chevtek/react-spinners package then you can find it here.

Recently I wrote a post about how to do loading spinners the Angular 2+ way, which itself was an updated version of a previous post for AngularJS (1.x). This is yet another iteration of this same tutorial but this time for React.

Often we have a simple scenario where we click a button to begin some operation and showing a loading graphic is easy and straightforward.

// some.component.jsx

export default SomeComponent () => {  
  componentWillMount() {
    this.doAThing = this.doAThing.bind(this);
    this.state = {
      loading: false
    };
  }

  doAThing() {
    this.setState({ loading: true });
    someService.doTheThing().then(() => {
      this.loading = false;
    };
  }

  return (
    <button onClick={this.doAThing()}>Do A Thing</button>
    { this.state.loading && <img src="path/to/animated/loading.gif" /> }
  );
};

In most cases the above scenario is adequate and quick to implement, but in large complex applications there are a lot of interactions going on. What if you want to show/hide a spinner in a separate component tree that isn't managed by this component? Do you really want to have to broadcast some event, create some type of message bus, or hoist your spinner state all the way up into your application state just to show/hide a spinner somewhere else in your app? What if you want to hide/show spinner(s) from within a service? What about hiding/showing multiple loading spinners? What if you want to hide every loading spinner on the page in the event of an error so as to prevent hanging loading graphics? I created this library so I could stop having to think through those increasingly complex scenarios so often.


The Component

To start we need to create a basic SpinnerComponent that returns a basic template and takes in a URL that points to a loading graphic.

// Spinner.component.jsx

import * as React from 'react';

export class SpinnerComponent extends React.Component {  
  render() {
    const { loadingImage } = this.props;
    return (
      <div style={{ display: 'inline-block' }}>
        <img src={loadingImage} />
      </div>
    );
  }
}

In the above snippet we create a basic React component that simply renders an inline-block div that wraps a simple image element. You may have noticed the line const { loadingImage } = this.props;. This is called object destructuring. You're free to read the documentation but I won't go into how it works here beyond simply saying that the line is identical to const loadingImage = this.props.loadingImage;

Because we eventually want this component to register itself with a SpinnerService that we'll create shortly, we expect props to contain a name property. This name will be used to identify the unique spinner instance in the SpinnerService's cache of SpinnerComponents. We also added a group property because the user should optionally be able to specify a group name so they can show/hide an entire group of spinners.

The name prop will be required because the SpinnerService API will expect all spinners to have names. We also want the user to be able to pass children into the SpinnerComponent instead of a loadingImage URL so we will make loadingImage optional as well.

The next thing we need to implement is our component's state. The only thing we need to track in our component's state is whether or not it should be shown. We will use the constructor to set up the component's initial state.

// Spinner.component.jsx

export class SpinnerComponent extends React.Component {

  constructor(props, context) {
    super(props, context);

    if (!this.props.name) {
      throw new Error('Spinner components must have a name prop.');
    }

    if (!this.props.loadingImage && !this.props.children) {
      throw new Error('Spinner components must have either a loadingImage prop or children to display.');
    }

    this.state = {
      show: this.props.hasOwnProperty('show') ? this.props.show : false
    };
  }

  render() {
    let divStyle = { display: 'inline-block' };
    if (this.state.show) {
      const { loadingImage } = this.props;
      return (
        <div style={divStyle}>
          { loadingImage && <img src={loadingImage} /> }
          { this.props.children }
        </div>
      );
    }
    return (<div style={divStyle}></div>);
  }
}

First we check if this.props has a show property and if so, copy it's value to our initial state. Next we check this.state.show in our render method and only render our spinner if it's true. Otherwise return an empty div while ensuring it has the same inline-block style so it doesn't cause any CSS surprises just because it's empty. You may have noticed we're also now checking if loadingImage has a value and only rendering the <img> tag if it does. Finally we also added a reference to { this.props.children } below our <img>. This is what allows the user to pass in child elements to our component. Whatever the user passes in will be rendered where we made that call.

<Spinner name="mySpinner">  
  <span>Loading...</span>
</Spinner>  

Before we move on I want to talk about state management. In react it is common practice to keep moving component state upwards in the component tree when managing multiple child components together. This is where spinner management gets a little cumbersome in larger apps. In a typical large app we'd need to manage an array or map of spinner states and modify that parent state from all over our app based on various actions. Some parent component would manage that state and delegate those changes down the component tree when it gets modified. In this situation we are building a flexible library; we're not shipping a complex parent component to manage all of our spinners. Instead we're going to implement a spinner service that manages all of our spinner's states. The problem here is that we don't have one big parent component to call this.setState(...) on to begin the update lifecycle for it and all child components. Take a look at this lifecycle diagram:

Notice that this.setState(...) is the trigger for updating components. If we were writing this for our own app we'd likely just delegate all of this to our redux store or some other smart parent component and all of our spinner components would be "dumb" components whose only task is to display the spinner based on that parent state.

I wanted this library to be simple and I don't want to have to ship some parent container component that the user needs to include and configure or wire into their store. That might work for a library like react-router because configuring one top-level component makes sense when you're defining routes for your app. I want things to just work out of the box with minimal or no configuration. After much consideration I decided that letting each spinner component handle it's own show state makes the most sense. Our service can then simply store references to the registered spinner components and call on them to update their own state as needed. This allows the user to very simply import and drop in a <Spinner /> anywhere in their app without any configuration. Then the user can import the spinner service anywhere else in their app and begin interacting with their spinners easily, no setup required.

We're almost done putting together our SpinnerComponent. All we need now is a way for the component to register itself with our SpinnerService. First we need to import spinnerService. We haven't written it yet but we'll pretend we have for now.

// Spinner.component.jsx

import { spinnerService } from './spinner.service';

export class SpinnerComponent extends React.Component {

  constructor(props, context) {
    super(props, context);

    this.state = {
      show: this.props.hasOwnProperty('show') ? this.props.show : false
    };

    if (this.props.hasOwnProperty('spinnerService')) {
      this.spinnerService = this.props.spinnerService;
    } else {
      this.spinnerService = spinnerService;
    }

    this.spinnerService._register(this);
  }

  componentWillUnmount() {
    this.spinnerService._unregister(this);
  }

  ...

}

We did several things here. First we imported an instance of spinnerService from ./spinner.service.js which we're going to write in a moment. Users of our library won't have to pass a spinner service to the spinner component because it will already import an instance created in the spinner service module and it will call _register on that instance. However, we do want to give the user control over how SpinnerService gets instantiated if they choose to so we also accept an instance of SpinnerService in our props for our component. If one is passed in this way it will override the embedded instance of SpinnerService.

Notice too that we added a componentWillUnmount lifecycle hook and we call _unregister on our SpinnerService. Whenever React unmounts this component it will now automatically remove itself from the spinner cache.

Before we move onto the SpinnerService we need to add a few methods to our class. We want the service to be able to read the name of our spinner as well as the group if one was specified on the spinner. It should also expose show and allow show to be set from the service. We could just have the service read from spinnerComponent.state and spinnerComponent.props accordingly but we really don't want the SpinnerService to have to know implementation details of SpinnerComponent in order to read the right data from the right location. To get around this we'll just expose some getters and setters on our component class.

// Spinner.component.jsx

export class SpinnerComponent extends React.Component {  
  ...

  get name() {
    return this.props.name;
  }

  get group() {
    return this.props.group;
  }

  get show() {
    return this.state.show;
  }

  set show(show) {
    this.setState({ show });
  }

  ...
}

The Service

We've seen some examples of how our SpinnerComponent itself expects to use our SpinnerService. You may have noticed that the register/unregister method names were prepended with underscores. This is because I want the user to have access to the register/unregister methods in case they want to tinker with the library but the methods are really just meant for the SpinnerComponent to register itself internally and aren't intended to be used by the user in most situations.

The service we want to write is so simple that I'm just going to list the full code file here and then discuss it a bit.

// spinner.component.js

export class SpinnerService {

  constructor() {
    this.spinnerCache = new Set();
  }

  _register(spinner) {
    this.spinnerCache.add(spinner);
  }

  _unregister(spinnerToRemove) {
    this.spinnerCache.forEach(spinner => {
      if (spinner === spinnerToRemove) {
        this.spinnerCache.delete(spinner);
      }
    });
  }

  _unregisterGroup(spinnerGroup) {
    this.spinnerCache.forEach(spinner => {
      if (spinner.group === spinnerGroup) {
        this.spinnerCache.delete(spinner);
      }
    });
  }

  _unregisterAll() {
    this.spinnerCache.clear();
  }

  show(spinnerName) {
    this.spinnerCache.forEach(spinner => {
      if (spinner.name === spinnerName) {
        spinner.show = true;
      }
    });
  }

  hide(spinnerName) {
    this.spinnerCache.forEach(spinner => {
      if (spinner.name === spinnerName) {
        spinner.show = false;
      }
    });
  }

  showGroup(spinnerGroup) {
    this.spinnerCache.forEach(spinner => {
      if (spinner.group === spinnerGroup) {
        spinner.show = true;
      }
    });
  }

  hideGroup(spinnerGroup) {
    this.spinnerCache.forEach(spinner => {
      if (spinner.group === spinnerGroup) {
        spinner.show = false;
      }
    });
  }

  showAll() {
    this.spinnerCache.forEach(spinner => spinner.show = true);
  }

  hideAll() {
    this.spinnerCache.forEach(spinner => spinner.show = false);
  }

  isShowing(spinnerName) {
    let showing;
    this.spinnerCache.forEach(spinner => {
      if (spinner.name === spinnerName) {
        showing = spinner.show;
      }
    });
    return showing;
  }
}

const spinnerService = new SpinnerService();  
export { spinnerService }  

Most of the above is hopefully fairly self-explanatory but lets go over a couple things. Firstly you might notice we declared a Set in the constructor: this.spinnerCache = new Set();. The cool thing about Sets is that they guarantee uniqueness. We can't accidentally add the same instance of SpinnerComponent twice. The rest of the service is quite simple. We expose hide/show methods for all spinners, groups of spinners, and individual spinners. We also exposed an isShowing method so the user can optionally query for the status of any individual spinner.

We also added some additional unregister methods for all spinners and groups of spinners just in case. I don't currently know if/when those would ever get used but they seem logical enough to have for those who like to tinker.

We not only export the SpinnerService class itself but at the bottom of the file we also export a spinnerService instance. This allows the user to simply import { spinnerService } from '@chevtek/react-spinners'; and immediately begin calling show/hide methods as needed. If they choose to create their own instance of SpinnerService they may do so by importing the class itself instead. I don't anticipate this being done much but some complex enterprise apps may have dependency injection in place and they may want to have more control over where that service gets instantiated.

Here is an example of our component and service in use with a not very practical toggle function. This is just an example. In large apps the spinnerService can be imported anywhere and used within other services. That is the ideal scenario for this library but there's no reason it can't also be used in simple scenarios like the one below, it just might be overkill in some cases.

import React from 'react';  
import { spinnerService, SpinnerComponent } from '@chevtek/react-spinners';

export default class App extends React.Component {  
  constructor(props, context) {
    super(props, context);
    this.toggleLoader = this.toggleLoader.bind(this);
  }

  toggleLoader() {
    if (spinnerService.isShowing('mySpinner')) {
      spinnerService.hide('mySpinner');
    } else {
      spinnerService.show('mySpinner');
    }
  }

  render() {
    return (
      <div>
        <button onClick={this.toggleLoader}>START LOADING</button>
        <br />
        <Spinner name="mySpinner" group="foo">
          <h1>Loading...</h1>
        </Spinner>
      </div>
    );
  }
}

Hopefully this has helped you think more clearly about how to implement flexible loading components that can scale to large apps with ease!

Chev

Read more posts by this author.

comments powered by Disqus