no

How to Build a Basic CRUD App with NextJS, TS, React, Redux-Tookit and MUI5 Components

Learn how to build a NextJS application that uses `redux-observable` to manage side effects. 1. Introduction In this project, we will lear...

Learn how to build a NextJS application that uses `redux-observable` to manage side effects.

1. Introduction

In this project, we will learn how to build a basic CRUD application using NextJS Typescript and Redux-Toolkit to manage the side effects.

2. Tech Stack

  • NextJS - React framework
  • Typescript 
  • Redux-Toolkit - toolset for redux state management
  • MUI5 - UI components
  • Formik - form management
  • Yup - form validation
  • Prisma - database

3. Coding

3.1 Setup the database

In this example, we are using Prisma a light-weight server side database as our data store. To enable Prisma in our project we need to add the following dependencies:

  • "@prisma/client": "^3.8.0"
  •  "prisma": "^3.8.0" - devDependencies

Create the Prisma datasource configuration

datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}
 
generator client {
  provider = "prisma-client-js"
  binaryTargets = ["native"]
}
 
model User {
  id        Int      @id @default(autoincrement())
  firstName String
  lastName  String
  email     String?
  birthDate DateTime?
}

And initial schema and migration SQL inside src/prisma folder.

-- CreateTable
CREATE TABLE "User" (
    "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
    "firstName" TEXT NOT NULL,
    "lastName" TEXT NOT NULL,
    "email" TEXT,
    "birthDate" DATETIME
);

To build the local database, execute:

npx prisma migrate dev --name=init

The next step is whether you want to setup the redux store or view. It really depends on your programming style. As for me, I normally go with the store or core of the framework.

4. Setup the Application Store

4.1 Application Store

The application store is derived from the NextJS example, it persists the combined reducers and middlewares from Redux-toolkit's createApi.

import {configureStore} from '@reduxjs/toolkit';
import {UsersService} from "./UserService";
import {UserSlice} from "./slices/UserSlice";

export function makeStore() {
  return configureStore({
    reducer: {
      [UsersService.reducerPath]: UsersService.reducer,
      user: UserSlice.reducer
    },
    middleware: (getDefaultMiddleware) =>
        getDefaultMiddleware().concat(
            UsersService.middleware
        ),
  })
}

const store = makeStore()

// Infer the `RootState` and `AppDispatch` types from the store itself
export type RootState = ReturnType<typeof store.getState>

// Inferred type: {users: UsersState}
export type AppDispatch = typeof store.dispatch

export default store;

4.2 User Service/API

This file contains all the API endpoints for the user which uses query and mutation to edit the user's data. It uses the header defined in the BaseService file.

import {BaseService} from "./BaseService";
import {UserType} from "./types/UserType";
import {createSlice} from "@reduxjs/toolkit";

interface Pagination {
  offset: number,
  limit: number
}

export const UsersService = BaseService.injectEndpoints({
  endpoints: (build) => ({
    getUsers: build.query<UserType[], Pagination>({
      query: (param: Pagination) => `/users?offset=${param.offset}&limit=${param.limit}`,
      providesTags: [{type: "User", id: "LIST"}]
    }),
    getUser: build.query<UserType, number>({
      query: (id) => ({
        url: `/users/${id}`,
      })
    }),
    createUser: build.mutation<UserType, UserType>({
      query: (body: UserType) => ({
        url: `/users`,
        method: 'POST',
        body
      }),
      invalidatesTags: [{type: "User", id: "LIST"}]
    }),
    updateUser: build.mutation<UserType, Pick<UserType, 'id'> & Partial<UserType>>({
      query: ({id, ...body}) => ({
        url: `/users/${id}`,
        method: 'PATCH',
        body
      }),
      invalidatesTags: [{type: "User", id: "LIST"}]
    }),
    deleteUser: build.mutation<void, number>({
      query: (id) => ({
        url: `/users/${id}`,
        method: 'DELETE',
      }),
      invalidatesTags: [{type: "User", id: "LIST"}],
    }),
  }),
  overrideExisting: true,
})

export const {
  useGetUsersQuery, useGetUserQuery,
  useCreateUserMutation, useDeleteUserMutation, useUpdateUserMutation
} = UsersService;

5. UI Side

