Repatch - the simplified Redux

I’ve been involved in react-redux projects for several years. After I first met with flux, I was impressed by its expressive power that describes complicated use cases in contrast to other dataflow concepts, which caused many troubles when the complexity of a project increased.

The action controlled dataflow concept is simple and clear. Data changes can be described as actions with a minimal payload. These actions make a deterministic, time-independent history of the application’s life. The application’s state at a given point is reducible by picking an action in the chain.

The concept of Redux has many theoretical principles and advantages, but I do not intend to talk about them. There is only one major disadvantage of immutability: the cost of it. But the price we must pay for immutable data handling is multiple refunded by avoiding re-renders and reflows in React applications. We can always keep track of the difference between two consecutive states, and that is something why I cannot list immutability as a disadvantage of Redux.

Motivation

Redux has one more disadvantage: it is painfully verbose.

Let's suppose we want to create an async action, which fetches users and saves them in a Redux store instance. We need 3 action definitions:

const START_FETCHING_USERS = "START_FETCHING_USERS";
const RESOLVE_FETCHING_USERS = "RESOLVE_FETCHING_USERS";
const REJECT_FETCHING_USERS = "REJECT_FETCHING_USERS";

The first action type START_FETCHING_USERS starts the process,RESOLVE_FETCHING_USERS provides the new set of users, and REJECT_FETCHING_USERS is emitted if there is an error during fetching.

Let's see the action creators:

const startFetchingUsers = () => ({ type: START_FETCHING_USERS });
const resolveFetchingUsers = users => ({ type: RESOLVE_FETCHING_USERS, users });
const rejectFetchingUsers = error => ({ type: RESOLVE_FETCHING_USERS, error });

and the reducer:

const initState = {
 isFetching: false,
 users: [],
 error: null
}

const reducer = (state = initState, action) => {
 switch (action.type) {
   case START_FETCHING_USERS: return {
     ...state,
     isFetching: true
   };
   case RESOLVE_FETCHING_USERS: return {
     ...state,
     isFetching: false,
     users: action.users
   };
   case REJECT_FETCHING_USERS: return {
     ...state,
     isFetching: false,
     error: action.error
   };
   default: return state;
 }
}

All that remains is to implement the async thunk action creator:

const fetchUsers = () => async (dispatch, getState, { api }) => {
 dispatch(startFetchingUsers());
 try {
   const users = await api.get('/users');
   dispatch(resolveFetchingUsers(users));
 } catch (error) {
   dispatch(rejectFetchingUsers(error.message));
 }
}

Okay, we finished the Redux parts & we're almost done. Now we just need to connect the action creators and the state to the React component, and we are good to go!

For this simple feature, we needed to type a lot of lines for

  • action types,
  • action creators,
  • action handlers in the reducer,

and we have not written any view components yet.

This is especially inconvenient when we are involved in developing a large application with thousands of action types, action creators, and sub-reducers. It causes further difficulties too, because these resources are separated in many files, in different places. So if we want to trace the effect of an action, we have to follow the flow of data across many files, which makes it easy to get lost.

By looking around in npm, we are most likely to find a bunch of libraries/helpers/middlewares, which help us to avoid typing, but using them introduces some other type of typing overhead as we need to import them in every file.

Maybe we should think of a simpler way and consider which features we really need from Redux.

  1. Do we have to keep the data immutable? Mutability is the highway to hell. So this is not a solution. Especially not in React applications.

  2. Do we have to know the name of an action? In most cases, the actions are used only in single place. We do not need to keep them reproducible. What if you have a way to dispatch anonymous actions? This would be great.

  3. Do we have to be able to serialize the actions? There are use cases where you absolutely need to be serializable, but in most applications, you do not. So let’s continue with the assumption that this is not a requirement for now.

We should adhere to the first restriction, while we can safely forget the others.

We should transform the Redux concepts to make it possible that we can create actions briefly. We want to describe an action as a single function, either in place.

Repatch

Repatch drops action types and action creators from the definition set, and answers the question: “What if reducers were the payload of the actions?”. The creed of this library is:

DISPATCH REDUCERS

store.dispatch(state => ({ ...state, counter: state.counter + 1 }));

In this terminology, an action is a function that returns a reducer:

const increment = amount => state => ({
  ...state,
  counter: state.counter + amount
});

store.dispatch(increment(42));

Repatch also has a Store class that we can instantiate with the initial state:

import Store from 'repatch';

const store = new Store(initialState);

Repatch's interface is very similar as redux’s, therefore we can use it with the react-redux library. The dispatch and subscribe methods have the same signature as in the Redux's Store.

