How to Add a Loading Indicator to Material Ui's <Button /> Component

January 28, 2020

Users are less likely to get frustrated with a website if they get feedback that "work" is being done. Hence the ubiquity of progress indicators on the web. This is actually the original inspiration for Node.js:

Dahl was inspired to create Node.js after seeing a file upload progress bar on Flickr. The browser did not know how much of the file had been uploaded and had to query the Web server. Dahl desired an easier way.

When a user submits a login form in the authentication flow it's desirable to give the user feedback that the request is in flight.

Material UI is an implementation of Google's material design in React.

Material UI has some fancy buttons. Let's make them even fancier by adding a spinner (progress indicator) that's activated when the user submits a login form and inactivated when authentication is complete.

OK, here's our button:

import React from 'react'
import Button from '@material-ui/core/Button'
const LoginButton = (props) => {
  const {
    children,
    loading,
    ...rest
  } = props
  return (
    <Button {...rest}>
      {children}
    </Button>
  )
}

Now let's add a progress indicator. Fortunately, Material UI has many such indicators to choose from.

import React from 'react'
import Button from '@material-ui/core/Button'
import CircularProgress from '@material-ui/core/CircularProgress'
const styles = {
  root: {
    marginLeft: 5
  }
}
const SpinnerAdornment = withStyles(styles)(props => (
  <CircularProgress
    className={props.classes.spinner}
    size={20}
  />
))
const AdornedButton = (props) => {
  const {
    children,
    loading,
    ...rest
  } = props
  return (
    <Button {...rest}>
      {children}
      {loading && <SpinnerAdornment {...rest} />}
    </Button>
  )
}

Now when authentication is in progress, we just need to set loading={true} and pass that as a prop to .

Here's an example of what our parent component might look like:

import React from 'react'
import TextField from '@material-ui/core/TextField'
import { withStyles } from '@material-ui/core/styles'
import { unstable_deferredUpdates as deferredUpdates } from 'react-dom'
const styles = theme => ({
  container: {
    position: 'absolute',
    width: '100%'
  },
  login: {
    boxShadow: '0 15px 35px rgba(50,50,93,.1), 0 5px 15px rgba(0,0,0,.07)',
    borderRadius: '4px',
    padding: '5vh',
    backgroundColor: '#fff',
    maxWidth: '340px',
    margin: '25vh auto',
    display: 'flex',
    flexDirection: 'column',
    justifyContent: 'space-between'
  },
  row: {
    display: 'flex',
    flexDirection: 'row',
    justifyContent: 'space-between'
  },
  button: {
    maxWidth: '200px',
    margin: theme.spacing.unit
  },
  gutter: {
    marginBottom: '1em'
  }
})
class LoginForm extends React.Component {
  state = {
    email: '',
    password: '',
    loading: false
  }
  onChange = (key, { currentTarget }) => {
    this.setState({
      [key]: currentTarget.value
    })
  }
  
  loginOrCreateAccount = () => {
    if (this.state.loading) return
    deferredUpdates(() => {
      this.setState({ loading: true })
    })
    this.props.createAccountSomehow(this.state)
      .then(() => {
        deferredUpdates(() => {
          this.setState(prevState => prevState.loading
            ? { loading: false } : null
        })
      })
  }
  render () {
    return (
      <div>
        <form className={classes.login}>
          <div className={classes.gutter}>
            <TextField
              id='email'
              label='Email'
              value={this.state.email}
              onChange={evt => this.onChange('email', evt)}
              autoComplete='current-email'
            />
            <TextField
              id='password'
              fullWidth
              value={this.state.password}
              onChange={evt => this.onChange('password', evt)}
              placeholder='Password'
              margin='normal'
              autoComplete='current-password'
            />
          </div>
          <div className={classes.row}>
            <AdornedButton
              className={classes.button}
              fullWidth
              loading={this.state.loading}
              variant='flat'
              color='secondary'
              id='submit-login'
              onClick={this.loginOrCreateAccount}
            >
              Sign in
            </AdornedButton>
            <AdornedButton
              className={classes.button}
              fullWidth
              loading={this.state.loading}
              variant='raised'
              color='primary'
              onClick={this.loginOrCreateAccount}
            >
              Create account
            </AdornedButton>
          </div>
        </form>
      </div>
    )
  }
}
export default withStyles(styles)(LoginForm)

We've used the AdornedButton from the second snippet twice: once for the login button and once for the create account button.

The overall strategy is straightforward: the user clicks one of two buttons (Login or Create Account), invoking this.loginOrCreateAccount(). Next, we toggle the loading state to true, authenticate the user, and toggle the loading the state back to false.

Such aggressive setState() calls in one class method really flogs the render cycle. It's not ideal.

Hence, we use unstable_deferredUpdates. unstable_deferredUpdates is an experimental feature React recently added. It's exported by the package react-dom starting with react-dom@16.4.2.

unstable_deferredUpdates accepts a function with a setState call. The intended use-case is a low priority update.

// Here's some annotated pseudo-code that may or may not be illuminating
class extends React.Component {
  ...
  loginOrCreateAccount = () => {
    // line below prevents concurrent requests
    if (this.state.loading) return
    // We set the loading state to true here.
    // This update is of course asynchronous.
    // I chose not to await the state change 
    // because it may be more performant to start 
    // authenticating the user at the same time.
    deferredUpdates(() => {
      this.setState({ loading: true })
    })
    this.props.createAccountSomehow(this.state)
      .then(() => {
        // Now we toggle the loading state back to false.
        // setState() accepts a function in addition to an object.
        // If a function is supplied, it is called with the most 
        // recent version of this.state. 
        // Returning null inside a functional setState call is 
        // an escape hatch that lets us avoid updating state.
        deferredUpdates(() => {
          this.setState(prevState => prevState.loading
            ? { loading: false } : null
        })
      })
  }
 }

Here's the result:

And an implementation of the examples above in the wild: https://github.com/unshift/contentkit/blob/master/src/containers/Login/LoginPage.js