Type-Safe Reducers using TypeScript - Part 2 - Introducing Redux Reducer Builder
This is a post in continuation to Type-Safe Reducers using TypeScript - Part 1.
In our last post, we have discussed the issues we face without proper type system and how TypeScript comes to rescue for JavaScript developers.
Today, we will see about a package that offers very rich type support for writing actions and building reducers in a redux based applications.
Before we start, let's assume that we want to build a to-do task manager app using redux. The users of this application can do the following operations,
- Create Task
- Update Task
- Mark Task as Completed
- Delete Task
- Save Tasks
- Restore Tasks
And a task object implements the following interface,
interface ITask {
id: string;
title: string;
completed?: boolean;
}
So, to create a new task we need title from user interface. That means the value for title should be supplied by our view layer of our application. But the values for id and completed need not be supplied by our view layer.
So, to simplify what to ask from our view layer we could define a factory method that creates the action to complete "Create Task" operation for us.
The factory method would look like below,
function createTaskAction(args: { title: string; }) {
return { type: 'CREATE-TASK', payload:{title: args.title} };
}
Similarly for the rest of our operation we would start creating factory methods that creates the respective actions for us. When our application's functionality grows and no. of actions also grows. Then we would end up lot of code boiler-plates.
So far we discussed about one side of the coin. On the other side we need to build reducer for these actions. Generally, in a redux based applications we are used to write one method with simple switch case state that acts as the gateway to respective action's reducer logic.
So in our case, our reducer finally may look like below,
function tasksReducer(state: {tasks: ITask[];}, action: AnyAction){
switch(action.type){
case: 'CREATE-TASK':
//...
break;
case: 'UPDATE-TASK':
//...
break;
case: 'MARK-TASK-AS-COMPLETED':
//...
break;
case: 'DELETE-TASK':
//...
break;
}
}
But if we see the action parameter's type, it is Generic. When we go inside each of our cases in that switch statement, we would be forced to assumed the type of payload property. Oh, wait our sweet IDE can't tell us that action object has payload property. Assumptions about types of action payloads starts to creep in.
Let's see how the package "@appsflare/redux-reducer-builder" could help us solve this problem through intuitive and simple way of creating actions and reducers along with rich design time type system support.
// file: task-actions.ts
import { createActionCreators } from '@appsflare/redux-reducer-builder';
export const TaskActions = createActionCreators({
namespace: 'CORE/TASKS',
actions: {
addTask: (args?: { title: string }) => args,
updateTask: (args?: { id: string, title: string }) => args,
markTaskAsCompleted: (args?: { id: string }) => args,
removeTask: (args?: { id: string }) => args,
loadTasksAsync: () => Promise.resolve(JSON.parse(localStorage.getItem('tasks') || '[]') as Array<{ id: string; title: string; }>)
}
});
And then the reducer,
// file: task-reducer.ts
import { buildReducer } from '@appsflare/redux-reducer-builder';
import { TaskActions } from './task-actions';
export const taskReducer = buildReducer(TaskActions, o => {
o.handlers.addTask((state, action) => {
return {
tasks: state.tasks.concat({ id: Date.now().toString(), ...action.payload })
};
});
o.handlers.updateTask((state, action) => {
const taskIndex = state.tasks.findIndex(i => i.id == a.payload.id);
if (!taskIndex) {
return state;
}
const tasks = [...state.tasks];
tasks[taskIndex] = { id: action.payload.id, title: action.payload.title }
return {
tasks
};
});
o.handlers.removeTask((state, action) => {
return {
tasks: state.tasks.filter(i => i.id !== action.payload.id)
};
});
o.handlers.loadTasksAsync({
fulfilled(state, action) {
return { tasks: action.payload.result };
}
});
}, initialState);
As we can see, the way we create actions and reducers got simplified to a great extent. And yet, we have not lost any type information here. Every handler that was added for an action brings in the action's full type definition.
As an added bonus, the redux-reducer-builder also supports asynchronous actions. Take a look at "loadTasksAsync" action and it's handler.
More bonus on the way, what if I want to dispatch a thunk that provides access to current state of the redux store, we have got that covered too,
const TaskThunks = createThunkCreators({
doCreate: (args?: { name: string }) => (dispatch, getState) => Promise.resolve(args!),
doUpdate: (args?: { name: string }) => (dispatch, getState) => { },
});
So here with thunk creators we could get access to "dispatch" and "getState" APIs of our redux store. With this we get the freedom to dispatch any actions of our choice. We can dispatch thunks as we dispatch actions. The only difference is that we cannot have a reducer for thunks.
That's all!! Now, creating type-safe reducer is a piece of cake with "@appsflare/redux-reducer-builder" :).
Thank you for reading my posts. If you have questions or feedback please post them below in the discussion box.