How to empower Redux with Ramda

If we do a search we can find literally thousands of articles on setting up Redux in, for example, React. Although most of those are all technically sound, they are usually not in my style of coding. Personally, I am a heavy user of Ramda and love using it everywhere I can. Unfortunately, this means I don’t use it often in a professional capacity since none of my (ex-)colleagues is familiar with it. Well… All except one.

I thought it might be interesting for some to see how I personally use Redux combined with Ramda. The great thing about Ramda is that it applies a functional programming style, and therefore has a lot of common ground with the subject. Another great thing is that everything is currying is applied by default, it is TypeScript ready and contains a very extensive feature set. Ramda is usually one of the first things I install on a new project and prefer it much more than say lodash or underscore.

This guide assumes you know at least some basics of Redux, Redux Saga, how it works, and of course, how TypeScript works, as I’ll be showing code examples in TypeScript.

I’m mainly going to concentrate on how to use actions, reducers and Redux sagas, so I’ll not be discussing how to set up a basic Redux environment.

Assume we need a reducer users, our intention is to implement a basic CRUD.

First I’ll add Ramda to the top as an import, define our main User interface and set an initial state to an array of User objects.

reducer.ts

import * as R from "ramda";

export interface User {
  id: string;
  name: string;
  email: string;
  age: number;  
}

const initialState: User[] = [];

The User interface consists of some arbitrary keys that will help me explain the intricacies of Ramda’s workings, but essentially they can be anything you like them to be. We’ll export the interface as we will need it throughout our little application later on.

Now what I want to do is immediately start writing a Jest test, in the spirit of TDD. I like doing TDD only in cases where I think it makes sense, so I usually don’t go overboard with it. Especially if I don’t know how I want my feature to end up like! In this case, I know what I want to achieve, so writing my test first isn’t a bad idea.

reducer.test.ts

import { reducer } from "./reducer";

describe("Reducer: User", () => {
  it("should return an initial state", () => {
    expect(reducer(undefined)).toEqual([]);   
  });
});

When running this test we’ll notice that it will fail immediately, of course, since we don’t even have an export called reducer defined yet. So let’s do that, and make sure it will pass our test. I’ll write the code first, and will explain afterwards what is happening, so bear with me.

reducer.ts

const initialState: User[] = [];

export const reducer = (state: User[] = [], action: any = {}): User[] =>
  R.cond([[R.T, R.prop("state")]])({state, action});

I created two new functions here, setDefaults and reducer. A good thing to know is that every Ramda function returns a new function (as I said before, everything in Ramda applies currying).

Let’s look at the reducer function first. The reducer has two well-known arguments, the state and the action. Both of these are optional so we just set some defaults for them in the function signature. The reducer returns a new state, which in our case will be User[].

Our function consists of a feature called R.cond which is an elaborate if/else function that takes the first “true” value. It requires that we use an array consisting of arrays of functions.

In our case, we have the if/else function [R.T, R.prop("state")], which can be explained as “condition is always true (R.T) and respond with the “state” property in the argument (R.prop("state"))”. The R.T function is a function that always returns true, so we know this will be triggered. Our response to that if-statement will then be R.prop("state"), which means that we will return the state of our arguments, and in our case the initialState.

Not very useful right now, as we just output the given (or default) state each time, but the groundwork has been laid out.

Next thing we want to do is add an action that allows us to add a user, which is the “C” from our “CRUD”. Let’s create a new action identifier in a central action file, then after that make an action to add a user that can be dispatched.

action-types.ts

export const ADD_USER = "ADD_USER";

action.ts

import { ADD_USER } from "./action_types";
import { User } from "./reducer";

export const addUserAction = (user: User) => ({ type: ADD_USER, user });

In my opinion, we don’t need a test for this pure function as we can readily see what it will do. Plus it will be tested through the reducer later on, so why bother.

Now we have an action dispatch function, we want to extend our reducer to add a new user to the state. We will do this by extending the R.cond functionality in the reducer function.

Let’s first write a (failing) unit test, so that when it passes we know that it works exactly as we want it to. I’ve added ... to let you know there is code in between here, as I want to keep the code changes easy to read.

reducer.test.ts

import { addUserAction } from "./action";
import { reducer, User } from "./reducer";

...

  it("action ADD_USER should add a new user", () => {
    const user: User = {
      id: "test-id",
      name: "Fu Bar",
      email: "email@example.com",
      age: 34,
    };

    expect(reducer(undefined, addUserAction(user))).toEqual([ user ]);
  });

Now we have a failing test, let’s write the functionality to make it pass.

reducer.ts

import { ADD_USER } from "./action_types";

... 

const isActionType = (type: string) => R.pathEq(["action", "type"], type);

const addUserToState = ({state, action}: any) => R.append(action.user, state);

export const reducer = (state: User[] = [], action: any = {}): User[] =>
  R.cond([
    [isActionType(ADD_USER), addUserToState],
    [R.T, R.prop("state")]
  ])({state, action});

Now we added three functions. The first isActionType is a function that determines if the given action type matches with the one supplied by the reducer.

The second function addUserToState is the function that will be called by the reducer to actually add the user to the state. The function signature consists of destructuring the input array to the state and action object, and then simply appending the User object in the action to the current state. It’s good to note that R.append will always return a new list.

Lastly, we add the condition to the reducer’s R.cond feature to make our unit test pass. This is simply done by adding a new array containing the [if, then] condition that R.cond needs: [isActionType(ADD_USER), addUserToState].

So far so good!

This concludes part one of my guide, I will continue with this very soon. In the meantime, you can view the code of this guide in its entirety on this Github repository.

👋