DEV Community

alanst32
alanst32

Posted on • Updated on

Clean React-Redux, Redux-Saga client-side solution.

Hello there!

On my previous post MERN client-side I have talked about a MERN client application with React, Typescript and the use of RxJs as an observables solution to collect and subscribe api response data.
Then I got on my mind, "How about Redux? Is it still worth?"

As we know Redux is a state manager container for JavaScript apps. It is a robust framework that allows you to have state control and information in all components/containers of your application. It works like a flow with a single store, it can be used in any environment like react, angular 1/2, vanilla etc.

And to support the use of Redux in React we also have React-Redux. A library that allow us to keep Redux solution up to date with React modern approaches. Through React Hooks from React-Redux we can access and control the store. Is without saying that without React-Redux I would not recommend the use of Redux in applications today.

On that thought I have decided to create a different MERN client-side solution with React and Typescript but not this this time with Redux and React-Redux.

And to make the application even more robust I am using Redux-Saga, which is basically a Redux side effect manager. Saga enables approaches to take parallel executions, task concurrency, task cancellation and more. You can also control threads with normal Redux actions. Comparing with React-Thunk, Saga it may seems complex at first but is a powerful solution. (But that's a talk for another post right ;) )

EDIT: As you may know, there are modern approaches to implement Redux on your application today. I have decided to write/keep this post in the "old" way in order o not cover multiple topics in the same article, with that on mind we could easily discuss new Saga/Redux approach in another article since the base is covered.

Now, without stretching too far, let's code!

1 - Client Project.

As this application is a similar solution from my previous post, I won't focus on the Node, Typescript and Webpack configuration. But exclusively on the Redux state flow between the CRUD operations.

Project structure

Alt Text

2 - Redux Flow.

As we know for our Redux flow we need to set:

  • Redux Actions
  • Redux Reducer
  • Redux Selector
  • Redux Store

And to work with the asynchronous calls to back end I am going to use a middleware layer.

  • Redux Saga layer

Actions

src/redux/actions/studentActions.ts

import StudentModel, { StudentRequest } from "@models/studentModel";

// TYPES
export enum STUDENT_ACTIONS {
    GET_STUDENTS_REQUEST = 'GET_STUDENTS_REQUEST',
    GET_STUDENTS_SUCCESS = 'GET_STUDENTS_SUCCESS',
    GET_STUDENTS_ERROR = 'GET_STUDENTS_ERROR',
    INSERT_STUDENT_REQUEST = 'INSERT_STUDENT_REQUEST',
    INSERT_STUDENT_SUCCESS = 'INSERT_STUDENT_SUCCESS',
    INSERT_STUDENT_ERROR = 'INSERT_STUDENT_ERROR',
    UPDATE_STUDENT_REQUEST = 'UPDATE_STUDENT_REQUEST',
    UPDATE_STUDENT_SUCCESS = 'UPDATE_STUDENT_SUCCESS',
    UPDATE_STUDENT_ERROR = 'UPDATE_STUDENT_ERROR',
    DELETE_STUDENT_REQUEST = 'DELETE_STUDENT_REQUEST',
    DELETE_STUDENT_SUCCESS = 'DELETE_STUDENT_SUCCESS',
    DELETE_STUDENT_ERROR = 'DELETE_STUDENT_ERROR',
    ADD_SKILLS_REQUEST = 'ADD_SKILLS_REQUEST',
    ADD_SKILLS_SUCCESS = 'ADD_SKILLS_SUCCESS',
    ADD_SKILLS_ERROR = 'ADD_SKILLS_ERROR',
};

interface LoadingState {
  isLoading: boolean,
}

interface CommonErrorPayload {
  error?: {
      message: string,
      type: string,
  },
}

// ACTION RETURN TYPES
export interface GetStudentsRequest {
  type: typeof STUDENT_ACTIONS.GET_STUDENTS_REQUEST;
  args: StudentRequest,
};

export interface GetStudentsSuccess {
  type: typeof STUDENT_ACTIONS.GET_STUDENTS_SUCCESS;
  payload: StudentModel[],
};

export interface GetStudentsError {
  type: typeof STUDENT_ACTIONS.GET_STUDENTS_ERROR;
  payload: CommonErrorPayload,
};

export interface InsertStudentRequest {
  type: typeof STUDENT_ACTIONS.INSERT_STUDENT_REQUEST;
  args: StudentModel,
}

export interface InsertStudentSuccess {
  type: typeof STUDENT_ACTIONS.INSERT_STUDENT_SUCCESS,
};

export interface InsertStudentError {
  type: typeof STUDENT_ACTIONS.INSERT_STUDENT_ERROR;
  payload: CommonErrorPayload,
};

export interface UpdateStudentRequest {
  type: typeof STUDENT_ACTIONS.UPDATE_STUDENT_REQUEST;
  args: StudentModel,
};

export interface UpdateStudentSuccess {
  type: typeof STUDENT_ACTIONS.UPDATE_STUDENT_SUCCESS,
};

export interface UpdateStudentError {
  type: typeof STUDENT_ACTIONS.UPDATE_STUDENT_ERROR;
  payload: CommonErrorPayload,
};

export interface DeleteStudentRequest {
  type: typeof STUDENT_ACTIONS.DELETE_STUDENT_REQUEST;
  args: string[],
};

export interface DeleteStudentSuccess {
  type: typeof STUDENT_ACTIONS.DELETE_STUDENT_SUCCESS,
};

export interface DeleteStudentError {
  type: typeof STUDENT_ACTIONS.DELETE_STUDENT_ERROR;
  payload: CommonErrorPayload,
};

// ACTIONS
export const getStudentsRequest = (args: StudentRequest): GetStudentsRequest  => ({
  type: STUDENT_ACTIONS.GET_STUDENTS_REQUEST,
  args,
}); 

export const getStudentsSuccess = (payload: StudentModel[]): GetStudentsSuccess => ({
  type: STUDENT_ACTIONS.GET_STUDENTS_SUCCESS,
  payload,
});

export const getStudentsError = (payload: CommonErrorPayload): GetStudentsError => ({
  type: STUDENT_ACTIONS.GET_STUDENTS_ERROR,
  payload,
});

export const insertStudentRequest = (args: StudentModel): InsertStudentRequest => ({
  type: STUDENT_ACTIONS.INSERT_STUDENT_REQUEST,
  args,
});

export const insertStudentSuccess = (): InsertStudentSuccess => ({
  type: STUDENT_ACTIONS.INSERT_STUDENT_SUCCESS,
});

export const insertStudentError = (payload: CommonErrorPayload): InsertStudentError => ({
  type: STUDENT_ACTIONS.INSERT_STUDENT_ERROR,
  payload,
});

export const updateStudentRequest = (args: StudentModel): UpdateStudentRequest => ({
  type: STUDENT_ACTIONS.UPDATE_STUDENT_REQUEST,
  args,
});

export const updateStudentSuccess = (): UpdateStudentSuccess => ({
  type: STUDENT_ACTIONS.UPDATE_STUDENT_SUCCESS,
});

export const updateStudentError = (payload: CommonErrorPayload): UpdateStudentError => ({
  type: STUDENT_ACTIONS.UPDATE_STUDENT_ERROR,
  payload,
});

export const deleteStudentRequest = (args: string[]): DeleteStudentRequest => ({
  type: STUDENT_ACTIONS.DELETE_STUDENT_REQUEST,
  args,
});

export const deleteStudentSuccess = (): DeleteStudentSuccess => ({
  type: STUDENT_ACTIONS.DELETE_STUDENT_SUCCESS,
});

export const deleteStudentError = (payload: CommonErrorPayload): DeleteStudentError => ({
  type: STUDENT_ACTIONS.DELETE_STUDENT_ERROR,
  payload,
});
Enter fullscreen mode Exit fullscreen mode
Understanding the code.

No mystery here. On a redux flow we need to set which actions will be part of the state control, and for each CRUD operation I have set a state of REQUEST, SUCCESS and ERROR result. Which you will understand the reason why following below.
One interesting point here is since I am coding in Typescript I can benefit of Enum and Types usage to make our code clearer and more organised.

Reducer

src/redux/reducer/studentReducer.ts

import { STUDENT_ACTIONS } from "redux/actions/studentActions";

const initialState = {
    isGetStudentsLoading: false,
    data: [],
    getStudentsError: null,
    isInsertStudentLoading: false,
    insertStudentError: null,
    isUdpateStudentLoading: false,
    updateStudentError: null,
    isDeleteStudentLoading: false,
    deleteStudentError: null,
};

export default (state = initialState, action) => {
    switch(action.type) {
        case STUDENT_ACTIONS.GET_STUDENTS_REQUEST:
            return {
                ...state,
                isGetStudentsLoading: true,
                getStudentsError: null,
            };
        case STUDENT_ACTIONS.GET_STUDENTS_SUCCESS:
            return {
                ...state,
                isGetStudentsLoading: false,
                data: action.payload,
                getStudentsError: null,
            }; 
        case STUDENT_ACTIONS.GET_STUDENTS_ERROR:
            return {
                ...state,
                isGetStudentsLoading: false,
                data: [],
                getStudentsError: action.payload.error,
            };
        // INSERT
        case STUDENT_ACTIONS.INSERT_STUDENT_REQUEST:
            return {
                ...state,
                isInsertStudentLoading: true,
                insertStudentError: null,
            };
        case STUDENT_ACTIONS.INSERT_STUDENT_ERROR:
            return {
                ...state,
                isInsertStudentLoading: false,
                insertStudentError: action.payload.error,
            };
        // UPDATE
        case STUDENT_ACTIONS.UPDATE_STUDENT_REQUEST:
            return {
                ...state,
                isUdpateStudentLoading: true,
                updateStudentError: null,
            };
        case STUDENT_ACTIONS.UPDATE_STUDENT_ERROR:
            return {
                ...state,
                isUdpateStudentLoading: false,
                updateStudentError: action.payload.error,
            };
        // DELETE
        case STUDENT_ACTIONS.DELETE_STUDENT_REQUEST:
            return {
                ...state,
                isDeleteStudentLoading: true,
                deleteStudentError: null,
            }; 
        case STUDENT_ACTIONS.DELETE_STUDENT_ERROR:
            return {
                ...state,
                isDeleteStudentLoading: false,
                deleteStudentError: action.payload.error,
            };
        default: 
            return {
                ...initialState,
            }
    }
}
Enter fullscreen mode Exit fullscreen mode

src/redux/reducer/rootReducer.ts

import { combineReducers } from "redux";
import studentReducer from "./studentReducer";

const rootReducer = combineReducers({
    entities: combineReducers({
        student: studentReducer,
    }),
});

export type AppState = ReturnType<typeof rootReducer>;

export default rootReducer;
Enter fullscreen mode Exit fullscreen mode
Understanding the code.

Reducers are functions that takes the current state and an action as argument, and return a new state result. In other words, (state, action) => newState.

And in the code above I am setting how the Student state model is going to be according to each action received. As you can see the whole state is not being overwritten, but just the necessary attributes according to the action.

This application only has one reducer, but in most of the cases you will break down your reducers in different classes. To wrap them together we have the rootReducer class. Which basically combines all the reducers in the state.

Selector

In simple words, a "selector" is a function that accepts the state as an argument and returns a piece of data that you desire from the store.
But of course it has more finesse than that, it is an efficient way to keep the store at minimal and is not computed unless one of its arguments changes.

src/redux/selector/studentSelector.ts

import { get } from 'lodash';
import { createSelector } from 'reselect';
import { AppState } from '@redux/reducer/rootReducer';

const entity = 'entities.student';

const getStudentsLoadingState = (state: AppState) => get(state, `${entity}.isGetStudentsLoading`, false);
const getStudentsState = (state:  AppState) => get(state, `${entity}.data`, []);
const getStudentsErrorState = (state: AppState) => get(state, `${entity}.getStudentsError`);
export const isGetStudentsLoading = createSelector(getStudentsLoadingState, (isLoading) => isLoading);
export const getStudents = createSelector(getStudentsState, (students) => students);
export const getStudentsError = createSelector(getStudentsErrorState, (error) => error);

const insertStudentLoadingState = (state: AppState) => get(state, `${entity}.isInsertStudentLoading`, false);
const insertStudentErrorState = (state: AppState) => get(state, `${entity}.insertStudentError`);
export const isInsertStudentLoading = createSelector(insertStudentLoadingState, (isLoading) => isLoading);
export const insertStudentError = createSelector(insertStudentErrorState, (error) => error);

const updateStudentLoadingState = (state: AppState) => get(state, `${entity}.isUdpateStudentLoading`, false);
const updateStudentErrorState = (state: AppState) => get(state, `${entity}.updateStudentError`);
export const isUpdateStudentLoading = createSelector(updateStudentLoadingState, (isLoading) => isLoading);
export const updateStudentError = createSelector(updateStudentErrorState, (error) => error);

const deleteStudentLoadingState = (state: AppState) => get(state, `${entity}.isDeleteStudentLoading`, false);
const deleteStudentErrorState = (state: AppState) => get(state, `${entity}.deleteStudentError`);
export const isDeleteStudentLoading = createSelector(deleteStudentLoadingState, (isLoading) => isLoading);
export const deleteStudentError = createSelector(deleteStudentErrorState, (error) => error);

const isAddSkillsLoadingState = (state: AppState) => get(state, `${entity}.isAddSkillsLoading`, false);
const addSkillErrorState = (state: AppState) => get(state, `${entity}.addSkillsError`);
export const isAddSkillsLoading = createSelector(isAddSkillsLoadingState, (isLoading) => isLoading);
export const addSkillsError = createSelector(addSkillErrorState, (error) => error);
Enter fullscreen mode Exit fullscreen mode
Understanding the code.

With the selector concept on mind, we can take from the code above is that we are returning the desire part of the store we need according to the function created.
For instance in getStudentsLoadingState I don't need to return the whole store to the caller, but only the flag that indicates whether the students are being loaded instead.

Store

The Redux store brings together the state, actions and reducers to the application. Is an immutable object tree that holds the current application state. Is through the store we will access the state info and dispatch actions to update its state information. Redux can have only a single store in your application.
src/redux/store/store.ts

import { createStore, applyMiddleware } from 'redux';
import createSagaMiddleware from '@redux-saga/core';
import { composeWithDevTools } from 'redux-devtools-extension';
import rootReducer from '../reducer/rootReducer';
import logger from 'redux-logger';
import { rootSaga } from '@redux/saga/rootSaga';

const initialState = {};
const sagaMiddleware = createSagaMiddleware();

const store = createStore(rootReducer, initialState, composeWithDevTools(applyMiddleware(sagaMiddleware, logger)));

sagaMiddleware.run(rootSaga)

export default store;
Enter fullscreen mode Exit fullscreen mode
Understanding the code.

For Store creation, it is required to set the Reducer or the Reducers combined and the initial state of the application.

And if you are using a middleware like I am, the middleware is also required to be set into the store. In this case is the class rootSaga which I am describing below.

Saga

According to Saga website:

In redux-saga, Sagas are implemented using Generator functions. To express the Saga logic, we yield plain JavaScript Objects from the Generator. We call those Objects Effects. ...You can view Effects like instructions to the middleware to perform some operation (e.g., invoke some asynchronous function, dispatch an action to the store, etc.).

With Saga we can instruct the middleware to fetch or dispatch data according to an action for example. But of course is more complex than that, but don't worry I will break down and explain the code below into pieces.

With Saga I can set the application to dispatch or fetch APIS according to the action received.
src/redux/saga/studentSaga.ts

import { all, call, put, takeLatest, takeLeading } from "redux-saga/effects";
import StudentModel, { StudentRequest } from '@models/studentModel';
import { formatDate } from '@utils/dateUtils';
import { get } from 'lodash';
import axios from 'axios';
import { isEmpty } from 'lodash';
import { deleteStudentError, getStudentsError, getStudentsRequest, getStudentsSuccess, insertStudentError, STUDENT_ACTIONS, updateStudentError } from "@redux/actions/studentActions";

// AXIOS
const baseUrl = 'http://localhost:3000';
const headers = { 
    'Content-Type': 'application/json',
    mode: 'cors',
    credentials: 'include'
};

const axiosClient = axios;
axiosClient.defaults.baseURL = baseUrl;
axiosClient.defaults.headers = headers;

const getStudentsAsync = (body: StudentRequest) => {
    return axiosClient.post<StudentModel[]>(
        '/student/list', 
        body
    );
}

function* getStudentsSaga(action) {
    try {
        const args = get(action, 'args', {})
        const response = yield call(getStudentsAsync, args);
        yield put(getStudentsSuccess(response.data));
    } catch(ex: any) {
        const error = {
            type: ex.message, // something else can be configured here
            message: ex.message,
        };
        yield put(getStudentsError({error}));
    }
}

const insertStudentsAsync = async (body: StudentModel) => {
    return axiosClient.post(
        '/student',
        body
    )
}

function* insertStudentSaga(action) {
    try {
        const studentModel = get(action, 'args');
        if (studentModel == null) {
            throw new Error('Request is null');
        }
        yield call(insertStudentsAsync, studentModel);

        const getAction = {
            type: STUDENT_ACTIONS.GET_STUDENTS_REQUEST,
            args: {},
        };
        yield call(getStudentsSaga, getAction);
    } catch(ex: any) {
        const error = {
            type: ex.message, // something else can be configured here
            message: ex.message,
        };
        yield put(insertStudentError({error}));
    }
};

const updateStudentAsync = async (body: StudentModel) => {
    return axiosClient.put(
        '/student',
        body
    );
};

/**
 * 
 * @param action {type, payload: StudentModel}
 */
function* updateStudentSaga(action) {
    try {
        const studentModel = get(action, 'args');
        if (studentModel == null) {
            throw new Error('Request is null');
        };
        yield call(updateStudentAsync, studentModel);

        const getStudentRequestAction = getStudentsRequest({});
        yield call(getStudentsSaga, getStudentRequestAction);
    } catch(ex: any) {
        const error = {
            type: ex.message, // something else can be configured here
            message: ex.message,
        };
        yield put(updateStudentError({error}));
    }
};

const deleteStudentsAsync = async (ids: string[]) => {
    return axiosClient.post(
        '/student/inactive',
        {ids}
    );
};

/**
 * 
 * @param action {type, payload: string[]}
 */
 function* deleteStudentSaga(action) {
    try {
        const ids = get(action, 'args');
        if (isEmpty(ids)) {
            throw new Error('Request is null');
        };
        yield call(deleteStudentsAsync, ids);

        const getStudentRequestAction = getStudentsRequest({});
        yield call(getStudentsSaga, getStudentRequestAction);
    } catch(ex: any) {
        const error = {
            type: ex.message, // something else can be configured here
            message: ex.message,
        };
        yield put(deleteStudentError({error}));
    }
};

function* studentSaga() {
    yield all([
        takeLatest(STUDENT_ACTIONS.GET_STUDENTS_REQUEST, getStudentsSaga),
        takeLeading(STUDENT_ACTIONS.INSERT_STUDENT_REQUEST, insertStudentSaga),
        takeLeading(STUDENT_ACTIONS.UPDATE_STUDENT_REQUEST, updateStudentSaga),
        takeLeading(STUDENT_ACTIONS.DELETE_STUDENT_REQUEST, deleteStudentSaga),
    ]);
}

export default studentSaga;
Enter fullscreen mode Exit fullscreen mode
Understanding the code.

Let's break into pieces here:

1 - Exported function studentSaga().

To put it simple, I am telling SAGA to wait for an action and then to perform or call a function. For instance when GET_STUDENTS_REQUEST is dispatched by Redux, I am telling SAGA to call getStudentsSaga method.
But in order to achieve that I have to use the SAGA API, in specific the methods:

  • takeLatest: Forks a saga on each action dispatched to the store that matches the pattern. And automatically cancels any previous saga task started previously if it's still running. In other words, if GET_STUDENTS_REQUEST is dispatched multiple times, SAGA will cancel the previous fetch and create a new one.
  • takeLeading: The difference here is that after spawning a task once, it blocks until spawned saga completes and then starts to listen for a pattern again.
  • yieldAll: Creates an Effect that instructs Saga to run multiple Effects in parallel and wait for all of them to complete. Here we set our actions to the attached Saga fork method to run in parallel in the application.
2 - Updating the Store with SAGA_.

Now that the (action/methods) are attached to Saga effects, we can proceed to the creation of effects in order to call APIS or update the Redux Store.

3 - getStudentsSaga()_ method.

More SAGA API is used here:

  • yield call: Creates an Effect that calls the function attached with args as arguments. In this case, the function called is an Axios API POST that returns a Promise. And since is a Promise, Saga suspends the generator until the Promise is resolved with response value, if the Promise is rejected an error is thrown inside the Generator.
  • yield put: Here, I am setting the store with the new Student list data, by creating an Effect that instructs Saga to schedule an action to the store. This dispatch may not be immediate since other tasks might lie ahead in the saga task queue or still be in progress. You can, however expects that the store will be updated with the new state value.

The rest of the class is more of the same flow, I operate the CRUD methods accordingly to the logic and use the same Saga effects necessary to do it.

But Saga offers way more possibilities, don't forget to check it out its API reference for more options.

4 rootSaga.

By this time you might have been wondering, "Where is the rootSaga specified on the Store?".

Below we have the rootSaga class, which follows the same principle as rootReducer. Here we combines all Saga classes created on the application.

src/redux/saga/rootSaga.ts


import { all, fork } from "redux-saga/effects";
import studentSaga from "./studentSaga";

export function* rootSaga() {
    yield all([fork(studentSaga)]);
};
Enter fullscreen mode Exit fullscreen mode

3 - Hook up Redux with React.

Now that all redux flow is set, is time to hoop up with React Components, to do that we just need to attach the Redux Store as a provider to the application.

src/index.tsx

import * as React from "react";
import * as ReactDOM from "react-dom";
import App from 'App';
import { Provider } from 'react-redux';
import store from "@redux/store/store";

ReactDOM.render(
    <Provider store={store}>
        <App/>
    </Provider>, 
    document.getElementById('root')
);
Enter fullscreen mode Exit fullscreen mode

4 - Use of Redux on Components.

For last, we now are able to consume state and dispatch actions from/to Redux, at first we will dispatch an action to tell Redux and Saga to fetch students data.

Note: For the purpose of this article and to focus on Redux I have shortened the code in areas not related to Redux. However, if would be able to check the whole code, you can check tis Git Repository, the link is by the end of this post.

Fetching data.

src/components/home/index.tsx

import React, { useEffect, useState } from "react";
import _ from 'lodash';
import StudentModel, { StudentRequest } from "@models/studentModel";
import StudentForm from "@app/studentForm";
import StudentTable from "@app/studentTable";
import { useDispatch } from "react-redux";
import { createStyles, makeStyles } from '@mui/styles';
import { Theme } from '@mui/material';
import { getStudentsRequest } from "@redux/actions/studentActions";

const useStyles = makeStyles((theme: Theme) =>
    createStyles({...}),
);

export default function Home() {
    const classes = useStyles();
    const dispatch = useDispatch();
    const emptyStudentModel: StudentModel = {
        _id: '',
        firstName: '',
        lastName: '',
        country: '',
        dateOfBirth: '',
        skills: []
    };

    useEffect(() => {
        const args: StudentRequest = {
            name: '',
            skills: [],
        };
        dispatch(getStudentsRequest(args));
    }, []);

    return (
        <div className={classes.home}>
            <StudentForm></StudentForm>   
            <StudentTable></StudentTable>
        </div>
    );
}
Enter fullscreen mode Exit fullscreen mode
Understanding the code.

With the new updates on React and React-Redux framework we can now use specific hooks on functional components to manage our state with Redux.
On the code above through the hook useEffect an action is dispatched to fetch Students data.

  • useDispatch: This hooks replicates the old mapDispatchToProps method, which is to set dispatch actions to the redux store. And since the code is in typescript, we can take the advantages of passing actions that are already mapped by interfaces. But underneath what is happening is the same as:
dispatch({
  type: 'GET_STUDENTS_REQUEST',
  args: {
    name: '',
    skills: []
  }
})
Enter fullscreen mode Exit fullscreen mode
Saving and reloading state data.

Now that the data is loaded, we can proceed with the rest of CRUD operations.

src/components/studentForm/index.tsx

import { Button, TextField, Theme } from '@mui/material';
import { createStyles, makeStyles } from '@mui/styles';
import React, { useState } from "react";
import { Image, Jumbotron } from "react-bootstrap";
import logo from '@assets/svg/logo.svg';
import StudentModel from "@models/studentModel";
import { useSelector } from "react-redux";
import { isEmpty } from 'lodash';
import { getStudents } from "@redux/selector/studentSelector";
import { insertStudentRequest } from "@redux/actions/studentActions";
import { useDispatch } from "react-redux";

const useStyles = makeStyles((theme: Theme) =>
    createStyles({
        {...}
    }),
);

function JumbotronHeader(props) {
    const classes = useStyles();
    const { totalStudents } = props;
    return (
        <Jumbotron .../>
    );
}

export default function StudentForm(props) {
    const students = useSelector(getStudents);
    const dispatch = useDispatch();
    const classes = useStyles();
    const [firstName, setFirstName ] = useState('');
    const [lastName, setLastName] = useState('');
    const [country, setCountry] = useState('');
    const [dateOfBirth, setDateOfBirth] = useState('');
    const totalStudents = isEmpty(students) ? 0 : students.length;

    async function insertStudentAsync() {
        const request: StudentModel = {
            firstName,
            lastName,
            country,
            dateOfBirth,
            skills: [] 
        };
        dispatch(insertStudentRequest(request));
    }

    return (
        <div className={classes.header}>
            <JumbotronHeader totalStudents={students.length}/>
            <form>
                 // Form Components
                 {...}
                <Button 
                    id="insertBtn"
                    onClick={() => insertStudentAsync()}>
                    Insert
                </Button>
            </form>
        </div>
    );
}
Enter fullscreen mode Exit fullscreen mode
Highlights

What is important to here is when the button is clicked a Redux Action is dispatched by useDispatch hook, to insert student data on database and also to refresh the student list afterwards.

src/components/studentTable/index.tsx

import React, { useEffect, useState } from "react";
import StudentModel from "@models/studentModel";
import { isEmpty } from 'lodash';
import { getStudents, isGetStudentsLoading } from "@redux/selector/studentSelector";
import { deleteStudentRequest, updateStudentRequest } from "@redux/actions/studentActions";
import { useDispatch, useSelector } from "react-redux";
import { shadows } from '@mui/system';
import { createStyles, makeStyles } from '@mui/styles';
import {...} from '@mui/material';
import { KeyboardArrowDown, KeyboardArrowUp } from '@mui/icons-material'

const useStyles = makeStyles((theme: Theme) =>
    createStyles({
        {...}
    }),
);

function getSkillsSummary(skills: string[]) {
    {...}
}

function SkillsDialog(props: {
    openDialog: boolean,
    handleSave,
    handleClose,
}) {
    const {
        openDialog,
        handleSave,
        handleClose
    } = props;
    const classes = useStyles();
    const [open, setOpen] = useState(false);
    const [inputText, setInputText] = useState('');

    useEffect(() => {
        setOpen(openDialog)
    }, [props]);

    return (
        <Dialog
            open={open}
            onClose={handleClose}>
         {...}
        </Dialog>
    )
}

function Row(
    props: {
        student: StudentModel,
        handleCheck
    }
) {
    const classes = useStyles();
    const dispatch = useDispatch();
    const { student, handleCheck } = props;
    const [open, setOpen] = useState(false);
    const [openDialog, setOpenDialog] = useState(false);

    const openSkillsDialog = () => {...};

    const closeSkillsDialog = () => {...};

    async function saveSkillsAsync(newSkill: string) {
        const skills = student.skills;
        skills.push(newSkill);

        const request: StudentModel = {
            _id: student._id,
            firstName: student.firstName,
            lastName: student.lastName,
            country: student.country,
            dateOfBirth: student.dateOfBirth,
            skills: skills 
        };

        dispatch(updateStudentRequest(request));
        closeSkillsDialog();
    }

    return (
        <React.Fragment>
            <TableRow ...>
                {...}
            </TableRow>
            <TableRow>
                <TableCell ...>
                    <Collapse ...>
                        <Box className={classes.innerBox}>
                            <Typography ...>
                            <Table ...>
                                <TableBody>
                                    <Button...>

                                    {student.skills.map((skill) => (
                                        <TableRow key={skill}>
                                            <TableCell ...>
                                        </TableRow>
                                    ))}
                                    <SkillsDialog
                                        openDialog={openDialog}
                                        handleClose={closeSkillsDialog}
                                        handleSave={saveSkillsAsync}
                                    />
                                </TableBody>
                            </Table>
                        </Box>
                    </Collapse>
                </TableCell>
            </TableRow>
        </React.Fragment>
    );
}

export default function StudentTable() {
    const dispatch = useDispatch();
    const students: StudentModel[] = useSelector(getStudents);
    const isLoading: boolean = useSelector(isGetStudentsLoading);
    const [selectedAll, setSelectedAll] = useState(false);
    const [studentList, setStudentList] = useState<StudentModel[]>([]);

    useEffect(() => {
        setStudentList(students);
    }, [students]);

    useEffect(() => {
        {...}
    }, [studentList]);

    const handleCheck = (event, id) => {
        {...}
    }

    const handleSelectAll = (event) => {
        {...}
    }

    async function deleteStudentsAsync() {
        const filter: string[] = studentList
            .filter(s => s.checked === true)
            .map(x => x._id || '');
        if (!isEmpty(filter)) {
            dispatch(deleteStudentRequest(filter));
        };
    }

    const LoadingCustom = () => {...}

    return (
        <TableContainer component={Paper}>
            {
                isLoading && (
                    <LoadingCustom />
                )
            }
            {!isLoading && (
                <Table aria-label="collapsible table">
                    <TableHead>
                        <TableRow>
                            <TableCell>
                                <Checkbox ... />
                            </TableCell>
                            <TableCell>
                                <Button
                                    variant="contained"
                                    color="primary"
                                    onClick={() => deleteStudentsAsync()}>
                                    Delete
                                </Button>
                            </TableCell>
                            <TableCell>{...}</TableCell>
                        </TableRow>
                    </TableHead>
                    <TableBody>
                        {studentList.map((row) => {
                            return (
                                <Row .../>
                            );
                        })}
                    </TableBody>
                </Table>
            )}
        </TableContainer>
    );
}
Enter fullscreen mode Exit fullscreen mode
Highlights
  • useSelector: Similar to useDispatch this hook replicates mapStateToProps redux old method. Allows you to extract data from the Redux store state, using a selector function. In our example I am loading students list data from store.

As for the rest of CRUD operations I continue to use useDispatch to perform the actions necessary.

Final Considerations and GIT.

With the new behaviour of functional components creation in React. React-Redux hooks extends Redux lifetime. Otherwise I would not recommend using Redux instead of RxJS for example. Furthermore, using SAGA as middleware make the application even more robust, which allows us to control the effects of asynchronous calls through the system.

If you have stayed until the end, thank you very much. And please let me know your thoughts about the usage of Redux on current present.

You can check the whole code of the project on its git repository: MERN-CLIENT-REDUX.

See ya.

Discussion (7)

Collapse
phryneas profile image
Lenz Weber

Please note that this is all based on a very old style of Redux itself that we are no longer teaching for production use.
We officially recommend to use the official Redux Toolkit for any Redux code written nowadays - in new projects as well as in old projects.
Working with Redux Toolkit, you won't have to deal with switch..case reducers, ACTION_TYPE string constants, immutable reducer logic and all that stuff. It essentially reduces your code to a fourth, works much better with TypeScript and includes RTK-Query, which is essentially "React Query for Redux".
I'd recommend going with the official Redux tutorial to get up to speed with the most modern approaches: redux.js.org/tutorials/essentials/...

Collapse
alanst32 profile image
alanst32 Author

Thanks for the comment, I am aware of React Query, I am working in something right now maybe I will post it here. My main objective was to describe Saga, so I have done in the "old" way to point step by step. I thought the modern approach would be a material for another article ;)

