In the previous instalment of this series, we refactored a SolidJS Audio Player from using four disjoint signals to a State discriminated union that now makes illegal states impossible.
Which is nice, but we‘re not done yet.
The next thing we‘re going to focus on is the way we‘re executing these state transitions: Right now they happen ad-hoc, inside the command-ish functions, smeared together with the side-effects.
And as functional programmers, whenever we see something like „this handles logic together with side effects“, we want the size of that to be as minimal as possible.
So what can we do?
We can apply a different instantiation of the same trick we used last time.
Instead of setting the state directly in effectful functions, we can encode the state transitions as data, and by doing that make our intent (more) explicit.
This state-transition-data (let‘s call is Actions for now) we can we can then use to write a pure function that updates our state.
There‘s a name for this 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 Actions (aka. the state-transition-data) I just mentioned.
reduce looks like this:
export const reduce = (oldState: State, action: Action): State => {
// ...
}
Explicit State Transitions: An Action discriminated union
Just to recap—this is the current state of the AudioPlayer (code here: SolidJS playground, Github)
import { render } from 'solid-js/web'
import { createEffect, createSignal, createMemo, onMount, For, Show } from 'solid-js'
export type Track = { id: string; url: string; title: string }
export type Time = number
export type Initial = { type: 'Initial' }
export type Loading = { type: 'Loading'; track: Track }
export type Playing = { type: 'Playing'; track: Track; currentTime: Time }
export type Paused = { type: 'Paused'; track: Track; currentTime: Time }
export type State = Initial | Loading | Playing | Paused
export const MISUAudioPlayer = () => {
const [state, setState] = createSignal<State>({ type: 'Initial' })
createEffect(() => console.log(JSON.stringify(state())))
const audio: HTMLAudioElement = new Audio()
const playTrack = (track: Track) => {
setState({ type: 'Loading', track })
audio.src = track.url
audio.play().catch((err) => {
console.error('Playback failed', err)
setState({ type: 'Paused', track, currentTime: 0 })
})
}
const togglePlay = () =>
setState((prev: State) => {
if (prev.type === 'Paused') {
audio.play()
return { ...prev, type: 'Playing' }
} else if (prev.type === 'Playing') {
audio.pause()
return { ...prev, type: 'Paused' }
} else {
return prev
}
})
onMount(() => {
audio.oncanplay = () =>
setState((prev: State) => {
if (prev.type === 'Loading') {
return { type: 'Playing' as const, track: prev.track, currentTime: 0 }
} else {
return prev
}
})
audio.ontimeupdate = () =>
setState((prev: State) => {
if (prev.type === 'Playing') {
return { ...prev, currentTime: audio.currentTime }
} else {
return prev
}
})
})
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',
},
]
const trackState = createMemo(() => {
if (state().type === 'Loading' || state().type === 'Paused' || state().type === 'Playing') {
return state() as Loading | Playing | Paused
} else {
return undefined
}
})
return (
<div class="p-5">
<h2>Audio Player: Illegal States Are Unrepresentable</h2>
{/* JSON.stringify(state()) */}
<For each={tracks}>
{(track) => (
<button class="p-2 bg-zinc-200 m-2 cursor-pointer" onClick={() => playTrack(track)}>
Play {track.title}
</button>
)}
</For>
<div class="mt-4 p-2 border-[1px] border-solid border-red-500">
<Show when={trackState()} fallback={<p>No track selected</p>}>
{(ts) => (
<>
<h3>Now: {ts().track.title}</h3>
<Show when={ts().type !== 'Loading'} fallback={<p>Loading...</p>}>
<p>Time: {(ts() as Playing | Paused).currentTime.toFixed(2)}s</p>
<button class="p-2 bg-zinc-200 m-2 cursor-pointer" onClick={togglePlay}>
{ts().type === 'Playing' ? 'Pause' : 'Resume'}
</button>
</Show>
</>
)}
</Show>
</div>
</div>
)
}
render(() => <MISUAudioPlayer />, document.getElementById("app")!);
Our job is now to give names to all of these state transitions, and attach data to them (or not).
The first state transition (code-wise) happens in playTrack. The thing we‘re doing there is roughly equivalent to selecting a Track, so we‘re going to call it SelectTrack.
export type SelectTrack = { type: 'SelectTrack', track: Track }
export type Action = SelectTrack
In the same playTrack function, we‘re also setting up an error callback for the play() method that pauses the audio on error.
export type SelectTrack = { type: 'SelectTrack', track: Track }
export type Pause = { type: 'Pause' }
export type Action = SelectTrack | Pause
Taking a look at togglePlay, we see that it does pretty much the state transitions its name indicates: It toggles from Playing to Paused.
export type SelectTrack = { type: 'SelectTrack', track: Track }
export type Pause = { type: 'Pause' }
export type TogglePlay = { type: 'TogglePlay' }
export type Action = SelectTrack | Pause | TogglePlay
Going on, we see an oncanplay callback that sets the currentTime of the state to zero.
A good name for this would be CanPlay or something more abstract like AudioReady.
export type SelectTrack = { type: 'SelectTrack', track: Track }
export type Pause = { type: 'Pause' }
export type TogglePlay = { type: 'TogglePlay' }
export type AudioReady = { type: 'AudioReady' }
export type Action = SelectTrack | Pause | TogglePlay
And then there‘s ontimeupdate: In there, we‘re setting the currentTime repeatedly. We could call that action naively SetTime or something, but the canonical name for an action like this is Tick.
export type SelectTrack = { type: 'SelectTrack', track: Track }
export type Pause = { type: 'Pause' }
export type TogglePlay = { type: 'TogglePlay' }
export type AudioReady = { type: 'AudioReady' }
export type Tick = { type: 'Tick', currentTime: Time }
export type Action = SelectTrack | Pause | TogglePlay | Tick
Now, we can take a moment and evaluate whether these Actions make sense in combination.
Something you might notice is that Pause and TogglePlay are very close to each other, and so now we‘ve got a decision to make: Do we expect Pause to ever have a different behavior from TogglePlay (for example: Do we want to be able to have a button that only can Pause, and which doesn‘t make the Audio play again when it‘s pressed repeatedly? Maybe, maybe not.) This is a design choice to make. For now, we‘re going to say that we don‘t need such a behavior—and even so, it would be very easy to add it later.
So our ‚final‘ version of Action looks like this:
export type SelectTrack = { type: 'SelectTrack', track: Track }
export type TogglePlay = { type: 'TogglePlay' }
export type AudioReady = { type: 'AudioReady' }
export type Tick = { type: 'Tick', currentTime: Time }
export type Action = SelectTrack | TogglePlay | Tick
Explicit state transitions: The reduce function
Now we need to actually use this to drive our state transitions, and we do that by writing our reduce function:
We definitely will want to dispatch on our Action type.
export const reduce = (s: State, a: Action): State => {
switch (a.type) {
case 'SelectTrack':
// ...
case 'TogglePlay':
// ...
case 'AudioReady':
// ...
case 'Tick':
// ...
}
And we repeatedly ask ourselves the question: How does State need to change in accordance with one of the Actions?
So let‘s take State in mind again:
export type Initial = { type: 'Initial' }
export type Loading = { type: 'Loading'; track: Track }
export type Playing = { type: 'Playing'; track: Track; currentTime: Time }
export type Paused = { type: 'Paused'; track: Track; currentTime: Time }
export type State = Initial | Loading | Playing | Paused
Okay. Our first intuition would be that SelectTrack should probably just transition from Initial to Loading:
export const reduce = (s: State, a: Action): State => {
switch (a.type) {
case 'SelectTrack':
if (s.type === 'Initial') {
return { type: 'Loading', track: a.track }
} else {
return s
}
case 'TogglePlay':
// ...
case 'AudioReady':
// ...
case 'Tick':
// ...
}
But that‘s not correct: We also want to be able to select another song while there‘s currently an active song—so we want the transition to happen from whereever:
export const reduce = (s: State, a: Action): State => {
switch (a.type) {
case 'SelectTrack':
return { type: 'Loading', track: a.track }
case 'TogglePlay':
// ...
case 'AudioReady':
// ...
case 'Tick':
// ...
}
What should TogglePlay do? Pretty much exactly what our togglePlay function has been doing so far:
export const reduce = (s: State, a: Action): State => {
switch (a.type) {
case 'SelectTrack':
return { type: 'Loading', track: a.track }
case 'TogglePlay':
if (s.type === 'Playing') {
return { ...s, type: 'Paused' }
} else if (s.type === 'Paused') {
return { ...s, type: 'Playing' }
} else {
return s
}
case 'AudioReady':
// ...
case 'Tick':
// ...
}
What should AudioReady do? Checking the State union, it should really only ever do one thing: Transition from Loading to Playing.
export const reduce = (s: State, a: Action): State => {
switch (a.type) {
case 'SelectTrack':
return { type: 'Loading', track: a.track }
case 'TogglePlay':
if (s.type === 'Playing') {
return { ...s, type: 'Paused' }
} else if (s.type === 'Paused') {
return { ...s, type: 'Playing' }
} else {
return s
}
case 'AudioReady':
if (s.type === 'Loading') {
return { type: 'Playing', track: s.track, currentTime: 0 }
} else {
return s
}
case 'Tick':
// ...
}
Tick is also straightforward: Whenever we receive it, we set the time. (We could also receive the Tick message without a payload, and the count things ourselves, but that introduces more complexity than it buys us here.)
export const reduce = (s: State, a: Action): State => {
switch (a.type) {
case 'SelectTrack':
return { type: 'Loading', track: a.track }
case 'TogglePlay':
if (s.type === 'Playing') {
return { ...s, type: 'Paused' }
} else if (s.type === 'Paused') {
return { ...s, type: 'Playing' }
} else {
return s
}
case 'AudioReady':
if (s.type === 'Loading') {
return { type: 'Playing', track: s.track, currentTime: 0 }
} else {
return s
}
case 'Tick':
if (s.type === 'Playing') {
return { ...s, currentTime: a.time }
}
return s
}
And that‘s it. Now we have one pure function that tracks all of our state transitions, which is short, contained, and very easy to test.
TypeScript: Better Type Hints
…almost: Since we like cleaning up after ourselves, and make our job easier for the future, we can also specify a default case for the Action swtich/case. This gives us better type hints when editing the reduce function in the future:
export const reduce = (s: State, a: Action): State => {
switch (a.type) {
case 'SelectTrack':
return { type: 'Loading', track: a.track }
case 'TogglePlay':
if (s.type === 'Playing') {
return { ...s, type: 'Paused' }
} else if (s.type === 'Paused') {
return { ...s, type: 'Playing' }
} else {
return s
}
case 'AudioReady':
if (s.type === 'Loading') {
return { type: 'Playing', track: s.track, currentTime: 0 }
} else {
return s
}
case 'Tick':
if (s.type === 'Playing') {
return { ...s, currentTime: a.time }
}
return s
default:
const _exhaustive: never = a
return _exhaustive
}
}
Wiring everything up | Machinery
Now, there‘s only one thing left: We need to wire up our new beautiful state transition machinery.
And this is where we‘re finally interacting with SolidJS again.
Our goal is the following: We want to be able to dispach those Actions from anywhere in our application in the easiest way possible.
We want our app‘s state transition surface to look something like this:
const playTrack = (track: Track) => dispatch({ type: 'SelectTrack', track })
But this dispatch function needs to be able to modify the state (and we don‘t want to pass the state to it).
We can do it by writing a createReducer function that:
- takes in the reduce function and an initial State
- returns a view on the state, as well as the dispatch function
export const createReducer = <S extends object, A>(
reduce: (state: S, action: A) => S,
initialState: S,
): [S, (action: A) => void] => {
const [store, setStore] = createStore<S>(initialState)
const dispatch = (action: A): void => {
setStore(reconcile(reduce(store, action)))
}
return [store, dispatch]
}
The dispatch function part here is a bit tricky. It needs to close over the state while only taking the action as an argument, and then setting the result of the action applied to the state through reduce to the new state (which is a SolidJS store).
Picking this apart is not the point of this blog post, but you can find more information on it here: https://www.solidjs.com/tutorial/stores_immutable
Implementation
With this out of the way, we can use our newfound machinery.
We create our dispatch and state store (as an update for createSignal):
export const STAudioPlayer = () => {
const [state, dispatch] = createReducer<State, Action>(reduce, { type: 'Initial' })
// ...
}
And then we dispach our messages like we need:
export const STAudioPlayer = () => {
const [state, dispatch] = createReducer<State, Action>(reduce, { type: 'Initial' })
const playTrack = (track: Track) => dispatch({ type: 'SelectTrack', track })
const togglePlay = () => dispatch({ type: 'TogglePlay' })
onMount(() => {
audio.oncanplay = () => dispatch({ type: 'AudioReady' })
audio.ontimeupdate = () => dispatch({ type: 'Tick', time: audio.currentTime })
})
// ...
}
And that‘s it with the state transition changes already. But now, we still need to drive the changes to the ‚real‘ HTMLAudioElement from our state. We can do that via an createEffect.
export const STAudioPlayer = () => {
const [state, dispatch] = createReducer<State, Action>(reduce, { type: 'Initial' })
const playTrack = (track: Track) => dispatch({ type: 'SelectTrack', track })
const togglePlay = () => dispatch({ type: 'TogglePlay' })
onMount(() => {
audio.oncanplay = () => dispatch({ type: 'AudioReady' })
audio.ontimeupdate = () => dispatch({ type: 'Tick', time: audio.currentTime })
})
createEffect(() => {
if (state.type === 'Playing') {
audio.play().catch((err) => {
console.error('Playback failed', err)
dispatch({ type: 'TogglePlay' })
})
} else if (state.type === 'Paused') {
audio.pause()
} else if (state.type === 'Loading') {
audio.src = state.track.url
}
})
}
Full code here: SolidJS playground, Github
And that‘s it with the changes. The STAudioPlayer should have the same behavior as the MISUAudioPlayer before.
This completes part 2 of this series.
In the next part, we‘re going to apply the same technique (of modeling implicit intent as explicit data) again to focus on the effect boundary of the system. What we‘re going to make explicit is the createEffect we just created.
Looking forward to see you there!