Creating a declarative oscillator component with React hooks.

Front-end, React

Creating a declarative oscillator component with React hooks.

Geoffrey Dhuyvetters

Geoffrey Dhuyvetters

Most developers are familiar with the concept of imperative and declarative programming. 

Imperative programming is when you provide the program with the exact steps to achieve the output with an emphasis on how. 

Declarative programming is a coding style where you focus on what the piece should achieve with less emphasis on the actual implementation (how).

Suppose you want to create a piece of code that creates a new array of numbers multiplied by itself.
This is how the solution in an imperative style would look like:

const numbers = [1, 2, 3];

const results = []; // create an empty result array

for (let i = 0; i < numbers.length; i++) {
  // loop over all the original numbers
  const number = numbers[i]; // create a variable for each number
  const multiplied = number * number; // multiply it by itself

  results.push(multiplied); // add it to the results array
}

console.log(results); // log the results

And this is a declarative solution:

console.log([1, 2, 3].map(x => Math.pow(x, 2)));

// for each number in the array
// store the result of the function in a newly created array

As you can see, the declarative solution is a lot easier to read and reason about. It also leaves less room for errors since the implementation of the map function is handled by the language. A small note —  this statement does imply that you’re used to working in a declarative codebase. It might have a learning curve at first.

This is how you render a DOM element (div with some text) in an imperative way (one of the solutions, there are multiple):

const name = "Geoffrey"; // create name variable

const helloWorld = document.createElement("div"); // create DOM element
helloWorld.textContent = `Hello ${name}`; // add text content

const parent = document.querySelector(".app"); // query for parent to attach
parent.appendChild(helloWorld); // append new element at correct place

This creates the same result in a declarative style (using React):

import React from "react";
import ReactDOM from "react-dom";

const HelloWorld = ({ name }) => <div>Hello {name}</div>;

ReactDOM.render(
  <HelloWorld name={"Geoffrey"} />,
  document.querySelector(".app"),
);

Writing UI components in a declarative style is relatively easy with React, but how can we create declarative React components out of non-declarative browser APIs?

Let’s have a look at the Web Audio API. To create an oscillator, you need these steps:

const audioContext = new AudioContext(); // create AudioContext instance

const oscillator = audioContext.createOscillator(); // create oscillator from factory
oscillator.frequency.value = 300; // set frequency via AudioParam
oscillator.type = "sine"; // set oscillator type

oscillator.connect(audioContext.destination); // connect output to AudioContext destination
oscillator.start(); // start the oscillator

You can extract this into a factory function, but creating a reusable component would make the following possible:

import React from "react";
import ReactDOM from "react-dom";

import { createChord, Chord, noteToFrequency } from "music-fns";

import Oscillator from "./Oscillator";

ReactDOM.render(
  <>
    {createChord("B3", Chord.MAJOR)
       .map(frequency => (
         <Oscillator
            frequency={noteToFrequency(frequency)}
            type={"sine"}
            key={frequency}
         />
    ))}
  </>,
  document.querySelector(".app"), // React requires this
);

This piece of code would create a B major chord using the music-fns library I’ve been working on — very readable, very declarative.

A while ago I saw ‘Mixed Mode React’ by the infamous Ken Wheeler. This talk introduced the concept of creating components out of non-DOM related APIs and mixing them in projects. His talk really stuck with me but a part that felt very cumbersome was all the code located in the lifecycle methods. It makes it hard to share code between components. Back then (1 year ago, when the dinosaurs roamed the earth) classes were the only option if you wanted to use lifecycle methods, and this made it hard to share logic between components. The upcoming hooks feature introduces an entirely different way to approach this. Let’s create the component by using the technique Ken talked about while using hooks.

First, start with a functional component that doesn’t have a visual representation:

import React from "react";

export default () => {
  return null;
};

Rendering null is perfectly valid in React; we want our component to behave as a controller for our OscillatorNode.

On componentDidMount, we want to do the initial setup:

On componentWillUnmount, we want to do some clean up:

The useEffect hook replaces the componentDidMount, componentDidUpdate and componentWillUnmount lifecycles. We can ignore componentDidUpdate by passing an empty array as the second argument. Here’s the code:

import React, { useEffect } from "react";
const audioContext = new AudioContext();