Middlewares and Async Actions

Repatch also has an interface for chaining middlewares. This is convenient for using your favorite async-action middleware. The package provides a thunk middleware - similar to redux-thunk - which is useful for creating async actions. If your reducer returns a function, it will be automatically considered an async action by the middleware. The dispatch and getState functions will be passed as arguments to it by the store instance. You can set up the middleware to provide one extra argument to. You can use that, for example to inject your client API library.

Let's see the example related to our use-case below:

const fetchUsers = () => _ => async (dispatch, getState, { api }) => {
 dispatch(state => ({ ...state, isFetching: true }));
 try {
   const users = await api.get('/users');
   dispatch(state => ({ ...state, users }));
 } catch (error) {
   dispatch(state => ({ ...state, error: error.message }));
 } finally {
   dispatch(state => ({ ...state, isFetching: false }))
 }
}

Using this thunk middleware shows the real power of repatch as we can describe async actions in only a few lines of code. As you can see, we did not need to define verbose action types, action creators and action handlers in the reducer, as we could simply dispatch an arrow function defined in place, thus creating an anonymous action. How cool is that? This makes it possible that actions either can also be created from a component.

All that remains is the Store instantiation with the initial state:

const store = new Store({
 isFetching: false,
 users: [],
 error: null
});

and somewhere dispatching the action:

store.dispatch(fetchUsers())

Let's see an other example:

const updateUser = delta => state => async (dispatch, getState, { api }) => {
 try {
   const editedUserId = getState().editedUser;
   dispatch(toggleSpinner(true));
   await api.put(`/users/${editedUserId}`, { body: delta });
   await dispatch(fetchUsers());
   dispatch(toggleSpinner(false));
 } catch (error) {
   dispatch(state => ({ ...state, isFetching: false, error: error.message }));
 }
};

You can see from the function signature that in this example the extra argument is our client API object, as I mentioned previously. Also, note that the reducer's state argument is not always satisfactory for reading the state because it is a momentary representation from the time when the action was fired. Therefore we need to use the getState function instead of state.

In this example, toggleSpinner is a regular synchronous action that we can dispatch. The api.put method is a simple async method to call the API, there is no obstacle in the way of awaiting for it. The line await dispatch(fetchUsers()) is a bit more interesting. Using redux-thunk we got used to embedding async actions within each other and waiting for them.

Sub-reducers

Sub-reducers in Redux

Redux's reducers are composable to form a hierarchical structure. This way we do not need to define one giant reducer, instead, we can separate them to smaller nested reducers. Combining reducers is not magic, we just create a reducer that reduces the parts one by one to an object using their sub-state.

const rootReducer = (state, action) => ({
 foo: fooReducer(state.foo, action),
 bar: barReducer(state.bar, action)
});

is equivalent to

const rootReducer = redux.combineReducers({
  foo: fooReducer,
  bar: barReducer
});

Sub-reducers in Repatch

Repatch also offers a way to combine sub-reducers. We just define a function that takes a nested reducer as argument, and returns a reducer that reduces the whole state:

const reduceFoo = fooReducer => state => ({
 ...state,
 foo: fooReducer(state.foo)
});

Now reducing the foo property is easy. Let's suppose we would like to set an x property in the foo object:

const setX = x => reduceFoo(state => ({ ...state, x }));

It will be really useful if the sub-reducer describes a deeply nested property:

const reduceFoo = reducer => state => ({
  ...state,
  bar: {
    ...state.bar,
    foo: reducer(state.bar.foo)
  }
});

Testing

How about testing? Writing unit tests for a reducer is simple:

import * as assert from 'assert';
import { changeName } from './actions';

// ...

it('changeName', () => {
 const state = { name: 'john' };
 const nextState = changeName('jack')(state);
 assert.strictEqual(nextState.name, 'jack');
});

Async actions are a bit more complicated because they take effect by depending on external resources such as the store instance and other APIs. But external resources always need to be mocked in all environments.

import Store, { thunk } from 'repatch';
import * as assert from 'assert';

const mockUsers = [{ username: 'john' }];
const mockApi = {
 getUsers: () => Promise.resolve(mockUsers)
}

// ...

it('fetchUsers', async () => {
 const state = { users: [] };
 const store = new Store(state)
   .addMiddleware(thunk.withExtraArgument({ api: mockApi }));
 await store.dispatch(fetchUsers());
 const nextState = store.getState();
 assert.deepEqual(nextState.users, mockUsers);
});

The TODO app

Every javascript library has a todo example, so repatch has one too. If you are looking for the TypeScript example, you can find it here.