React Task App (Part 2)

Rahul Fernando
9 min readApr 5, 2022

This is the 2nd article on React Task App. In the previous article, we were able to implement all the UI components of the Task App. This article going to complete our Task app using redux and redux-saga with help of json-server.

Install the json-server package, which I have installed globally. Then create database/db.json file outside the src which is the root of the project and add below code to it.

{
“tasks”: [
{
“task”: “All about reactJs”,
“id”: 1
}
]
}

Open a separate terminal and navigate to the database folder you just created and run the below command.

json-server — port 3001 — watch db.json

Now we have access to our mock API. Create actions.ts, constant.ts, reducer.ts and saga.ts files inside reducer/tasks folder.

Let’s define the constants we need inside constant.ts

export const ADD_NEW_TASK_START = ‘ADD_NEW_TASK_START’;
export const ADD_NEW_TASK_SUCCESS = ‘ADD_NEW_TASK_SUCCESS’;
export const ADD_NEW_TASK_FAILURE = ‘ADD_NEW_TASK_FAILURE’;
export const FETCH_TASK_LIST_START = ‘FETCH_TASK_LIST_START’;
export const FETCH_TASK_LIST_SUCCESS = ‘FETCH_TASK_LIST_SUCCESS’;
export const FETCH_TASK_LIST_FAILURE = ‘FETCH_TASK_LIST_FAILURE’;
export const UPDATE_TASK_START = ‘UPDATE_TASK_START’;
export const UPDATE_TASK_SUCCESS = ‘UPDATE_TASK_SUCCESS’;
export const UPDATE_TASK_FAILURE = ‘UPDATE_TASK_FAILURE’;
export const DELETE_TASK_START = ‘DELETE_TASK_START’;
export const DELETE_TASK_SUCCESS = ‘DELETE_TASK_SUCCESS’;
export const DELETE_TASK_FAILURE = ‘DELETE_TASK_FAILURE’;
export const RESET_DELETE_TASK = ‘RESET_DELETE_TASK’;
export const SELECT_TASK = ‘SELECT_TASK’;
export const RESET_SELECT_TASK = ‘RESET_SELECT_TASK’;

We need to add a new task, fetch all tasks, select tasks, update selected tasks, and delete a task. For those let’s define our actions in actions.ts file.

import { ADD_NEW_TASK_FAILURE, ADD_NEW_TASK_START, ADD_NEW_TASK_SUCCESS, FETCH_TASK_LIST_START, FETCH_TASK_LIST_SUCCESS, FETCH_TASK_LIST_FAILURE, SELECT_TASK, RESET_SELECT_TASK, UPDATE_TASK_START, UPDATE_TASK_SUCCESS, UPDATE_TASK_FAILURE, DELETE_TASK_START, DELETE_TASK_SUCCESS, DELETE_TASK_FAILURE, RESET_DELETE_TASK } from “./constants”;export const addNewTaskStart = (payload: ITask) => ({
type: ADD_NEW_TASK_START,
payload,
});
export const addNewTaskSuccess = (payload: ITask) => ({
type: ADD_NEW_TASK_SUCCESS,
payload,
});
export const addNewTaskFailure = (payload: string) => ({
type: ADD_NEW_TASK_FAILURE,
payload,
});
export const fetchTaskListStart = () => ({
type: FETCH_TASK_LIST_START,
});
export const fetchTaskListSuccess = (payload: Array<ITask>) => ({
type: FETCH_TASK_LIST_SUCCESS,
payload,
});
export const fetchTaskListFailure = (payload: string) => ({
type: FETCH_TASK_LIST_FAILURE,
payload,
});
export const selectTask = (payload: ITask) => ({
type: SELECT_TASK,
payload,
});
export const resetSelectTask = () => ({
type: RESET_SELECT_TASK,
});
export const updateTaskStart = (payload: ITask) => ({
type: UPDATE_TASK_START,
payload,
});
export const updateTaskSuccess = (payload: ITask) => ({
type: UPDATE_TASK_SUCCESS,
payload,
});
export const updateTaskFailure = (payload: string) => ({
type: UPDATE_TASK_FAILURE,
payload,
});
export const deleteTaskStart = (payload: any) => ({
type: DELETE_TASK_START,
payload,
});
export const deleteTaskSuccess = (payload: string) => ({
type: DELETE_TASK_SUCCESS,
payload,
});
export const deleteTaskFailure = (payload: string) => ({
type: DELETE_TASK_FAILURE,
});
export const resetDeleteTask = () => ({
type: RESET_DELETE_TASK,
});

