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.

App.jsx

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.

App.jsx

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:

Search.jsx

<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.

https://github.com/sanctuary-js/sanctuary-maybe/blob/master/index.js

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.

Feed.jsx

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>
))

Feed.jsx

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.