Click here to Skip to main content
15,887,365 members
Articles / Web Development / React

Build a Metronome in React

Rate me:
Please Sign up or sign in to vote.
3.00/5 (2 votes)
22 Aug 2017CPOL6 min read 14.9K   1   1
How to build a Metronome in React

You know that feeling of excitement when you’re learning a new thing (like React)? It seems fun. The concepts make sense (some of them anyway). You can’t wait to dive in and start building something.

Unfortunately the next feeling is usually something like “Hmm… but what should I make?”

In this post, we’ll build a metronome. It will cover these topics:

  • Loading sounds [Webpack, JS]
  • Initializing state [React]
  • Arrow functions to bind class methods [JS, React]
  • Interval timers [JS]
  • Setting state, both with an object, and a function [React]
  • Doing a thing after state is set [React]
  • Input components + handling changes [React]

Create the App

We’ll use Create React App to initialize our project. Install it if you haven’t, and then at a command prompt, run:

$ create-react-app react-metronome

Once it finishes installing, cd into the directory and start it up:

$ cd react-metronome
$ npm start   # or yarn

Create the Metronome Component

The first thing we’ll do is replace the App component with our Metronome one. In index.js, just replace “App” with “Metronome” like this:

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import Metronome from './Metronome';
import registerServiceWorker from './registerServiceWorker';

ReactDOM.render(<Metronome/>, document.getElementById('root'));
registerServiceWorker();

And then, create two new files: src/Metronome.css, and src/Metronome.js.

Leave the CSS file empty for now, and start off Metronome.js with a barebones component to verify everything is working:

JavaScript
import React, { Component } from 'react';
import './Metronome.css';

class Metronome extends Component {
  render() {
    return (
      <div className="metronome">
        hi
      </div>
    );
  }
}

export default Metronome;

If it’s all working, the app should auto-refresh and you should see “hi”. With that in place, let’s add some UI components.

Render the Metronome UI

I like to take little incremental steps as I build out an app. That way, I can always hit Save and see the app work, and if it’s broken, I know what I changed, so I can go back and fix it.

Here’s the next little step: render the metronome’s BPM (beats per minute) slider, and a button, with some static data.

JavaScript
import React, { Component } from 'react';
import './Metronome.css';

class Metronome extends Component {
  render() {
    let bpm = 100;
    let playing = false;

    return (
      <div className="metronome">
        <div className="bpm-slider">
        <div>{bpm} BPM</div>
          <input
            type="range"
            min="60"
            max="240"
            value={bpm} />
        </div>
        <button>
          {playing ? 'Stop' : 'Start'}
        </button>
      </div>
    );
  }
}

export default Metronome;

Then open up Metronome.css and add a little styling to make it look better:

CSS
.metronome {
  text-align: center;
  max-width: 375px;
  margin: 0 auto;
  padding: 30px;
}

.bpm-slider input {
  width: 100%;
  margin: 10px;
}

.metronome button {
  background: #C94D46;
  padding: 10px;
  border: 1px solid #832420;
  border-radius: 2px;
  width: 100px;
  color: #fff;
  font-size: 18px;
}

It should look like this:

—— metronome UI screenshot

You won’t be able to change anything yet, because we didn’t implement the onChange handler for the input control. It’s stuck at 100 (the value={bpm}).

Initialize the State

The metronome’s BPM and its state (playing or not) are good candidates to put in React’s state, so we’ll initialize state in the constructor and use those variables in the render function:

JavaScript
class Metronome extends Component {
  constructor(props) {
    super(props);

    this.state = {
      playing: false,
      count: 0,
      bpm: 100,
      beatsPerMeasure: 4
    };
  }

  render() {
    const { playing, bpm } = this.state;

    return (
      <div className="metronome">
        <div className="bpm-slider">
          <div>{bpm} BPM</div>
          <input
            type="range"
            min="60"
            max="240"
            value={bpm} />
        </div>
        <button>
          {playing ? 'Stop' : 'Start'}
        </button>
      </div>
    );
  }
}

Even though we’ve introduced state, the app is never changing that state, so none of the controls will work yet. But it should still render with no errors. One change at a time. Little steps!

Update the BPM

To make the slider work, we’ll add a handler function to the class, and pass it as the onChange prop to the input, like this:

JavaScript
class Metronome extends Component {
  ...

  handleBpmChange = event => {
    const bpm = event.target.value;
    this.setState({ bpm });
  }

  render() {
    const { playing, bpm } = this.state;

    return (
      <div className="metronome">
        <div className="bpm-slider">
          <div>{bpm} BPM</div>
          <input
            type="range"
            min="60"
            max="240"
            value={bpm}
            onChange={this.handleBpmChange} />
        </div>
        <button>
          {playing ? 'Stop' : 'Start'}
        </button>
      </div>
    );
  }
}

Now you should be able to drag the slider and watch the BPM change.

Arrow Functions and this

Did you notice that the handler function is declared as an arrow function instead of a plain one? The reason for using an arrow function is that this will be automatically bound to refer to the Metronome instance, and everything will work nicely.

If we’d used a regular function like handleBpmChange() { ... }, then the this binding would be lost when it gets passed to the onChange handler in render.

Chalk it up to an annoying quirk of JavaScript: when you call a function as this.foo(), referring to this inside foo will do what you expect. But if you merely refer to a function as this.foo (without calling it), then the value of this gets lost.

Since event handler functions (like handleBpmChange) are almost always passed around by reference, it’s important to declare them as arrow functions. You can also bind them in the constructor, but it’s a bit more hassle, and one more thing to forget, so I like to use the arrow functions.