Now we need to define our reducers which are responsible to update states based on the action type. We need states for creating a new task, updating a task, deleting a task, displaying all tasks and selecting a single task. Create IAction interface in type/reducer.d.ts file and add define IAction interface as below.

export interface IAction {
type: any;
payload: any
}

Here is our reducer.ts file.

import { IAction } from ‘../../types/reducer’;
import {
ADD_NEW_TASK_FAILURE,
ADD_NEW_TASK_START,
ADD_NEW_TASK_SUCCESS,
DELETE_TASK_FAILURE,
DELETE_TASK_START,
DELETE_TASK_SUCCESS,
FETCH_TASK_LIST_FAILURE,
FETCH_TASK_LIST_START,
FETCH_TASK_LIST_SUCCESS,
RESET_DELETE_TASK,
RESET_SELECT_TASK,
SELECT_TASK,
UPDATE_TASK_FAILURE,
UPDATE_TASK_START,
UPDATE_TASK_SUCCESS,
} from ‘./constants’;
const init = {
newTaskData: {
loading: false,
data: null,
error: null,
},
taskListData: {
loading: false,
data: [],
error: null,
},
updateTaskData: {
loading: false,
data: null,
error: null,
},
deleteTaskData: {
loading: false,
data: null,
error: null,
},
selectedTask: null,
};
export default function tasks(state = init, { type, payload }: IAction) {
switch (type) {
case ADD_NEW_TASK_START:
return {
…state,
newTaskData: {
…state.newTaskData,
loading: true,
},
};
case ADD_NEW_TASK_SUCCESS:
return {
…state,
newTaskData: {
…state.newTaskData,
loading: false,
data: payload ? payload : null,
error: null,
},
};
case ADD_NEW_TASK_FAILURE:
return {
…state,
newTaskData: {
…state.newTaskData,
loading: false,
data: null,
error: payload ? payload : null,
},
};
case FETCH_TASK_LIST_START:
return {
…state,
taskListData: {
…state.taskListData,
loading: true,
},
};
case FETCH_TASK_LIST_SUCCESS:
return {
…state,
taskListData: {
…state.taskListData,
loading: false,
data: payload ? payload : [],
error: null,
},
};
case FETCH_TASK_LIST_FAILURE:
return {
…state,
taskListData: {
…state.taskListData,
loading: false,
data: [],
error: payload ? payload : null,
},
};
case UPDATE_TASK_START:
return {
…state,
updateTaskData: {
…state.updateTaskData,
loading: true,
},
};
case UPDATE_TASK_SUCCESS:
return {
…state,
updateTaskData: {
…state.updateTaskData,
loading: false,
data: payload ? payload : null,
error: null,
},
};
case UPDATE_TASK_FAILURE:
return {
…state,
updateTaskData: {
…state.updateTaskData,
loading: false,
data: null,
error: payload ? payload : null,
},
};
case DELETE_TASK_START:
return {
…state,
deleteTaskData: {
…state.deleteTaskData,
loading: true,
},
};
case DELETE_TASK_SUCCESS:
return {
…state,
deleteTaskData: {
…state.deleteTaskData,
loading: false,
data: payload,
error: null,
},
};
case DELETE_TASK_FAILURE:
return {
…state,
deleteTaskData: {
…state.deleteTaskData,
loading: false,
data: null,
error: payload,
},
};
case RESET_DELETE_TASK:
return {
…state,
deleteTaskData: init.deleteTaskData
};
case SELECT_TASK:
return {
…state,
selectedTask: payload,
};
case RESET_SELECT_TASK:
return {
…state,
selectedTask: init.selectedTask,
};
default:
return state;
}
}