Collapse
phryneas profile image
Lenz Weber

Just to make sure: I am not talking about "React Query" here. I am talking about "RTK Query", which is part of the official Redux Toolkit.

And also when using Saga, the general official recommendation to use Redux Toolkit for that still stands (for two years now: redux.js.org/style-guide/style-gui...).

We are not teaching Vanilla Redux for any kind of production use any more and it would be great to see articles picking up on that.

Thread Thread
alanst32 profile image
alanst32 Author

Good to know, I will take a look on the links. My projects lately have been with RxJs, so I guess I need to catch up with Redux modern approaches. But is like I always say Adapt and Evolve.
Cheers mate.

Collapse
pcelac profile image
Aleks

Or simply use React Query and reduce lines of code by ton. Plus, you get nice control of cache.

Collapse
alanst32 profile image
alanst32 Author • Edited on

Agree, solutions like React Query or RxJS would be much less code. The reason of the post was not to say that Redux would be a better option, but to demonstrate how we can achieve a state management with Typescript and Redux.

Collapse
pcelac profile image
Aleks

Ok got it.
Still, there is a lot of boilerplate to do such actions.
And if we are talking about nowadays...
Nowadays even Redux creators strongly recommend using Redux Toolkit, which reduces lot of boilerplate and has some nice features to handle those async requests.