In the UI we implemented a traditional list and a swipeable detail pages using MUI5 components.

Here is the User list page that uses MUI5 components to render a table. On page load implemented using useEffect, it calls an API to retrieve the list of users. Delete action is also implemented on this page.

import React, {useState} from 'react';
import {
  Alert,
  Box,
  Button,
  ButtonGroup,
  CircularProgress,
  Container,
  Dialog,
  DialogActions,
  DialogContent,
  DialogContentText,
  DialogTitle,
  Snackbar,
  SwipeableDrawer,
  Table,
  TableBody,
  TableCell,
  TableContainer,
  TableFooter,
  TableHead,
  TablePagination,
  TableRow
} from "@mui/material";
import moment from "moment";
import {Delete, Edit, PersonAdd} from "@mui/icons-material";
import {useAppDispatch} from 'services/hooks';

import {useRouter} from "next/router";
import {NextPage} from "next";
import {useDeleteUserMutation, useGetUsersQuery} from "../../services/UserService";
import Footer from "../../components/Footer/Footer";
import UserDetail from "./components/UserDetail";
import {UserType} from "../../services/types/UserType";
import {clearUser, setUser} from "../../services/slices/UserSlice";

const EMPTY_DIALOG = {
  open: false,
  text: '',
  title: '',
  onConfirm: () => {
  },
  onCancel: () => {
  }
}

const EMPTY_ALERT = {
  open: false,
  text: '',
};

