DEV Community

loading...

Building a React app with functional programming (Part 1)

Marko Bilal
Javascript / Go Engineer.
・7 min read

Alt Text

Originally published at https://www.markob.io/functional-programming-react/ on Jan 05 2021

In this series we will scrap together a real world React app that allows the user to search for podcasts and audiobooks and add them to a feed that lists the latest episodes.

It is intended to showcase how to use the functional programming concepts and patterns described in the fantastic Professor Frisby's Mostly Adequate Guide to Functional Programming.

We will explore how to compose functions, make the asynchronous look synchronous and leverage functional utilities from Ramda to write some succinct code.

You can skip the gibberish and grab the code from the Github repo, or tinker with it live in the CodeSandbox.

Core libraries

Folktale Task: for the Task data structure

Sanctuary: for Either and Maybe data structures.

Most: for Stream data structure (needed for consuming xml feed data)

Ramda: for core lambda utilities like map compose reduce identity.

I like Ramda for this because their functions are not strictly curried, meaning we can pass all required arguments in any arity we like ie.

const res = map(myFunc, myCollection)
// or
const res = map(myFunc)(myCollection)
Enter fullscreen mode Exit fullscreen mode

App requirements

Search page

The search page is where we can look for podcasts and books using a search field and render the results in a list.
From the rendered list of results, we can add / save the item locally so that the episodes of that subscription appear in our feed page.

  • Call iTunes api for 2 different queries, podcasts and audiobooks
  • Consolidate results into one list
  • Allow user to add a result to their feed, creating a subscription for feed
  • Save subscriptions to localStorage

Feed page

The feed page will simply display all the latest episodes from our subscriptions.

  • Get all subscriptions from localStorage
  • Create stream from calling each subscription's feed url
  • Consolidate streams into one and render final list

Search page - task and Either

iTunes API

Because our app depends on results from the iTunes API, we will create an api package first that
will make the calls.

Here is the first of 2 functions:

api.js

import axios from "axios"
import Task, { task } from "folktale/concurrency/task"
import { Left, Right } from "sanctuary-either"

const SEARCH_URL = "https://itunes.apple.com/search"

export const fetchMedia = ({ term, media, limit = 5 }) =>
  term
    ? task(async resolver => {
        try {
          const response = await axios.get(SEARCH_URL, {
            params: { term, media, limit },
          })

          resolver.resolve(Right(response))
        } catch (err) {
          resolver.resolve(Left(err))
        }
      })
    : Task.of(Left(null))
Enter fullscreen mode Exit fullscreen mode

Our search function fetchMedia accepts 3 arguments:

  • term: the search term,
  • media: type of media ie podcast or audiobook,
  • limit

and returns a Folktale task.

The task data structure is used as a container type for asynchronous results like promises. Using this data structure allows us to containerize
the api response and operate on it just like containerized values from synchronous operations.

fetchMedia returns a task which makes a network call and returns our result as an Either once this task is run. The Either allows us to handle an error the same as a successful response. Right is used to
transfer the actual value we are looking for, and Left signals that we didn't get what we were looking for and any operation on the result will be ignored
on a Left.

To summarize: our result is a task wrapping an Either which contains our actual api response value.

Now that we have our api package, let's build the search page.

Here is our React component:

Search.jsx

const err = err => {
  return []
}

const success = res => {
  return path(["data", "results"])(res)
}
const parseResponse = either(err)(success)

const [query, setQuery] = useState("")
const [results, setResults] = useState([])

const fetchBoth = async e => {
  e.preventDefault()
  const combineResults = curry((pods, audiobooks) => {
    return compose(flatten, map(parseResponse))([pods, audiobooks])
  })

  // directly taken from https://mostly-adequate.gitbooks.io/mostly-adequate-guide/content/ch10.html#ships-in-bottles
  const liftA2 = curry((g, f1, f2) => {
    return f1.map(g).ap(f2)
  })

  const allTasks = liftA2(
    combineResults,
    fetchMedia({ term: query, media: "podcast" }),
    fetchMedia({ term: query, media: "audiobook" })
  )

  allTasks.run().listen({
    onRejected: val => {
      console.error(val)
    },
    onResolved: setResults,
  })
}
Enter fullscreen mode Exit fullscreen mode

So, a lot to unpack here but it is quite simple when its boiled down. I didn't include the html / jsx markup because we only need to know that fetchBoth is called once user types a search term and hits fetch button.

Consolidating api responses

Our search function fetchBoth consolidates both audiobooks and podcasts into the results listed on the page. This is a contrived example of having to make multiple api calls in order to get a single result list.

