Have you ever looked at a piece of code and thought:
I think this works, but I can‘t quite convince myself it‘s correct?
The code‘s logic is relatively straightforward—but the state is smeared into a handful of boolean flags and variables that could be combined in ways that don‘t make sense.
And you find yourself mentally tracing through every code path to make sure none of the should-be-impossible combinations actually happen.
This is pretty normal, because most of the code today is written like that.
It‘s also what we‘re going to tackle in this series, with concrete techniques you can apply today, in your existing TypeScript codebase.
In this series, we‘ll take a small but realistic application—a web audio player—and refactor it step by step from an imperative to a functional style. Along the way, you‘ll learn how to make illegal states unrepresentable using algebraic data types, how to push side effects to the boundary of your application, and how to use reducers and pure functions to make state transitions explicit and testable.
We‘re going to start with code that looks perfectly modern-React-normal — and then bring the hidden state and effect to the surface.
Why an Audio Player?
Mostly to keep the mental workload small: You already have a good mental model of how an audio player should work, and whatever we‘re going to add to it, it‘s still going to be relatively simple.
These properties make it a good example application for learning the techniques, and not the domain, of this tutorial.
Also, it‘s still a barebones useful thing, and not the, say, standard counter example.
That said, it‘s also not the best application for showcasing the techniques used here. Since it‘s a small example, it‘s pretty easy to convince yourself you don‘t actually need this (which might be true here), and that this is too much additional stuff to learn for too little gain.
If that‘s you, you might especially profit from bearing with me here: The comparative value of functional programming techniques to OO gets bigger the less you understand your domain at first, and the more state you need to hold in your head while writing the code.
That‘s why this is a not-so-good example, too: I‘m pretty sure you both understand the audio player right now and right away, and are able to hold its hidden state in your head.
Approach
So we‘re starting out with writing the player in ‚normal‘, hidden-state SolidJS style.
Then, we‘re going to take a look at hidden states, make them explicit, and transform the whole codebase to a more functional style.
After this, we show avenues for improvement: When and where to utilize functional data structures, more precisely typed records, and so on.
Then we show how the pattern we‘re using easily generalizes to all the parts that have hidden state that isn‘t yet captured in code.
Imperative Audio Player
For now, let‘s start with the example application.
We want to build an audio player for the web.
Code
Let‘s start with the barebones component:
import { createSignal, For, Show } from 'solid-js'
export const ImperativeAudioPlayer = () => {
return (
<div style=>
<h2>Imperative Audio Player</h2>
<For each={tracks}>
{(track) => <button onClick={() => playTrack(track)}>Play {track.title}</button>}
</For>
<div style=margin-top>
<Show when={currentTrack()} fallback={<p>No track selected</p>}>
<h3>Now: {currentTrack().title}</h3>
<Show when={isLoading()}>
<p>Loading...</p>
</Show>
<p>Time: {currentTime().toFixed(2)}s</p>
<button onClick={togglePlay}>{isPlaying() ? 'Pause' : 'Resume'}</button>
</Show>
</div>
</div>
)
}
Add in the signals:
const [isPlaying, setIsPlaying] = createSignal(false)
const [isLoading, setIsLoading] = createSignal(false)
const [currentTrack, setCurrentTrack] = createSignal<any>(null)
const [currentTime, setCurrentTime] = createSignal(0)
The audio element:
const audio = new Audio()
The callback functions for the elements:
const playTrack = (track: any) => {
setCurrentTrack(track)
setIsLoading(true)
setIsPlaying(false)
audio.src = track.url
audio.play().catch((err) => {
console.error('Playback failed', err)
setIsPlaying(false)
})
}
const togglePlay = () => {
if (audio.paused) {
audio.play()
setIsPlaying(true)
} else {
audio.pause()
setIsPlaying(false)
}
}
Now, there‘s only two things left:
- Initializing the audio element with the correct callbacks:
onMount(() => {
audio.oncanplay = () => {
setIsLoading(false)
setIsPlaying(true)
}
audio.ontimeupdate = () => {
setCurrentTime(audio.currentTime)
}
})
And actually adding in tracks:
const tracks = [
{
id: '1',
title: 'Lo-Fi Beats',
url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3',
},
{
id: '2',
title: 'Synthwave',
url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-2.mp3',
},
]
This should complete to something like this:
import { createSignal, onMount, For, Show } from 'solid-js'
export const ImperativeAudioPlayer = () => {
const [isPlaying, setIsPlaying] = createSignal(false)
const [isLoading, setIsLoading] = createSignal(false)
const [currentTrack, setCurrentTrack] = createSignal<any>(null)
const [currentTime, setCurrentTime] = createSignal(0)
const audio = new Audio()
const playTrack = (track: any) => {
setCurrentTrack(track)
setIsLoading(true)
setIsPlaying(false)
audio.src = track.url
audio.play().catch((err) => {
console.error('Playback failed', err)
setIsPlaying(false)
})
}
const togglePlay = () => {
if (audio.paused) {
audio.play()
setIsPlaying(true)
} else {
audio.pause()
setIsPlaying(false)
}
}
onMount(() => {
audio.oncanplay = () => {
setIsLoading(false)
setIsPlaying(true)
}
audio.ontimeupdate = () => {
setCurrentTime(audio.currentTime)
}
})
const tracks = [
{
id: '1',
title: 'Lo-Fi Beats',
url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3',
},
{
id: '2',
title: 'Synthwave',
url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-2.mp3',
},
]
return (
<div style=>
<h2>Imperative Audio Player</h2>
<For each={tracks}>
{(track) => <button onClick={() => playTrack(track)}>Play {track.title}</button>}
</For>
<div style=margin-top>
<Show when={currentTrack()} fallback={<p>No track selected</p>}>
<h3>Now: {currentTrack().title}</h3>
<Show when={isLoading()}>
<p>Loading...</p>
</Show>
<p>Time: {currentTime().toFixed(2)}s</p>
<button onClick={togglePlay}>{isPlaying() ? 'Pause' : 'Resume'}</button>
</Show>
</div>
</div>
)
}
Analysis: Room for Improvement
So, I don‘t know about you, but to me, this looks fine?
I think I can hold all the state there is in this example in my head at the same time, and check that I‘m not missing any edge cases.
But the problem is, I also pretty regularly think that and then get absorbed into a longer debugging session that stemmed from me not being able to hold it in my head at the same time.
So even if we both think right now that this probably is fine, let‘s go through the exercise anyway.
There‘s a few things that come to mind.
1. Making Illegal States Unrepresentable
Right now, isPlaying, isLoading and currentTrack are independent values. I think there‘s no mistake in there, but there‘s nothing keeping us from making a mistake, and having the system arrive at a state like {isPlaying: true, isLoading: true, *}, which doesn‘t make any sense (and therefore should be illegal). This is a mistake in our modeling.
In other words: The number of currently legal states in our program is something like 2 (playing) x 2 (loading) x 3 (<number of tracks> and null) x (many + 1) (<timestamps in a song> | null), which is more than what the actually sensible number of states in our program is.
So let‘s model this in accordance with what makes sense in our domain. Per song, we have:
- An initial state: When the song‘s not playing, and hasn‘t started loading yet. (Corresponding to
{isPlaying: false, isLoading: false, currentTrack: null, currentTime: null})
- A
Loading state: We have a track selected, but it hasn‘t loaded yet. (Corresponding to {isPlaying: false, isLoading: true, currentTrack: someTrack, currentTime: null})
- A
Playing state: When the song is done loading, and playing. (Corresponding to {isPlaying: true, isLoading: false, currentTrack: someTrack, currentTime: someTime})
- A
Paused state: When we‘ve paused the song. (Corresponding to {isPlaying: false, isLoading: false, currentTrack: someTrack, currentTime: someTime})
So only four of the states are actually valid.
2. Pushing The Effects to the Boundary
We‘ve got another problem. Right now, we don‘t have any restriction/practice on where to call effectful commands (in our application right now, those are: audio.play(), audio.pause(), audio.src = ..., console.error).
This enables another class of bugs: Bugs where the real-world state is disconnected from our modeled state. (Because we forget to call an effectful command at all, call it in the wrong place, etc.)
To mitigate this, we want to introduce an effect boundary, after which no more side effects in our program are allowed. (In TypeScript, we can‘t enforce this statically and need to rely on convention.)
Since we already specified our program as data in the first place, this is pretty easy to add.
Whenever there are state transitions from one state to another, we probably want to call an effect.
Speaking of which:
3. Transitioning Between States
Right now, state transitions happen ad-hoc, inside the command-ish functions, smeared together with the side-effects.
That‘s probably not what we want, either.
Preferably, those state transitions would also be encoded in data, and we then have some function that consumes this ‚state-transition-data‘ to produce a new state for our application.
Luckily, for this there‘s a pattern that already exists in the programming (and the frontend) world: The reducer pattern.
It works like this: You have a function called reduce that transitions from one oldState to a newState, taking in the ‚state-transition-data‘ I just mentioned. This ‚state-transition-data‘ is normally called an Action or a Message.
reduce looks like this:
export const reduce = (oldState: State, action: Action): State => {
// ...
}
And that‘s probably enough for now.
In the next post, we‘re going to start with the implementation.