Let’s move to saga.ts file which handles API calls. We’ll use the some of saga effects in this application. ‘Call’ is a function that creates a plain object describing the function call. The redux-saga middleware takes care of executing the function call and resuming the generator with the resolved response. ‘TakEvery’ is a function of each action dispatched to the store that matches the pattern. ‘Put’ creates an Effect description that instructs the middleware to schedule the dispatching of an action to the store.

import { call, put, takeEvery } from ‘redux-saga/effects’;
import {
ADD_NEW_TASK_START,
DELETE_TASK_START,
FETCH_TASK_LIST_START,
UPDATE_TASK_START,
} from ‘./constants’;
import {
addNewTaskSuccess,
addNewTaskFailure,
fetchTaskListSuccess,
fetchTaskListFailure,
updateTaskSuccess,
updateTaskFailure,
deleteTaskSuccess,
deleteTaskFailure,
} from ‘./actions’;
import { HTTP_METHODS } from ‘../../enum’;
const apiUrl = ‘http://localhost:3001/tasks';function service(method: HTTP_METHODS, data?: any) {
const api =
method === HTTP_METHODS.PUT || method === HTTP_METHODS.DELETE
? `${apiUrl}/${data.id}`
: apiUrl;
return fetch(api, {
method,
headers: {
‘Content-Type’: ‘application/json’,
},
body: data ? JSON.stringify({ task: data.task }) : null,
})
.then((response) => response.json())
.catch((error) => {
throw error;
});
}
function* createTask({ payload }: any): any {
try {
const response = yield call(service, HTTP_METHODS.POST, payload);
if (response) {
yield put(addNewTaskSuccess(response.task));
} else {
yield put(addNewTaskFailure(‘Something went wrong’));
}
} catch (error: any) {
yield put(addNewTaskFailure(‘Something went’));
}
}
function* fetchTaskList(): any {
try {
const response = yield call(service, HTTP_METHODS.GET);
if (response) {
yield put(fetchTaskListSuccess(response));
} else {
yield put(fetchTaskListFailure(‘Something went wrong’));
}
} catch (error: any) {
yield put(fetchTaskListFailure(‘Something went’));
}
}
function* updateTask({ payload }: any): any {
try {
const response = yield call(service, HTTP_METHODS.PUT, payload);
if (response) {
yield put(updateTaskSuccess(response));
} else {
yield put(updateTaskFailure(‘Something went wrong’));
}
} catch (error: any) {
yield put(updateTaskFailure(‘Something went’));
}
}
function* deleteTask({ payload }: any): any {
try {
yield call(service, HTTP_METHODS.DELETE, payload);
yield put(deleteTaskSuccess(‘Deleted !’));
} catch (error: any) {
yield put(deleteTaskFailure(‘Something went’));
}
}
export default function* taskSaga() {
yield takeEvery(ADD_NEW_TASK_START, createTask);
yield takeEvery(FETCH_TASK_LIST_START, fetchTaskList);
yield takeEvery(UPDATE_TASK_START, updateTask);
yield takeEvery(DELETE_TASK_START, deleteTask);
}

Now we need to combine all the sagas and reducer. In this, we have only one reducer and one saga. But if the application gets bigger you want to maintain separate reducer files and saga files. In that case, you want to combine those for one. Create root-reducer.ts and saga-reducer.ts file add below code snippers

root-reducer.ts

import { combineReducers } from ‘redux’;
import tasks from ‘./tasks/reducer’;
const rootReducer = combineReducers({
tasks
});
export default rootReducer;

root-saga.ts

import { all } from ‘redux-saga/effects’;
import taskSaga from ‘./tasks/saga’;
export default function* rootSaga() {
yield all([taskSaga()]);
}

Go to store/index.ts file adds the below configuration to your file.

