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)
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
andaudiobooks
- 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))
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,
})
}
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)
})
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])
})
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])
})
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])
})
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
, map
andflatten
are all utilities from Ramda.compose
takes 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,
})
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.
Top comments (0)