2019-07-14

Turning imperative code into declarative using React

1108 words - 4 minutes

With React’s lifecycle hooks many pieces of imperative code can be turned into a safer, declarative version, where most complexity is contained inside a React component.

Declarative? What’s that?

React is most commonly used to build interfaces on the web using declarative HTML-like syntax. Declarative here means telling the library what we want the end result to be rather than what we want the computer to strictly do.

// Declarative renderer function
function Declarative() {
  const [count, setCount] = useState(0);
  return <b onClick={() => setCount(count + 1)}>{count}</b>;
}

// Imperative
var count = 0;
function increment() {
  document.querySelector("#counter").innerHTML = ++count;
}

<b id="counter" onClick="javascript:increment()">0</b>

Declarative code means that the library has to do additional calculations to determine what to edit in the DOM. On the other hand we always get a more deterministic visual representation in return for the small performance hit in calculating the DOM diff. If done correctly, the UI will match our state. In imperative UI code this guarantee is significantly harder to achieve, because we need to always remember to update every place where our state is used one by one and manually.

Sometimes imperative is needed

For instance when using libraries, imperative code cannot always be avoided. Let’s take a look at how Spotify’s Web Playback SDK works for pausing, resuming and listening for a status of a track.

const spotify = new Spotify.Player(...);

spotify.addListener("player_state_changed",
  ({ track_window: { current_track } }) => {
    console.log("Currently playing: ", current_track);
  });
spotify.pause().then(() => console.log("Paused"));
spotify.resume().then(() => console.log("Resumed"));

As we can see the Spotify Playback SDK is highly asynchronous, event based and imperative by nature. What if we wanted to implement a small player UI where you can play and pause the track and importantly see the current state of track? Can we implement all this in a safe and declarative manner or at least hide all the complexity of dealing with asynchronity?

Lifecycles to the rescue

We can take advantage of React’s lifecycle hooks and their strong support for immutable state updates to create our own small diffing algorithm.

Effect hooks included with React hooks are excellent for turning imperative code to declarative.

useEffect(() => { ... }, []) is equivalent to
componentDidMount() { ... }.

useEffect(() => () => { ... }, []) is equivalent to
componentWillUnmount() { ... }.

useEffect(() => { ... }, [myProp]) is equivalent to

componentDidUpdate(prevProps) {
  if (prevProps.myProp !== myProp) {
    ...
  }
}

By using these three different hooks, we can deal with all diffing operations that happen in React with imperative code! Here’s a lightweight example of a player that fully synchronizes with Spotify player’s playing state and one-way synchronizes with the volume.

const spotify = new Spotify.Player(...);

// Null component to sync our Player with the Spotify player
function SpotifyConnector({volume, onStopped}) {
  // Called on mount and unmount
  // This is communication from Spotify -> our Player
  useEffect(() => {
    const listener = ({ track_window: { current_track } }) => {
      // boolean indicating whether current_track exists
      const isPlaying = !!current_track;
      
      if (!isPlaying) {
        // Inform parent if the track is paused or ends
        onStopped();
      }
    };
    
    // Track must play when this component is mounted
    spotify.resume();
    spotify.addListener("player_state_changed", listener);
    return () => {
      // And track must be paused when this component is unmounted
      spotify.pause();
      spotify.removeListener("player_state_changed", listener);
    };
  }, []);
  
  // Always called on mount and when "volume" prop changes
  // This is communication from our Player -> Spotify
  useEffect(() => {
    spotify.setVolume(volume);
  }, [volume]);
  
  return null;
}

// Stateful player
function Player() {
  const [muted, setMuted] = useState(false);
  const [playing, setPlaying] = useState(false);
  
  return (
    <>
      {playing &&
        <SpotifyConnector
          volume={muted ? 0 : 1}
          onStopped={() => setPlaying(false)}
        />}
      <button onClick={() => setMuted(!muted)}>
        {muted ? "Unmute" : "Mute"}
      </button>
      <button onClick={() => setPlaying(!playing)}>
        {playing ? "Pause" : "Resume"}
      </button>
    </>
  );
}

Creating a new null component just for handling state between us and Spotify encloses all complexity within that component. Additionally due to the pure nature of the component we can control the underlying state simply by passing the state we want (declarativeness!). This helps get rid of data races caused by the asynchronous nature of the Spotify player, because all state changes in the underlying Spotify player are bubbled up using the listener we registered on the player.