I have been wanting to try something from the world of functional programming for a while. While I haven't found a use for libraries like Immutable.js
in solving my everyday tasks, Ramda
has been very convenient for me.
Ramda for managing application logic
The pipe
operator, as well as ifElse
, cond
, andThen
/otherwise
, allow creating concise and expressive functions consisting of simpler parts.
For example, we need a function that would make a GET request to the server and retrieve an array of some data (users, products, sales, etc.) - let's call them entities
. At the same time, it is necessary to check that the response status is equal to 200.
function GET:
async function fetchData(entity: string) {
return await axios.get(`http://localhost:3002/${entity}`, { validateStatus: () => true });
}
Then the complete function responsible for getting and checking data from the server will look like this:
export const fetchEntity = async <T>(entity: string): Promise<{ data: T[]; }> => {
return R.pipe(
R.always(entity),
fetchData,
R.andThen(
R.ifElse(
(data) => data.status !== 200,
() => { throw new Error(`${entity} fetch error`); },
R.pick(['data']),
)),
R.otherwise(() => { throw new Error(`${entity} fetch error`); }),
)();
};
The last line is actually unnecessary since the axios
request should not throw errors, but I added it here just in case and for demonstration purposes.
- The
R.pipe
function executes the functions passed as arguments to it in sequence. -
R.always(entity)
is a function that does nothing but returns the variable entity (as a function, since a function is required). This is equivalent to() => entity
. -
fetchData
takes the response from the previous function, entity, as its argument and makes aGET
request to the server. -
R.andThen
andR.otherwise
are equivalent to.then
and.catch
. In case an error occurs,R.otherwise
calls a function that throws the error. -
R.ifElse
takes 3 functions as arguments: the first function should return true or false, based on which the 2nd or 3rd function is executed. The(data) => ...
notation means that the response from the previous function, fetchData, is taken as an argument. -
R.pick(['data'])
is the last function in the list. It receives an object and returns the value for the specified key. In this case, it is data, which should contain the array we are interested in.
The advantages of such a function notation for me are:
Firstly, the function notation is very concise, and it can be easily understood at first glance what is happening here.
Secondly, it is relatively easy to expand by adding new functions anywhere among the arguments of R.pipe
.
n this example, the advantages may not be so obvious. However, if the request requires more complex logic and a greater number of data manipulations, the use of the pipe
function can be noticeably more preferable.
Ramda for Redux Slices
The recommended tool when using Redux is the @reduxjs/toolkit
library, which takes care of some routine actions to prepare for work.
The use of Redux Toolkit does indeed make the work easier. However, personally, I am not a fan of the approach to its use in the official Redux
documentation. How Immer
works. Immer
wraps the state in a proxy, so it allows us to mutate the state as if immutability doesn't matter. All mutations will be intercepted at the proxy level and we will get a new state
at the output, while the old one will not be changed in any way.
For example, if we need to change fetchingStatus
to pending
when requesting data from the server, it might look like this:
builder.addCase(fetchInitialData.pending, (state, _action) => {
state.fetchingStatus = 'Fetching'
})
What I don't like about this is that Immer
is so hidden under the hood here that it's not immediately clear what's happening. It looks like a bad piece of code with mutations.
The fact that the official documentation uses the word state
to refer to the argument instead of draft
or stateProxy
only exacerbates the situation.
So far, I have settled on the following approach:
- get the state from the proxy using
current
from the@reduxjs/toolkit
library. - based on this, I get a new state, which I return (i.e., I work with it "the old-fashioned way").
And Ramda
helps me with this due to its hiddenness of data immutability and convenient tools for working with objects.
The above example looks like this:
builder.addCase(fetchInitialData.pending, (stateProxy, _action) => {
const state = current(stateProxy)
return R.set(R.lensProp('fetchingStatus'), 'Fetching', state)
})
It turned out to be more verbose than the previous version, but at the same time, more clean.
Here is an example of code when we need to load additional data to the existing one:
builder.addCase(fetchInitialData.fulfilled, (stateProxy, action) => {
const state: AircompanyState = current(stateProxy)
return R.pipe(
R.always(state),
R.mergeLeft(action.payload),
R.set(R.lensProp('fetchingStatus'), 'Success')
)()
})
In the future, I will try to find a broader application for Ramda and also try the fp-ts
library, which seems to be similar but has better type support for working with Typescript
(in some cases, unfortunately, as is needed with Ramda
). Also, don't forget about Immer
. For example, useImmer
can be very useful when you need to edit deeply nested values of state.
Top comments (0)