const Users: NextPage = () => {

  const router = useRouter();
  const dispatch = useAppDispatch();
  const [offset, setOffset] = useState(0);
  const [limit, setLimit] = useState(10);
  const [dialog, setDialog] = useState(EMPTY_DIALOG);
  const [alert, setAlert] = useState(EMPTY_ALERT);

  const {
    data,
    error,
    isLoading: isUsersLoading,
    isSuccess: isUsersQueried,
    isFetching: isUsersFetching,
    isError: isUsersError
  } = useGetUsersQuery({offset: (offset * limit), limit});

  const [deleteUser, {
    data: deletedUser,
    isLoading: isUserDeleting,
    isSuccess: isUserDeleted
  }] = useDeleteUserMutation();

  const drawerBleeding = 56;
  const [openDrawer, setOpenDrawer] = React.useState(false);

  const handleChangeRowsPerPage = ({target: {value}}) => {
    setLimit(value);
  };

  const handleChangePage = (_, nextPage) => {
    setOffset(nextPage);
  };

  const handleDeleteUser = (userId: number) => async () => {
    try {
      await deleteUser(userId).unwrap();
      setAlert({
        open: true,
        text: `Successfully deleted user: ${userId}`,
      });
      resetDeleteDialog();

    } catch (error) {
      console.log(`Error: Failed deleting user with id ${userId}`);
    }
  };

  const resetDeleteDialog = () => {
    setDialog(EMPTY_DIALOG);
  }

  const openDeleteDialog = (userId: number) => () => {
    setDialog({
      open: true,
      title: 'Delete user',
      text: `Delete user: ${userId}?`,
      onConfirm: handleDeleteUser(userId),
      onCancel: () => resetDeleteDialog()
    });
  }

  const resetAlert = () => {
    setAlert(EMPTY_ALERT);
  }

  const editUser = (user: UserType) => () => {

    setOpenDrawer(true);
    dispatch(setUser(user));
  };

  const toggleEditDrawer = (newOpen: boolean) => () => {

    if (!newOpen) {
      dispatch(clearUser());
    }
    setOpenDrawer(newOpen);
  };

  const renderTable = (users: UserType[], count: number) => {
    const hasUsers = count > 0;

    return (
        <React.Fragment>
          <TableContainer>
            <Table>
              <TableHead>
                <TableRow>
                  <TableCell colSpan={6} align="right">
                    <Button variant="outlined" color="primary" onClick={toggleEditDrawer(true)}>
                      <PersonAdd/>
                    </Button>
                  </TableCell>
                </TableRow>
                <TableRow>
                  <TableCell>Id</TableCell>
                  <TableCell>First name</TableCell>
                  <TableCell>Last name</TableCell>
                  <TableCell>Email</TableCell>
                  <TableCell>Birth date</TableCell>
                  <TableCell></TableCell>
                </TableRow>
              </TableHead>
              <TableBody>
                {hasUsers ? (
                    users.map((user) => (
                        <TableRow key={user.id}>
                          <TableCell>{user.id}</TableCell>
                          <TableCell>{user.firstName}</TableCell>
                          <TableCell>{user.lastName}</TableCell>
                          <TableCell>{user.email}</TableCell>
                          <TableCell>
                            {moment.utc(user.birthDate).format('MM-DD-YYYY')}
                          </TableCell>
                          <TableCell sx={{textAlign: "right"}}>
                            <ButtonGroup>
                              <Button onClick={editUser(user)}>
                                <Edit/>
                              </Button>
                              <Button onClick={openDeleteDialog(user.id)}>
                                {<Delete/>}
                              </Button>
                            </ButtonGroup>
                          </TableCell>
                        </TableRow>
                    ))
                ) : (
                    <TableRow>
                      <TableCell colSpan={6}>No users found.</TableCell>
                    </TableRow>
                )}
              </TableBody>
              <TableFooter>
                <TableRow>
                  <TablePagination
                      count={count}
                      page={offset}
                      rowsPerPage={limit}
                      onPageChange={handleChangePage}
                      onRowsPerPageChange={handleChangeRowsPerPage}
                  />
                </TableRow>
              </TableFooter>
            </Table>
          </TableContainer>
          <SwipeableDrawer
              anchor="bottom"
              open={openDrawer}
              onClose={toggleEditDrawer(false)}
              onOpen={toggleEditDrawer(true)}
              swipeAreaWidth={drawerBleeding}
              disableSwipeToOpen={false}
              ModalProps={{
                keepMounted: true,
              }}
          >
            <UserDetail toggleEditDrawer={toggleEditDrawer}></UserDetail>
          </SwipeableDrawer>
        </React.Fragment>
    );
  }

  const renderBody = () => {
    if (isUsersQueried) {
      const {users, count} = data;

      return (isUsersFetching || isUsersLoading) ?
          <Box sx={{display: 'flex'}}>
            <CircularProgress/>
          </Box> :
          renderTable(users, count)
    }
  }

  const renderError = () => {
    return isUsersError && <Alert severity="error">{JSON.stringify(error)}</Alert>;
  }

  return (
      <Container maxWidth={"md"} fixed>
        {renderError()}
        {renderBody()}
        <Footer></Footer>
        <Dialog
            open={dialog.open}
            onClose={dialog.onCancel}
            aria-labelledby="alert-dialog-title"
            aria-describedby="alert-dialog-description"
        >
          <DialogTitle id="alert-dialog-title">
            {dialog.title}
          </DialogTitle>
          <DialogContent>
            <DialogContentText id="alert-dialog-description">
              {dialog.text}
            </DialogContentText>
          </DialogContent>
          <DialogActions>
            <Button onClick={dialog.onCancel}>Disagree</Button>
            <Button onClick={dialog.onConfirm} autoFocus>
              Agree
            </Button>
          </DialogActions>
        </Dialog>
        <Snackbar
            open={alert.open}
            autoHideDuration={6000}
            onClose={resetAlert}
            message={alert.text}
        />
      </Container>
  );
}

export default Users;

And here is the User detail page. We use formik to build the form and yup to define the validation rules, this is triggered when we submit the form.

This is very important, notice that inside the useEffect we are setting the form values when in edit mode when the user exists using formik, this useEffect will only be triggered if the value of user changes to avoid too many re-renders error.

6. Git Repository

As always the complete source code is available in GitHub: https://github.com/czetsuya/lab-next-ts.

7. Dependencies

  • https://www.npmjs.com/package/universal-rxjs-ajax
  • https://github.com/erikras/ducks-modular-redux
  • https://github.com/vercel/next.js/tree/canary/examples/with-redux
  • https://redux-observable.js.org
  • https://www.prisma.io

Related

react 2845267098496545163

Post a Comment Default Comments

Outsourcing

Are you looking for freelancers in the Philippines? Get in touch.

Donations

If you like what I do, you can support this channel by buying me a coffee. I would be grateful for your contribution! Your donations will help me create more FREE online courses and learning materials for programmers and remote workers.

item