Loading the Audio Files

Let’s work on getting the “clicks” playing. First, we need to import some sounds, and Webpack can do this for us by adding a couple import statements at the top:

JavaScript
import click1 from './click1.wav';
import click2 from './click2.wav';

TODO: Add click sounds to download.

Then in the constructor, we will create two Audio objects with those files, which we’ll then be able to trigger.

JavaScript
constructor(props) {
  super(props);

  this.state = {
    playing: false,
    count: 0,
    bpm: 100,
    beatsPerMeasure: 4
  };

  // Create Audio objects with the files Webpack loaded,
  // and we'll play them later.
  this.click1 = new Audio(click1);
  this.click2 = new Audio(click2);
}

Testing Audio Playback

I don’t know about you, but I’m itching to hear something! Before we get into starting/stopping a timer, let’s just make sure it works.

Add a startStop function to play a sound, and wire it up to call it from the button’s onClick handler:

JavaScript
startStop = () => {
 this.click1.play();
}

render() {
  const { playing, bpm } = this.state;

  return (
    <div className="metronome">
      <div className="bpm-slider">
        <div>{bpm} BPM</div>
        <input
          type="range"
          min="60"
          max="240"
          value={bpm}
          onChange={this.handleBpmChange} />
      </div>
      {/* Add the onClick handler: */}
      <button onClick={this.startStop}>
        {playing ? 'Stop' : 'Start'}
      </button>
    </div>
  );
}

Click the button a few times. It should play a “click”.

Starting and Stopping

Now let’s get the timer working, so this thing can actually play a beat. Here’s the code:

JavaScript
startStop = () => {
  if(this.state.playing) {
    // Stop the timer
    clearInterval(this.timer);
    this.setState({
      playing: false
    });
  } else {
    // Start a timer with the current BPM
    this.timer = setInterval(this.playClick, (60 / this.state.bpm) * 1000);
    this.setState({
      count: 0,
      playing: true
      // Play a click "immediately" (after setState finishes)
    }, this.playClick);
  }
}

How this works is:

  • If the metronome is playing, stop it: clear the timer, and set the playing state to false. This will cause the app to re-render, and the button will say “Start” again.
  • If the metronome is not playing, start a timer that plays a click every few milliseconds, depending on the bpm.
  • If you’ve used a metronome before, you know how the first beat is usually a distinctive sound (“TICK tock tock tock”). We’ll use count to keep track of which beat we’re on, incrementing it with each “click”, so we need to reset it here.
  • Calling setInterval will schedule the first “click” to be one beat in the future, and it’d be nice if the metronome started clicking immediately, so the second argument to setState takes care of this. Once the state is set, it will play one click.

You’ll notice this doesn’t play a sound, but rather calls out to this.playClick which we haven’t written yet. Here it is:

JavaScript
playClick = () => {
  const { count, beatsPerMeasure } = this.state;

  // The first beat will have a different sound than the others
  if(count % beatsPerMeasure === 0) {
    this.click2.play();
  } else {
    this.click1.play();
  }

  // Keep track of which beat we're on
  this.setState(state => ({
    count: (state.count + 1) % state.beatsPerMeasure
  }));
}

With those functions in place, the metronome should work! Click “Start” and listen to it click away at 100 BPM.

You can change the tempo, but you’ll have to stop and start the metronome to make the change take effect. Let’s fix that.

Handling BPM Changes

As the user changes the BPM, we can restart the metronome with the new tempo. Update the handleBpmChange function to this:

JavaScript
handleBpmChange = event => {
  const bpm = event.target.value;

  if(this.state.playing) {
    // Stop the old timer and start a new one
    clearInterval(this.timer);
    this.timer = setInterval(this.playClick, (60 / bpm) * 1000);

    // Set the new BPM, and reset the beat counter
    this.setState({
      count: 0,
      bpm
    });
  } else {
    // Otherwise just update the BPM
    this.setState({ bpm });
  }
}

The “else” case here, when the metronome isn’t playing, just updates the BPM. Simple.

If the metronome is playing though, we need to stop it, create a new timer, and reset the count so it starts over. We’re not playing the initial “click” here, immediately after the BPM is changed, because otherwise we’ll get a string of “clicks” as the user drags the BPM slider around.

Improvements?

The metronome works now! Is it perfect? Gig-ready? Well probably not.

If you have a good sense of time, you may notice that this metronome doesn’t. The beats are a little bit off, and inconsistent. The browser’s sense of time with setInterval is not perfect.

Getting the timing rock solid is a bit more work. See this project for an idea of how to do it. I didn’t want to go into that level of detail in this post – this is a React article after all, not a tour of the Web Audio API. :)

Get the Code

Hopefully, you were able to follow along and build this yourself. You won’t learn a heck of a lot by git cloneing example projects and running them, so I encourage you to actually type it out and experiment on your own!

If you really just want to download the code though, I won’t stand in your way. But I might glare at you a little.

What’s Next?

If you enjoy this step-by-step style, I wrote a whole book like this. :) It goes through all the important concepts in React, and comes with exercises to make the knowledge stick. You can get a couple sample chapters and learn more here.

Build a Metronome in React was originally published by Dave Ceddia at Dave Ceddia on August 21, 2017.

This article was originally posted at https://daveceddia.com/feed.xml

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
United States United States
Dave is a Software Engineer in the Boston area and writes about AngularJS and other JavaScript things over at daveceddia.com

Comments and Discussions

 
QuestionYou need to include a screenshot of what it looks like Pin
Sacha Barber23-Aug-17 4:02
Sacha Barber23-Aug-17 4:02 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.