How to Not Build a React App (Part III)
Design and Effects
Back to: Part I
The design will be personal, as this app has a target audience of one. I see three main pages:
- The player: Current song, basic controls.
- The queue: Upcoming songs.
- The library: Search and browse.
In this post I will focus on the Player, and furthermore, modeling the state needed for the player.
The Player
Here we need information about the current track:
- Title
- Artist
- Album
- Track number
- Track position
- Track length
As well as some basic controls:
- Play/Pause/Stop
- Previous/Next Track
- Volume
Album art would be good, but I need to think about text vs binary data coming over the websocket, and test out how MPD deals with album art for my library.
Refer to Part II for the commands currentsong
which gives all the info for the first part; status
has information about the volume and if the track is paused. Controlling Playback
has pause
, stop
, next
, previous
. While Playback Options has getvol
and setvol
.
State I: The view state.
We mostly repeat the above in typescript, but I will note that the track position is special because it only applies to the current song. In light of that, I group that value with the general status of the player. It should be clear that this is enough data to render the component described above
export type Song = {
file: SongId;
title?: string;
artist?: string;
album?: string;
trackNo?: string;
duration?: number;
}
export type PlayStatus = {
state: 'pause' | 'stop' | 'play';
volume: number;
trackPosition: number;
currentTrack: SongId
}
State II: Side Effects and Server Communication.
Background Tangent
Our app will need to talk to the mpd server. Right now I plan on doing things differently than most popular frameworks. I think I have a good motivation, but we shall see if it works out well. First, let’s discuss some of the existing options.
Redux Saga has this example:
class UserComponent extends React.Component {
...
onSomeButtonClicked() {
const { userId, dispatch } = this.props
dispatch({type: 'USER_FETCH_REQUESTED', payload: {userId}})
}
...
}
Then there is some redux middleware that (more or less) will do the side effects based on the actions dispatched. (By side effect I mean something like making an http request, or sending a message on a websocket.)
There’s also TanStack Query which has this example:
function Example() {
const { isLoading, error, data } = useQuery({
queryKey: ['repoData'],
queryFn: () =>
fetch('https://api.github.com/repos/tannerlinsley/react-query').then(
(res) => res.json(),
),
})
if (isLoading) return 'Loading...'
if (error) return 'An error has occurred: ' + error.message
return (
<div>
<h1>{data.name}</h1>
<p>{data.description}</p>
<strong>👀 {data.subscribers_count}</strong>{' '}
<strong>✨ {data.stargazers_count}</strong>{' '}
<strong>🍴 {data.forks_count}</strong>
</div>
)
}
Here, there is a hook that does the side effect directly in the component.
The Gist of Our Approach
We will do something more in line with Redux Saga, but instead of the side effects being driven off of the actions the side effects will happen based on the state.
Let’s do a quick review of our state management and how it relates to Redux. If you follow that link you see we have a createHandler
. A handler replaces both an action creator and a reducer. We won’t dispatch actions, but instead invoke the handlers directly. (The arguments to the handler will be the payload
that is typically part of a Redux action.) Instead of “action” I’ll use the term “MpdCommand” for the side effects we need to send to the server. We will store the commands in our state and there will be some analogue of middleware that watches the state and performs side effects based on the state.
“Handlers” are for events in the browser and the effect will be to update the state object, “commands” or “MpdCommands” represent messages we need to send (or have sent) over the websocket, which ultimately ends up at the Mpd server.
I also find this approach aesthetically appealing. Part of the appeal of React is your UI can be declarative. Recall, the UI is basically a function from the state to some DOM. I want a similar pattern where the websocket events sent are also a function of the state. There will be two observers of the state. One asks, “What DOM should I show the user?” the other “What messages do I need to send the server?”
The Finer Points of State
Consider the state in part I above. For example, if the song is paused, and what the position in the track is. There is the true state on the server. We can query the true state and know what we were last told, we can also infer what the state must be from our commands and the responses. For example, suppose based on our last query the state is “playing” and we send the “pause” command and receive a successful response, “OK”, we can then infer the current state to be “paused”. Also note that we will need two queues of commands, one for commands1 in progress on the server, and another for commands we need to send. We will need some kind of reducer to determine the new state based on a successful command. In any case, it should be clear that it’s impossible to know the true state of the server, but based on querying and successful commands we can track, “what we believe the server state to be”. This is what we will store in our state object. Since the two command queues are also in our state object we can compute (in our declarative view function) what the state will be if the in-progress commands complete successfully as well as what the new state will be if all of the outstanding commands are sent and complete successfully. I imagine this will all be quite quick, but it’s worth thinking about the best UX for the differences between those three states.
Imagine a song is playing, or at least we believe it is playing based on the state we are tracking. The user clicks pause, but we have not sent the message yet. We could show the pause symbol in the UI, but blinking slowly. Then once we send the command, we could optimistically show it as paused (no blinking), but maybe have a progress line or spinner somewhere. Then once it completes successfully we go back to rendering the state as we best believe it to be.
Code
export type State = {
status: PlayStatus;
sentCommands: MpdCommand[];
desiredCommands: MpdCommand[];
responseData: string;
}
// This is not literally what get's sent on the websocket, just a representation of
// something we need to tell the server.
export type MpdCommand = {type: 'pause'} | {type: 'stop'} | {type: 'next_track'} | {type: 'previous_track'} | {type: 'set_volume', volume: number}
You see the status which is what we believe to be true, and the two queues. Finally there is the responseData
we need to track the server’s reply to the sentCommands
. Since we will have something watching the state, it will be able to see if responseData
ends in “OK” to know that the sent commands are finished (and do error handling). But we haven’t gotten there quite yet.
Next: Part IIII (Handlers)
This is plural because MPD supports command lists ↩︎