export default ({ frequency = 130, type = "sine" } = {}) => {
  useEffect(() => {
    // replacement for componentDidMount

    const oscillator = audioContext.createOscillator();

    oscillator.frequency.value = frequency;
    oscillator.type = type;

    oscillator.start();
    oscillator.connect(audioContext.destination);

    return () => {
      // replacement for componentWillUnmount
      oscillator.stop();
      oscillator.disconnect();
    };
  }, []); // only trigger effect on componentDidMount and componentWillUnmount

  return null;
};

One issue we now have is that if we create 3 oscillators, we will have 3 AudioContext instances. One way to solve this is by using a React context to pass around a single AudioContext (a tad confusing, I must admit):

import React, { useEffect, useContext } from "react";
import context from "./context";

export default ({ frequency = 130, type = "sine" } = {}) => {
  const { audioContext } = useContext(context); // use React context hook

  useEffect(() => {
    const oscillator = audioContext.createOscillator();

    oscillator.frequency.value = frequency;
    oscillator.type = type;

    oscillator.start();
    oscillator.connect(audioContext.destination);

    return () => {
      oscillator.stop();
      oscillator.disconnect();
    };
  }, []);

  return null;
};

and context.js would look like this:

import { createContext } from "react";

const audioContext = new AudioContext();
const context = createContext({ audioContext });

export default context;

We can now create multiple elements, they will all share the same AudioContext instance. The only part we’re missing now is a mechanism to control the frequency of each Oscillator.

To make this work we have to keep the actual oscillator in the state of the component; we can use the useState hook for this. This hook returns an array that you can deconstruct as [ statefulValue, updateFunction ] and the value you provide to useState is the initial state value. In our example, we need to store the oscillator instance in the state:

import React, { useEffect, useContext, useState } from "react";
import context from "./context";

export default ({ frequency = 130, type = "sine" } = {}) => {
  const [oscillator, setOscillator] = useState(undefined); // setup state

  const { audioContext } = useContext(context);

  useEffect(() => {
    const oscillator = audioContext.createOscillator();

    oscillator.frequency.value = frequency;
    oscillator.type = type;

    oscillator.start();
    oscillator.connect(audioContext.destination);

    setOscillator(oscillator); // update state

    return () => {
      oscillator.stop();
      oscillator.disconnect();
    };
  }, []);

  return null;
};

The last thing to add is an effect that changes the oscillator frequency when the prop value changes:

import React, { useEffect, useContext, useState } from "react";
import context from "./context";

export default ({ frequency = 130, type = "sine" } = {}) => {
  const [oscillator, setOscillator] = useState(undefined);

  const { audioContext } = useContext(context);

  useEffect(() => {
    const oscillator = audioContext.createOscillator();

    oscillator.frequency.value = frequency;
    oscillator.type = type;

    oscillator.start();
    oscillator.connect(audioContext.destination);

    setOscillator(oscillator);

    return () => {
      oscillator.stop();
      oscillator.disconnect();
    };
  }, []);

  useEffect(
    () => {
        if (oscillator) {
          oscillator.frequency.value = frequency;
        }
    },
    [frequency],
  ); // only trigger this effect when frequency changes

  return null;
};

Now we can use our component:

import React from "react";
import ReactDOM from "react-dom";

import Oscillator from "./Oscillator";

const frequency = 500;

ReactDOM.render(
  <Oscillator frequency={frequency} type={"sine"} />,
  document.querySelector(".app"), // not strictly needed but React requires it.
);

This is just a small example using this technique. Imagine an on-screen piano rendered on the Canvas listening to Web MIDI input, generating sound via Web Audio, and visuals via WebGL. With everything powered by declarative React components using the same concepts we already know, the possibilities are endless.You can find the full example of this article here (further split up in reusable hooks) and a more complicated chord example here.

Hire Geoffrey as a speaker?

Contact us
Geoffrey Dhuyvetters

Geoffrey Dhuyvetters

This former teacher likes all things front end (the more complex web applications get, the happier Geoffrey becomes). His weak spot? JavaScript and all its quirks. Nor is he afraid to experiment a little with Node.js. Geoffrey used to be in a post-punk/noise rock band and a couple of one-off noise bands. In the process he started building a +1,000 record collection.