Let's start with allTasks. This function lets us make two api calls in parallel and combine the results while keeping the values in
container land.

Breaking this down further, let's look at liftA2.

const liftA2 = curry((g, f1, f2) => {
  return f1.map(g).ap(f2)
})
Enter fullscreen mode Exit fullscreen mode

This is a curried function , meaning the arguments don't need to be supplied all the same time. They can be passed in one after the other at any point, and only once all arguments are provided does it run the function body. In our case, we passed everything in all at once.

Now, the g argument is function that we will apply to the result of the first function f1, in our case g is combineResults and f1 is the result of fetchMedia({ term: query, media: "podcast" }) which is a task.

Since task is a functor, it has a map method and so our liftA2 works.

const combineResults = curry((pods, audiobooks) => {
  return compose(flatten, map(parseResponse))([pods, audiobooks])
})
Enter fullscreen mode Exit fullscreen mode

Remember, once we map over a task, we are extracting the containerized value, so f1.map(combineResults) returns us a task but with the containerized value of f1 passed into combineResults.

The containerized value is a Left or a Right, and combinedResults is waiting for the 2nd argument before it will run. .ap method of task allows us to apply the results of f2 (which again will be a Left
or a Right) as the 2nd argument to combineResults and returns a task. In the end , we get to work with those containerized values without popping them out manually.

And since combineResults is a curried function, it gives us the ability to fire
off both api calls (the fetchMedia functions) in parallel without having to coordinate and wait for results of each and then pass them into combineResults.

So in the end, all we are doing is calling combineResults with the values from inside the two tasks (which are Either) returned by the fetchMedia functions.

Once both api calls return, the body of the combineResults function runs. Note: as you will see below, we must run the task that is returned
from liftA2 in order to kick off the entire chain of events.

Parsing and data transformation with map, compose and either

const err = err => {
  return []
}

const success = res => {
  return path(["data", "results"])(res)
}

const parseResponse = either(err)(success)

const combineResults = curry((pods, audiobooks) => {
  return compose(flatten, map(parseResponse))([pods, audiobooks])
})
Enter fullscreen mode Exit fullscreen mode

Here we can showcase why an Either is useful and some Ramda goodies. In the implementation above, we simply grabbed the results of the calls and passed them to parseResponse which extracts the value. But we can also do something like transforming the data before we extract it.

Our response has a url field and we want to toUpper it before parsing.

Just add that part to the compose :

const upperUrl = over(lensPath(["config", "url"]), toUpper)
const combineResults = curry((pods, audiobooks) => {
  return compose(
    flatten,
    map(parseResponse),
    map(map(upperUrl))
  )([pods, audiobooks])
})
Enter fullscreen mode Exit fullscreen mode

The upperUrl function takes a response object , and applies the toUpper function to the "config.url" path of the object and returns a new object with the url uppercased.

We must map twice because remember, first map will unwrap theunderlying value, the 2nd map operates on it. The beauty here is that if either pods or audiobooks returns a Left, we don't have to
have special case code for that, the Either takes care of it for us and the uppercase transformation simply doesn't run on a Left.

compose, mapandflattenare all utilities from Ramda.composetakes a series of functions and returns a function that passes it's argument to the supplied list of functions from right to left. So we pass the array[pods, audiobooks]tomap(parseResponse)and thenflatten.

This is where we get to pop out our values from their containers and use them in the render. Some folks might even call this function from render to keep the impure part
at the render step.

const parseResponse = either(err)(success) uses either which is an import from sancturay.

This either function extracts the containerized value.
The first argument you supply, in our case the err function, is the result returned if the Either is a Left.

The second argument is returned if it is a Right. If the underlying task returns a Left(err), we are ignoring the err argument and just returning an empty array.

We can instead throw the error, console.log it , or pass it to a bug catch api for logging. If the task wraps a Right , then the success function runs with the argument as the
value the Right wraps.

As you can see, our success function takes the value the Right provides and returns the object path "data.results".
See path from Ramda.

And the final part is that we flatten the results because we will have each api response as a list of results so it ends up a nested array.

In order to actually kickoff the task, we must run

otherTasks.run().listen({
  onRejected: val => {
    console.error(val)
  },
  onResolved: setResults,
})
Enter fullscreen mode Exit fullscreen mode

If you notice, we do not have any async/await logic inside our React component. The resolve/reject logic is whatever we specify in the .listen() method.

This ends our Search page, in the next part we tackle saving subscriptions and displaying a feed of latest episodes.

Discussion (0)