DEV Community

Petr Tcoi
Petr Tcoi

Posted on • Updated on

A couple of words about Ramda for React and Redux

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 });
} 
Enter fullscreen mode Exit fullscreen mode

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`); }),
  )();
};
Enter fullscreen mode Exit fullscreen mode

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 a GET request to the server.
  • R.andThen and R.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'
})
Enter fullscreen mode Exit fullscreen mode

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)
})
Enter fullscreen mode Exit fullscreen mode

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')
  )()
})
Enter fullscreen mode Exit fullscreen mode

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.

Latest comments (0)