Building a React app with functional programming (Part 2)
Applying functional programming patterns and data types in a real app.
Date published: January 11, 2021 ~12 min read
In the first part of this series, we made api calls to iTunes api for audiobooks and podcasts and rendered the results as a single list.
In this part, we will go through saving the items in this list and then displaying episodes from all saved subscriptions in a single list sorted by release date.
Saving subscriptions
In order to understand the way our app will save items, lets look at the App.js
root React component.
function App() {
const [subs, setSubs] = useState(Nothing)
useEffect(() => {
const storedSubs = JSON.parse(localStorage.getItem("subs"))
setSubs(of(Maybe)(storedSubs))
}, [])
const handleSave = data => () => {
const newSubs = concat(Just([data]))(subs)
setSubs(newSubs)
localStorage.setItem("subs", JSON.stringify(parseSubs(newSubs)))
}
const [page, setPage] = useState("feed")
const goPage = page => () => setPage(page)
return (
<div>
<Header goPage={goPage} page={page} />
<div className="max-w-7xl mx-auto py-12 sm:px-6 lg:px-8 h-full">
{page === "search" ? (
<Search saveSub={handleSave} />
) : (
<Feed subs={subs} /> //because subs is a Maybe - parse to extract
)}
</div>
</div>
)
}
export default App
The items are saved to localStorage
for simplicity sake. We keep the logic in the root component because
the sibling pages Search
and Feed
both depend on the data.
The Search
page needs to be able to save an item and Feed
page needs to use the saved data.
Here is an example of the structure of a single podcast when it is saved:
{
"wrapperType": "track",
"kind": "podcast",
"artistId": 1524874689,
"collectionId": 1460157002,
"trackId": 1460157002,
"artistName": "Barstool Sports",
"collectionName": "Million Dollaz Worth Of Game",
"trackName": "Million Dollaz Worth Of Game",
"collectionCensoredName": "Million Dollaz Worth Of Game",
"trackCensoredName": "Million Dollaz Worth Of Game",
"artistViewUrl": "https://podcasts.apple.com/us/artist/barstool-sports/1524874689?uo=4",
"collectionViewUrl": "https://podcasts.apple.com/us/podcast/million-dollaz-worth-of-game/id1460157002?uo=4",
"feedUrl": "https://mcsorleys.barstoolsports.com/feed/million-dollaz-worth-of-game",
"trackViewUrl": "https://podcasts.apple.com/us/podcast/million-dollaz-worth-of-game/id1460157002?uo=4",
"artworkUrl30": "https://is2-ssl.mzstatic.com/image/thumb/Podcasts124/v4/79/c7/62/79c7622b-b44e-e8d8-5e68-f4a12e1b069c/mza_13173545004459784696.jpg/30x30bb.jpg",
"artworkUrl60": "https://is2-ssl.mzstatic.com/image/thumb/Podcasts124/v4/79/c7/62/79c7622b-b44e-e8d8-5e68-f4a12e1b069c/mza_13173545004459784696.jpg/60x60bb.jpg",
"artworkUrl100": "https://is2-ssl.mzstatic.com/image/thumb/Podcasts124/v4/79/c7/62/79c7622b-b44e-e8d8-5e68-f4a12e1b069c/mza_13173545004459784696.jpg/100x100bb.jpg",
"collectionPrice": 0,
"trackPrice": 0,
"trackRentalPrice": 0,
"collectionHdPrice": 0,
"trackHdPrice": 0,
"trackHdRentalPrice": 0,
"releaseDate": "2021-01-11T03:00:00Z",
"collectionExplicitness": "explicit",
"trackExplicitness": "explicit",
"trackCount": 95,
"country": "USA",
"currency": "USD",
"primaryGenreName": "Music Commentary",
"contentAdvisoryRating": "Explicit",
"artworkUrl600": "https://is2-ssl.mzstatic.com/image/thumb/Podcasts124/v4/79/c7/62/79c7622b-b44e-e8d8-5e68-f4a12e1b069c/mza_13173545004459784696.jpg/600x600bb.jpg",
"genreIds": [
"1523",
"26",
"1310"
],
"genres": [
"Music Commentary",
"Podcasts",
"Music"
]
}
Now, lets look closely at the handleSave
function we pass to our Search
page. This is the callback
that we pass to the Add
button next to each search result rendered.
const handleSave = data => () => {
const newSubs = concat(Just([data]))(subs)
setSubs(newSubs)
localStorage.setItem("subs", JSON.stringify(parseSubs(newSubs)))
}
The first thing to note is that we partially “apply” the data , which is the result item, and return a function. This is so that the function body runs after the user clicks since this callback is passed to each rendered item:
<ResultsTable>
{results.map(data => (
<SearchResult key={data.collectionId} data={data} onSave={handleSave} />
))}
</ResultsTable>
Avoiding complexity with Maybe data type
Lets look a the function body of handleSave
.
const newSubs = concat(Just([data]))(subs);
Here we are working with the Maybe
data type from Sanctuary. You also notice in the very first line of our App.jsx
that we initialize the subs
state with:
const [subs, setSubs] = useState(Nothing);
A Maybe
allows us to treat actual values and null values the same. As we will see later, we avoid unncessary addition of complexity
by not having special case code.
So our newSubs
variable is just our existing subscriptions concatted with the new subscription we just added.
Sanctuary’s concat
dispatches the concat
method of the first argument, in this case a Just
, and the magic check if we can actually
concat the two values happens inside that implementation. Sanctuary does the lifting for us so we don’t have to worry about
concating a Nothing
and a Just
.
function Just$prototype$concat(other) {
return other.isJust ? Just(Z.concat(this.value, other.value)) : this
}
Avoiding null checks
The other two lines of our function just save the state locally and also persist it to localStorage
so that we can
keep our subscriptions after reloading.
setSubs(newSubs)
localStorage.setItem("subs", JSON.stringify(parseSubs(newSubs)))
We must pop our actual array of items out of the Maybe
container before we stringify, which is what parseSubs
does.
export const parseSubs = maybe_(nothing)(identity);
Likewise, when we load the page, we check localStorage
for saved subscriptions and load them into state as a Maybe
.
Right away you can see why our data type is handy:
const [subs, setSubs] = useState(Nothing)
useEffect(() => {
const storedSubs = JSON.parse(localStorage.getItem("subs"))
setSubs(of(Maybe)(storedSubs))
}, [])
Sanctuary provides us with of
and Maybe
which allows us to create a Maybe
from a value.
We don’t need to check if localStorage
returns a value or null for our key. We stick the return value in a Maybe
automatically and continue on our way.
Remember, each time we check for special cases with if statements, we are adding a small bit of complexity because we diverge from our intended flow to make sure everything is taken care of. When we don’t need to keep something like this in mind we have more mental capacity to continue implementing core logic.
Using Streams to combine XML feeds
We now move on to displaying all the lates episodes of our subscriptions in a Feed
page.
Fetching each feed
Since our podcast subscriptions provide a url to their xml feed, we need a way to organize and process the episodes.
In our api.js
file we have a new method called feedParse
.
export const feedParse = feedUrl => {
return task(async resolver => {
const feedparser = new FeedParser()
let response
try {
response = await axios({
method: "GET",
url: feedUrl,
responseType: "stream",
})
} catch (err) {
//retry using proxy for cors sensitive feeds
response = await axios({
method: "GET",
url: `https://cors-anywhere.herokuapp.com/${feedUrl}`,
responseType: "stream",
})
}
if (!response) {
console.log("No response!")
return
}
const event = () =>
new CustomEvent("episode", { detail: feedparser.read() })
const myTarget = new EventTarget()
const myStream = fromEvent("episode", myTarget)
let counter = 0
feedparser.on("readable", function () {
counter++
if (counter > 10) {
return
}
const newItemEv = event()
myTarget.dispatchEvent(newItemEv)
})
stringToStream(response.data).pipe(feedparser)
resolver.resolve(myStream)
})
}
In order to process the XML feeds we must use FeedParser, and its API is callback based.
I know the logic here is a bit confusing but the main concepts to keep in mind are that we are returning a Stream
object
inside a task
. We know about task
but what is a Stream
?
We are using Stream as a Monad that can handle stream data. The fromEvent
is a necessary constructor
in order for us to load each episode into the Stream
object.
Each episode that comes through the XML feed gets added to the stream as the newItemEv
.
So our parseFeed
function returns a task
that resolves a Stream
.
Parsing feeds
Our App.jsx
passes our subscriptions down to our Feed.jsx
page for fetching and rendering. Here it is in its entirety:
export default function Feed({ subs }) {
// subs Maybe(subscription)
const [episodes, setEpisodes] = useState([])
const [done, setDone] = useState(false)
const [finalStream, setFinalStream] = useState(empty())
useEffect(() => {
if (done) {
// the consolidated finalStream now defines how to handle all items read from stream
// which we just add to a single array
finalStream.forEach(item => {
if (item.detail) {
setEpisodes(old => [...old, item.detail])
}
})
}
}, [finalStream, done])
useEffect(() => {
// get feed for each subscription
const maybeStreams = map(map(compose(feedParse, prop("feedUrl"))), subs)
// each stream object gets merged into one
const appendToFeed = (stream = []) => {
setFinalStream(finalStream.concat(stream))
}
const runTask = task => {
task.run().listen({
onResolved: appendToFeed,
})
}
// unwrap tasks from Maybe, await each task, signal to our component we are done
map(compose(thunkify(setDone)(true), map(runTask)), maybeStreams)
}, [subs])
const censoredNameOfFirstSub = map(
compose(prop("collectionCensoredName"), head),
subs
)
const datediff = function (a, b) {
return new Date(b.date).getTime() - new Date(a.date).getTime()
}
const sortedEpisodes = sort(datediff, episodes)
return (
<div className="max-w-4xl mx-auto">
<div className="mb-4">{parseLatestTitle(censoredNameOfFirstSub)}</div>
{sortedEpisodes.slice(0, 10).map((item, i) => (
<Episode data={item} key={i} />
))}
</div>
)
}
Composing, mapping, and transforming Stream data
Lets start with our useEffect
that runs on render. A lot is going on here in a few lines of code but we will deconstruct.
useEffect(() => {
// get streams from feed urls
const maybeTasksOfStreams = map(
map(compose(feedParse, prop("feedUrl"))),
subs
)
// each stream object gets merged into one
const appendToFeed = (stream = []) => {
setFinalStream(finalStream.concat(stream))
}
const runTask = task => {
task.run().listen({
onResolved: appendToFeed,
})
}
// unwrap tasks from Maybe, await each task, signal to our component we are done
map(compose(thunkify(setDone)(true), map(runTask)), maybeTasksOfStreams)
}, [subs])
The subs are a Maybe
type. What we need to with our subscriptions is pass their feed url to parseFeeds
in order to get back a Stream
of episodes.
Tasks and Maybe
const maybeTasksOfStreams = map(map(compose(feedParse, prop("feedUrl"))), subs)
The outermost map
will unwrap the value of our Maybe (subs). The subs then get passed to the inner map
.
This inner map
returns us a new array of tasks
. The compose
simply takes the feedUrl
prop of each subscription object and passes it
to feedParse
which in turn returns us a task
.
Lets keep in mind that even though the outer map allows the inner map to operate on the wrapped value inside Maybe
, the returned
result is still a Maybe
. Anything we process and return inside the inner map is the wrapped value of this returned Maybe
. Run the program locally and console.log
the maybeTasksOfStreams
variable.
Processing the streams
The appendToFeed
and runTask
functions are simply utilities that we will cover last.
The main event here is the final line so lets dissect it:
map(compose(thunkify(setDone)(true), map(runTask)), maybeTasksOfStreams)
Again, the outermost map
will unwrap our Maybe
. This Maybe
contains an array of tasks
. What we want to do next is
run each task in order to operate on the returned Stream
. The map(runTask)
part will run all tasks in parallel because they are async operations.
The stream processing happens in the appendToFeed
function. Each task will resolve its result into the appendToFeed
function.
We have a finalStream
variable that acts as a single stream to store all episodes in from the aggregated streams. The Stream
data type from Most is a semigroup
so it has a concat method.
This is handy since we can just append incoming streams and only use the single main stream as our source of truth for episodes.
We signal to our React component that we have combined all of our streams with the setDone
call.
Finally, we get all the episodes from our consolidated stream:
const [done, setDone] = useState(false)
const [finalStream, setFinalStream] = useState(empty())
useEffect(() => {
if (done) {
// the consolidated finalStream now defines how to handle all items read from stream
// which we just add to a single array
finalStream.forEach(item => {
if (item.detail) {
setEpisodes(old => [...old, item.detail])
}
})
}
}, [finalStream, done])
We need to wait until all streams are processed in the appendToFeed
before we call the forEach
here. Inside the forEach
we simply extract the episode and update our state.
Optimizing the list updates as episodes come in is a different topic but this implementation is for simplicity sake.
Rendering results
Just for illustrative purposes, I’ve included this bit of code where we want to display different components or messages depending on if we have saved subscriptions or not.
In our render function, we will print the censored name of our latest subscription if it exists, otherwise we will print a message saying there are no subscriptions.
Check out the implementation:
export const parseSubs = maybe_(nothing)(identity)
const noSubs = () => "No podcasts added, use search to add"
const parseLatestTitle = maybe_(noSubs)(title => (
<div>
<span>Latest pod added: {title}</span>
<h2>Showing 10 latest episodes</h2>
</div>
))
const censoredNameOfFirstSub = map(
compose(prop("collectionCensoredName"), head),
subs
)
const datediff = function (a, b) {
return new Date(b.date).getTime() - new Date(a.date).getTime()
}
const sortedEpisodes = sort(datediff, episodes)
return (
<div className="max-w-4xl mx-auto">
<div className="mb-4">{parseLatestTitle(censoredNameOfFirstSub)}</div>
{sortedEpisodes.slice(0, 10).map((item, i) => (
<Episode data={item} key={i} />
))}
</div>
)
With our use of Maybe
type, we can confine the branching of what is rendered inside a separate function using maybe_
.
This keeps us consistent with performing impure actions at the very edges of our logic, in this case at the render step.
Regardless of if we have subs or not, our render will run the same function that will operate on the Maybe
.
You will also notice the beauty of a Maybe
in the censoredNameOfFirstSub
function. We can process the array of subs regardless of if they exist or not
because they are wrapped in a Maybe
. If the type is a Nothing
, none of the transformations run and we don’t have to do any null checks. We also avoid ternary syntax in our render
function
that is often seen in React components.
Conclusion
And that’s it. I hope you got a small glimpse into how much can be done in a seeminlgy few lines of code by using these handy data types and Ramda utilities all the while keeping our program relatively pollution free.
The main takeaway when starting out with functional programming in this context is to remember that values are wrapped in their container types and we are always operating on those values in a confined environment until we need to finally pull them out for rendering or persistence.