import { createStore, compose, applyMiddleware } from ‘redux’;
import rootReducer from ‘../redux/root-reducer’;
import rootSage from ‘../redux/root-saga’;
import createSagaMiddleware from ‘redux-saga’;
declare global {
interface Window {
__REDUX_DEVTOOLS_EXTENSION_COMPOSE__?: typeof compose;
}
}
const sagaMiddleware = createSagaMiddleware();
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const store = createStore(
rootReducer,
composeEnhancers(applyMiddleware(sagaMiddleware))
);
sagaMiddleware.run(rootSage);
export default store;

Now we need to provide access to the store. Todo that goes to index.ts file replace ReactDOM.render with

import React from ‘react’;
import ReactDOM from ‘react-dom’;
import ‘./index.css’;
import App from ‘./App’;
import reportWebVitals from ‘./reportWebVitals’;
import { Provider } from ‘react-redux’;
import store from ‘./store’;
ReactDOM.render(
<Provider store={store}>
<React.StrictMode>
<App />
</React.StrictMode>
</Provider>,
document.getElementById(‘root’)
);
reportWebVitals();

Now our application has access to the store. Now we can complete our operations. Let’s complete new task operation. Go to pages/newTask/index.tsx file and import actions we need from redux/tasks/actions and complete new task opeation.

