How to empower Redux with Ramda - Part 2

Read part 1 here

Since we now have a small reducer that adds a user to the state, we’ll continue with our next part; removing a user from the state.

First things first we want to add another action type and action.

action_types.ts

export const REMOVE_USER = "REMOVE_USER";

action.ts

import { ADD_USER, REMOVE_USER } from "./action_types";

...

export const removeUserAction = (id: string) => ({ type: REMOVE_USER, id });

Very basic stuff to set up but necessary I’m afraid. Now let’s continue to the meat of it all, the reducer. We’re going to set up something real quick to remove the user from the state. But first, let’s create a test and make it fail.

reducer.test.ts

import { addUserAction, removeUserAction } from "./action";

  ...

  it("action REMOVE_USER should remove a user", () => {
    const state: User[] = [{
      id: "test-id",
      name: "Fu Bar",
      email: "email@example.com",
      age: 34,
    }]

    expect(reducer(state, removeUserAction("test-id"))).toEqual([]);
  });
});

We have a failing test, so now can confidently implement our reducer functionality.

reducer.ts

import { ADD_USER, REMOVE_USER } from "./action_types";

...

const removeUserFromState = ({state, action}: any) => R.reject(R.propEq("id", action.id), state);

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

Beautiful. Just add one line to the reducer function and add a very simple function to R.reject the user when it matches the id.

Next up we want to update the user in our state, but as some has probably spotted already, this is going to be harder than it actually has to be. Our state consists of an array of User objects, which all have ids in them where we match on. Which means, that every time we remove or edit a user, we need to iterate through the whole state to find the user we need.

And, as we can see later on, once we start updating the user with a user: Partial<User> it will be hard to not allow update of the id, which we obviously not want.

One solution to this problem is to simply use a hash for the state instead of an array. The id of the record is then the key of the hash, and the user record is the value. Fortunately, we’re still very early in our reducer, so this change is relatively easy to make.

First, we’ll update our tests to reflect the changes we would like to see. In our case, I’m going to change how the state, reducer and actions behave, so we’ll have a lot of upcoming errors.

reducer.test.ts

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

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

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

    const id = "testId";

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

  it("action REMOVE_USER should remove a user", () => {
    const state: State = {
      testId: {
        name: "Fu Bar",
        email: "email@example.com",
        age: 34,
      }
    };

    expect(reducer(state, removeUserAction("testId"))).toEqual({});
  });

To recap what I changed:

Let’s start easy and change our addUserAction first.

action.ts

interface AddUserAction {
  id: string;
  user: User;
}

export const addUserAction = (action: AddUserAction) => ({ type: ADD_USER, ...action });

That was relatively painless, all it needed was a new interface and some basic changes. Next thing would be to change the state and the reducer.

First, the state.

reducer.ts

...

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

export interface State {
  [key: string]: User;
}

const initialState: State = {};

...

export const reducer = (state: State = {}, action: any = {}): State =>

...

Again, very painless. We created a new State interface that says we have a string key and an object of type User. We changed all references to the state so that it matches up with our new type.

The last thing to do is changing the way we use the state in the reducer.

reducer.ts

...

const addUserToState = ({state, action}: any) => R.merge(state, { [action.id]: action.user });

const removeUserFromState = ({state, action}: any) => R.omit([action.id], state);

...

This change should pass the test. There is a certain simplicity to this code that I find very attractive.

First of all, our state now consists of an object containing the keys as the primary ids, which makes for a sweet O(1) on all search, insert and deletion actions. Secondly, because it is such a simple way of inserting a removing. All it takes is just one R action (R.merge and R.omit respectively).

Now before I end this part of the guide, let me add one more bit of functionality. One that is so crazy simple it’s almost too good to be true.

To implement our “update user” feature, we’ll first update our User type, action and action types again.

reducer.ts

...

export interface User {
  name: string;
  email: string;
  age: number;
  marketing?: {
    email: boolean;
    telephone: boolean;
    post: boolean;
  }
}

...

action_types.ts

export const UPDATE_USER = "UPDATE_USER";

action.ts

...

interface UpdateUserAction {
  id: string;
  user: Partial<User>;
}

...

export const updateUserAction = (action: UpdateUserAction) => ({ type: UPDATE_USER, ...action });

We require for our action a Partial<User> so that we make all attributes optional. There is, however, a caveat when working with big extended types, so you might need to be careful with that.

For example, if you refer to a type in your interface, then that type will not become Partial<> and would need to adhere to the rules of the specified type.

Next up is (as usual) the reducer test.

reducer.test.ts

import { addUserAction, removeUserAction, updateUserAction } from "./action";

...

  it("action UPDATE_USER should update a user", () => {
    const state: State = {
      testId: {
        name: "Fu Bar",
        email: "email@example.com",
        age: 34,
      },
    };

    const expected: State = {
      testId: {
        name: "Foo Bar",
        email: "email@example.com",
        age: 60,
        marketing: {
          post: false,
          email: true,
          telephone: true,
        },
      },
    };

    const user: Partial<User> = {
      name: "Foo Bar",
      age: 60,
      marketing: { telephone: true, email: true, post: false }
    };

    expect(reducer(state, updateUserAction({ user, id: "testId" }))).toEqual(
      expected
    );
  });

In our test, we are going to add the marketing property, change their name and age. Of course, our test will fail now, as we don’t have an update user feature yet in our reducer.

reducer.ts

import { ADD_USER, REMOVE_USER, UPDATE_USER } from "./action_types";

...

const updateUserInState = ({state, action}: any) => R.mergeDeepRight(state, { [action.id]: action.user });

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

And that’s it. Our R.mergeDeepRight will do a deep merge (as the name suggests) and takes the right value (the action.user) as the one to override existing properties with. As you can see this function is nearly the same as the addUserToState function but acts differently because we asked it to deep merge. With the addUserToState function we basically said, if you find the existing property (the id of the user), just overwrite it with the value in the action.user. In the updateUserInState however, we traverse through the properties and only overwrite the ones we need.

This concludes this guide, there are some things we could still improve, but all Ramda functions are used pretty solidly. Personally, I find that this way of implementing Redux is a breath of fresh air, as it heavily reduces the oh-so-familiar switch (action.type) {} that you see around often.

You can view the code of this guide in its entirety on this Github repository.

👋