import React, { useEffect } from ‘react’;
import { useDispatch, useSelector } from ‘react-redux’;
import { useFormik } from ‘formik’;
import * as Yup from ‘yup’;
import CardContent from ‘@mui/material/CardContent’;
import CardActions from ‘@mui/material/CardActions’;
// actions
import {
addNewTaskStart,
fetchTaskListStart,
fetchTaskListSuccess,
resetSelectTask,
updateTaskStart,}
from '../../redux/tasks/actions';
// components
import Card from ‘../../components/card/card’;
import InputField from ‘../../components/inputField/inputField’;
import Button from ‘../../components/button/button’;
// hooks
import useChange from ‘../../hooks/use-change’;
// enum
import { BUTTON_TYPES, INPUT_TYPES } from ‘../../enum’;
// style
import classes from ‘./style.module.css’;
const NewTask: React.FC = () => {
const dispatch = useDispatch();
const newTaskData = useSelector(
(state: ITaskAddSelector) => state.tasks.newTaskData
);
useEffect(() => {
if (newTaskData) {
formik.resetForm();
dispatch(fetchTaskListStart());
}
}, [newTaskData])
const formik = useFormik({
initialValues: {
task: selectedTask ? selectedTask.task : ‘’,
},
enableReinitialize: true,
validationSchema: Yup.object().shape({
task: Yup.string().trim().required(‘Please enter valid task’),
}),
onSubmit: (values) => {
dispatch(addNewTaskStart({ task: values.task }));x`
},
});
return (
<Card className={classes.container}>
<CardContent>
<form onSubmit={formik.handleSubmit}>
<InputField
label=”Enter task”
name=”task”
type={INPUT_TYPES.TEXT}
value={formik.values.task}
error={formik.errors.task && formik.touched.task ? true : false}
helperText={
formik.errors.task && formik.touched.task
? formik.errors.task
: ‘’
}
onChange={formik.handleChange}
/>
<CardActions className={classes[‘card-actions’]}>
<Button
label=‘SAVE’
type={BUTTON_TYPES.SUBMIT}
/>
</CardActions>
</form>
</CardContent>
</Card>
);
};
export default NewTask;

We have used useSelector and useDispatch hooks from react-redux, if a new task is added successfully we fetch our list again. Let’s move to our list now.

import React, { useEffect, useState } from ‘react’;
import { useDispatch, useSelector } from ‘react-redux’;
import List from ‘@mui/material/List’;
// actions
import {
fetchTaskListStart,
fetchTaskListSuccess,
} from ‘../../redux/tasks/actions’;
// components
import TaskItem from ‘./taskItem’;
import Card from ‘../../components/card/card’;
const TaskList: React.FC = () => {
const dispatch = useDispatch();
const { updateList } = useChange();
const taskList = useSelector(
(state: ITaskListSelector) => state.tasks.taskListData.data
);
useEffect(() => {
dispatch(fetchTaskListStart());
}, []);
const taskClickHandler = (task: ITask, e: React.MouseEvent) => {

};
const taskDeleteHandler = (task: ITask, e: React.MouseEvent) => {
dispatch(deleteTaskStart({ id: task.id
})); };
return (
<Card>
<List>
{taskList && taskList.length > 0 && taskList.map((task) => (
<TaskItem
task={task.task}
onClick={taskClickHandler.bind(null, task)}
onDelete={taskDeleteHandler.bind(null, task)}
key={task.id}
/>
))}
</List>
</Card>
);
};
export default TaskList;

We fetch our task list in the component did mount, using useSelector hook we access to fetched list. taskClickHandler have been defined to select a task when a user clicks on the pen icon. taskDeleteHandler is defined to trigger deleteTaskStart action by passing task id as the payload. We need to update our list when a task is updated or deleted. We can fetch the list again when the above-mentioned action happens. But I’m going to skip fetching and instead I’m going to update the list using a custom hook.

Create hooks/use-change.tsx file. Add the below code.

const useChange = () => {
const updateList = (
action: string,
taskList: Array<ITask>,
task: ITask,
value?: string
) => {
let tempList = […taskList];
if (action === ‘UPDATE’) {
const index = tempList.findIndex((obj) => obj.id === task.id);
tempList[index] = { …task, task: value ? value : ‘’ };
}
if (action === ‘DELETE’) {
tempList = taskList.filter((obj) => obj.id !== task.id)
}
return tempList;
};
return { updateList };
};
export default useChange;

It is a convention to a ‘use’ prefix when naming a custom hook. Our custom hook has a function that will accept action, task object, task list and the value which an optional. Based on the action we have implemented the task updating logic and finally return updated list. Now we can use this hook when a task is updated and deleted. This will help to remove code duplication.

Now let’s use this hook. When a task gets deleted we need to update the list.

import useChange from '../../hooks/use-change';useEffect(() => {    
if (deletedTaskData) {
dispatch(fetchTaskListSuccess(updateList('DELETE', taskList,
selectedTask))
);
dispatch(resetDeleteTask());
}
}, [deletedTaskData]);

Let’s implement our update logic. First, we need to select a task and the selected task should appear in the input field and the button should change to ‘UPDATE’. Dispatch selectTask action by passing selected task object as the payload inside taskClickHandler function.

const taskClickHandler = (task: ITask, e: React.MouseEvent) => {
dispatch(selectTask(task));
};

Now we can access selected tasks using useSelector hook. In the pages/newTask/index.tsx file

const selectedTask = useSelector((state: ITaskSelectedSelector)
=> state.tasks.selectedTask );

Now our button should change ‘SAVE’ to ‘UPDATE’ if there is a task selected.

<Button              
label={selectedTask ? 'UPDATE' : 'SAVE'}
type={BUTTON_TYPES.SUBMIT}
/>

Also in the formik, now we need to bind the selected task in the initial values.

initialValues: {      
task: selectedTask ? selectedTask.task : '',
},

Our logic onSubmit method should look like this now.

onSubmit: (values) => {      
if (selectedTask) {
dispatch(updateTaskStart({ id: selectedTask.id, task:
values.task }));
} else {
dispatch(addNewTaskStart({ task: values.task }));
}
},

If the task is updated successfully we need to update our list and clear the input field. To achieve that behaviour add below code to pages/newTask/index.tsx

const taskList = useSelector((state: ITaskListSelector) => state.tasks.taskListData.data  );   useEffect(() => {    
if (updateTaskData) {
dispatch(fetchTaskListSuccess(updateList('UPDATE', taskList,
selectedTask, formik.values.task)));
dispatch(resetSelectTask());
formik.resetForm();
}
}, [updateTaskData]);

Run your application and json-server in order to test our implementation. You should able to create new task, delete and update a task.

From this article, we were able to complete task app. You can find the completed project from here https://github.com/RahulFernando/react-task-app

--

--