How to Build a Basic CRUD App with NextJS, React, Redux, Observable and MUI5 Components
Tags: nextjs, react, mui, redux
Published: 2022-12-03 03:11:04

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 and Redux & Observable to manage the side effects.

Note: we are not using `AjaxObservable` from the `rxjs` library; as of rxjs v5.5.6, it will not work on both the server and client side. Instead, we call the default export from universal-rxjs-ajax (as `request`).

We transform the Observable we get from `ajax` into a Promise to await its resolution. That resolution should be an action (since the epic returns Observables of actions). We immediately dispatch that action to the store.

This server-side solution allows compatibility with Next. It may be something other than what you wish to emulate. In different situations, calling or awaiting epics directly and passing their result to the store would be an anti-pattern. You should only trigger epics by dispatching actions. This solution may need to be generalized to resolve more complicated steps.

The layout of the redux-related functionality uses the redux-ducks pattern.

Except in those manners discussed above, the configuration is similar to the configuration found in the with-redux example on the NextJS repository and redux-observable docs.

## 2. Tech Stack

- NextJS - React framework
- Redux - state management
- Observable - middleware
- MUI5 - UI components
- Formik - form management
- Yup - form validation
- Prisma - database

## 3. Coding
### 3.1 Setup the database
We use Prisma, a lightweight server-side database, as our data store in this example. To enable Prisma in our project, we need to add the following dependencies:

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

Create an initial schema and migration SQL inside the src/prisma folder.

```
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 migration inside prisma/migrations/<migration_folder_dated>/migration_file.sql

```
-- 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 set up the redux store or view. It really depends on your programming style. As for me, I usually 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 using an AsyncStorage and runs the epics in the middleware.

### 4.2 User Module
We create a single module file for our user using the redux-ducks pattern. It contains the actionTypes, actions, reducers, and epics. This should make editing and maintenance easier.

Here is our User module with the basic CRUD features. It uses epics, a function that takes a stream of actions and returns a stream of actions. And as we can see here, one of its applications is when calling a REST API. What happens is that we initiate a redux action. This action passes in the redux middleware and executes an epic when one is defined for an action type.

```
import {of} from 'rxjs';
import {catchError, map, mergeMap} from 'rxjs/operators';
import {ofType} from 'redux-observable';
import {actionStates} from '../ModuleUtil';
import RxBackend from '../../utils/RxBackend';
 
// CONSTANTS - START
const INITIAL_STATE = {
  error: null,
  status: {inserted: false, updated: false, deleted: false},
};
// CONSTANTS - END
 
// ACTION TYPES - START
const RETRIEVE_LIST = actionStates('actions/USER_RETRIEVE_LIST');
const RETRIEVE_USER = actionStates('actions/RETRIEVE_USER');
const CREATE_USER = actionStates('actions/CREATE_USER');
const UPDATE_USER = actionStates('actions/UPDATE_USER');
const DELETE_USER = actionStates('actions/DELETE_USER');
const REDIRECT_TO_USERS_PAGE = actionStates('actions/REDIRECT_TO_USERS_PAGE');
const CLEAR_USER = "actions/CLEAR_USER";
const CLEAR_USER_STATUS = "actions/CLEAR_USER_STATUS";
// ACTION TYPES - END
 
// REDUCER - START
export const reducer = (state = INITIAL_STATE, {type, payload}) => {
  state = {...state, list: undefined};
  switch (type) {
    case RETRIEVE_LIST.SUCCESS:
      return {
        ...state,
        users: payload.users,
        count: payload.count
      };
    case RETRIEVE_USER.SUCCESS:
      return {
        ...state,
        user: payload.user
      };
    case RETRIEVE_LIST.ERROR:
      return {
        ...state,
        error: payload.error,
      };
    case CREATE_USER.SUCCESS:
      return {
        ...state,
        user: payload.user,
        status: {...state.status inserted: true},
      };
    case UPDATE_USER.SUCCESS:
      return {
        ...state,
        user: payload.user,
        status: {...state.status, updated: true},
      };
    case DELETE_USER.SUCCESS:
      return {
        ...state,
        user: payload.user,
        status: {...state.status, deleted: true},
      };
    case CLEAR_USER_STATUS:
      return {
        ...state,
        status: {...INITIAL_STATE.status}
      }
    case CLEAR_USER:
      return {
        ...state,
        user: null
      }
    default:
      return state;
  }
};
// REDUCER - END
 
// ACTIONS - START
export const retrieveList = ({offset, limit}) => ({
  type: RETRIEVE_LIST.START,
  payload: {offset, limit},
});
export const retrieveUsersOk = ({users, count}) => ({
  type: RETRIEVE_LIST.SUCCESS,
  payload: {users, count},
});
export const retrieveUsersKo = ({status, name, message}) => ({
  type: RETRIEVE_LIST.ERROR,
  payload: {status, name, message},
});
 
export const retrieveUser = (userId) => ({
  type: RETRIEVE_USER.START,
  payload: {userId}
});
export const retrieveUserOk = ({user}) => ({
  type: RETRIEVE_USER.SUCCESS,
  payload: {user}
});
export const retrieveUserKo = ({status, name, message}) => ({
  type: RETRIEVE_USER.ERROR,
  payload: {status, name, message},
});
 
export const createUser = (user, router) => ({
  type: CREATE_USER.START,
  payload: {user, router}
});
export const createUserOk = (router) => ({
  type: CREATE_USER.SUCCESS,
  payload: {router}
});
export const createUserKo = ({status, name, message}) => ({
  type: CREATE_USER.ERROR,
  payload: {status, name, message},
});
 
export const updateUser = (user, router) => ({
  type: UPDATE_USER.START,
  payload: {user, router}
});
export const updateUserOk = (router) => ({
  type: UPDATE_USER.SUCCESS,
  payload: {router}
});
export const updateUserKo = ({status, name, message}) => ({
  type: UPDATE_USER.ERROR,
  payload: {status, name, message},
});
 
export const deleteUser = ({userId}) => ({
  type: DELETE_USER.START,
  payload: {userId}
});
export const deleteUserOk = (user) => ({
  type: DELETE_USER.SUCCESS,
  payload: {user}
});
export const deleteUserKo = () => ({
  type: DELETE_USER.ERROR,
  payload: {}
});
export const clearUser = () => ({
  type: CLEAR_USER
});
export const clearUserStatus = () => ({
  type: CLEAR_USER_STATUS,
});
 
export const redirectToUsersPageOk = () => ({
  type: REDIRECT_TO_USERS_PAGE.SUCCESS,
});
 
// ACTIONS - END
 
// EPICS - START
const retrieveUsersEpic = (action$, state$) =>
    action$.pipe(
        ofType(RETRIEVE_LIST.START),
        mergeMap((action) => {
          const {limit, offset} = action.payload;
          return RxBackend.ajaxGet({
            url: `api/users?limit=${limit}&offset=${offset}`
          }).pipe(
              map((resp) => retrieveUsersOk(
                  {users: resp.response.users, count: resp.response.count})),
              catchError((err) => {
                const {status, name, message} = err;
                return of(retrieveUsersKo({status, name, message}));
              }),
          );
        })
    );
 
const retrieveUserEpic = (action$, state$) =>
    action$.pipe(
        ofType(RETRIEVE_USER.START),
        mergeMap((action) => {
          const {userId} = action.payload;
          return RxBackend.ajaxGet({
            url: `api/users/${userId}`
          }).pipe(
              map((resp) => retrieveUserOk(
                  {user: resp.response})),
              catchError((err) => {
                const {status, name, message} = err;
                return of(retrieveUserKo({status, name, message}));
              }),
          )
        })
    );
 
const createUserEpic = (action$, state$) =>
    action$.pipe(
        ofType(CREATE_USER.START),
        mergeMap(action => {
          const {user, router} = action.payload;
          const newUser = Object.keys(user).reduce((userValues, key) => {
            const value = user[key];
            return !value ? userValues : {...userValues, [key]: value};
          }, {});
          console.log("posting new user", newUser);
          return RxBackend.ajaxPost({
            url: `api/users`,
            body: {
              ...newUser
            }
          }).pipe(
              map(resp => createUserOk(router)),
              catchError(err => {
                const {status, name, message} = err;
                return of(createUserKo({status, name, message}));
              })
          )
        })
    );
 
const createUserOkEpic = (action$, state$) =>
    action$.pipe(
        ofType(CREATE_USER.SUCCESS),
        map(action => {
          const {router} = action.payload;
          console.log("create user ok", router)
          router.push("/users");
          return redirectToUsersPageOk();
        })
    );
 
const updateUserEpic = (action$, state$) =>
    action$.pipe(
        ofType(UPDATE_USER.START),
        mergeMap(action => {
          const {user, router} = action.payload;
          const newUser = Object.keys(user).reduce((userValues, key) => {
            const value = user[key];
            return !value ? userValues : {...userValues, [key]: value};
          }, {});
          console.log("put user", newUser);
          return RxBackend.ajaxPut({
            url: `api/users/${user.id}`,
            body: {
              ...newUser
            }
          }).pipe(
              map(resp => updateUserOk(router)),
              catchError(err => {
                const {status, name, message} = err;
                return of(updateUserKo({status, name, message}));
              })
          )
        })
    );
 
const updateUserOkEpic = (action$, state$) =>
    action$.pipe(
        ofType(UPDATE_USER.SUCCESS),
        map(action => {
          const {router} = action.payload;
          console.log("update user ok", router)
          router.push("/users");
          return redirectToUsersPageOk();
        })
    );
 
const deleteUserEpic = (action$, state$) =>
    action$.pipe(
        ofType(DELETE_USER.START),
        mergeMap(action => {
          const {userId} = action.payload;
          return RxBackend.ajaxDelete({
            url: `api/users/${userId}`
          }).pipe(
              map(resp => deleteUserOk(resp.response)),
              catchError(err => {
                const {status, name, message} = err;
                return of(deleteUserKo({status, name, message}));
              })
          )
        })
    );
// EPICS - END
 
export const epics = [
  retrieveUsersEpic,
  retrieveUserEpic,
  createUserEpic,
  createUserOkEpic,
  updateUserEpic,
  updateUserOkEpic,
  deleteUserEpic,
];
```

Notice that every actionState is expanded by several states like start, loading, success, error, etc.

This module exports a reducer and epics, which we need to add in our main reducer, and we call this file "Modules."

```
import {epics as userEpics, reducer as user} from './modules/UserModule.js';
 
export const reducers = {
  user
};
 
export const epics = [...userEpics];
```

As mentioned earlier, we are using universal-rxjx-ajax, to issue HTTP requests. We created a simple utility class for the most common HTTP methods to make calling it easier.

```
import {request} from "universal-rxjs-ajax";
 
const defaultHeaders = {
  Authorization: 'czetsuyatech'
};
 
export const getHeaders = (headers) =>
    Object.assign({}, defaultHeaders, headers);
 
const ajaxRequest = (options) => {
 
  options.url = process.env.NEXT_PUBLIC_API_URL + options.url;
  return request(options);
}
 
const ajaxGet = ({url = "/", headers = {}}) => {
 
  const config = {
    url,
    method: 'GET',
    headers: getHeaders(headers)
  }
 
  return ajaxRequest(config);
}
 
const ajaxPost = ({url = "/", headers = {}, body = {}}) => {
 
  const config = {
    url,
    method: 'POST',
    headers: getHeaders(headers),
    body
  }
 
  return ajaxRequest(config);
}
 
const ajaxPut = ({url = "/", headers = {}, body = {}}) => {
 
  const config = {
    url,
    method: 'PUT',
    headers: getHeaders(headers),
    body
  }
 
  return ajaxRequest(config);
}
 
const ajaxPatch = ({url = "/", headers = {}, body = {}}) => {
 
  const config = {
    url,
    method: 'PATCH',
    headers: getHeaders(headers),
    body
  }
 
  return ajaxRequest(config);
}
 
const ajaxDelete = ({url = "/", headers = {}}) => {
 
  const config = {
    url,
    method: 'DELETE',
    headers: getHeaders(headers)
  }
 
  return ajaxRequest(config);
}
 
const RxBackend = {
  ajaxGet,
  ajaxPost,
  ajaxPut,
  ajaxPatch,
  ajaxDelete
}
 
export default RxBackend;
```

## 5. UI Side
In the UI, we implemented traditional list detail (add/edit) pages. Where the user's id is passed in the detail page to edit and null to add a new record.

The User list page 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, {useEffect, useState} from 'react';
import {
  Button,
  ButtonGroup,
  Container,
  Dialog,
  DialogActions,
  DialogContent,
  DialogContentText,
  DialogTitle,
  Link,
  Snackbar,
  Table,
  TableBody,
  TableCell,
  TableContainer,
  TableFooter,
  TableHead,
  TablePagination,
  TableRow
} from "@mui/material";
import {useDispatch, useSelector} from "react-redux";
import moment from "moment";
import {Delete, Edit, PersonAdd} from "@mui/icons-material";
 
import {clearUser, clearUserStatus, deleteUser, retrieveList,} from 'redux/modules/UserModule';
import {useRouter} from "next/router";
import Footer from "../Footer/Footer";
 
const useUsers = () =>
    useSelector(({user: {users, count, status, user}}) => ({
      users,
      count,
      status,
      storeUser: user
    }));
 
const EMPTY_DIALOG = {
  open: false,
  text: '',
  title: '',
  onConfirm: () => null,
  onCancel: () => null
}
 
const EMPTY_ALERT = {
  open: false,
  text: '',
};
 
const Users = () => {
 
  const router = useRouter();
  const dispatch = useDispatch();
  const {users, count, status, storeUser} = useUsers();
  const hasUsers = !!users && users.length > 0;
  const [offset, setOffset] = useState(0);
  const [limit, setLimit] = useState(10);
  const [dialog, setDialog] = useState(EMPTY_DIALOG);
  const [alert, setAlert] = useState(EMPTY_ALERT);
 
  useEffect(() => {
    dispatch(retrieveList({offset: offset * limit, limit}));
  }, [dispatch, offset, limit]);
 
  useEffect(() => {
    if (status.deleted) {
      resetDialog();
      dispatch(retrieveList({offset: offset * limit, limit}));
      setAlert({
        open: true,
        text: `Successfully deleted user: ${storeUser.id}`,
      });
    }
  }, [status, offset, limit, dispatch]);
 
  const handleChangeRowsPerPage = ({target: {value}}) => {
    setLimit(value);
  };
 
  const handleChangePage = (_, nextPage) => {
    setOffset(nextPage);
  };
 
  const handleDeleteUser = ({id}) => () => {
    dispatch(deleteUser({userId: id}));
  };
 
  const resetDialog = () => {
    setDialog(EMPTY_DIALOG);
  }
 
  const resetAlert = () => {
    setAlert(EMPTY_ALERT);
    dispatch(clearUserStatus());
    dispatch(clearUser());
  };
 
  const openDialog = (user) => () => {
    setDialog({
      open: true,
      title: 'Delete user',
      text: `Delete user: ${user.id}?`,
      onConfirm: handleDeleteUser(user),
      onCancel: () => resetDialog()
    });
  };
 
  const editUser = ({id}) => () => {
    router.push(`/users/${id}`);
  }
 
  return (
      <Container maxWidth={"md"} fixed>
        <TableContainer>
          <Table>
            <TableHead>
              <TableRow>
                <TableCell colSpan={6} align="right">
                  <Link href="/users/new">
                    <Button variant="outlined" color="primary">
                      <PersonAdd/>
                    </Button>
                  </Link>
                </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={openDialog(user)}>
                              {<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>
        <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 its validation rules. This is triggered when we submit the form.

This is very important; notice that inside the useEffect we are fetching the user given an id. This useEffect will only be triggered if the value of the user changes to avoid too many re-render errors. If the user exists, we will fill the values of the form fields using formik.

```
import React, {useEffect} from 'react';
import {Alert, Box, Button, Container, Grid, Link, TextField, Typography} from "@mui/material";
import {useRouter} from "next/router";
import {useDispatch, useSelector} from "react-redux";
import * as yup from 'yup';
import {useFormik} from "formik";
import AdapterMoment from '@mui/lab/AdapterMoment';
import LocalizationProvider from '@mui/lab/LocalizationProvider';
import {DatePicker} from "@mui/lab";
import Footer from "../Footer/Footer";
import {clearUser, clearUserStatus, createUser, retrieveUser, updateUser} from '../../redux/modules/UserModule';
import moment from "moment";
 
const validationSchema = yup.object({
  email: yup
  .string()
  .trim()
  .email('Please enter a valid email address')
  .required('Email is required.'),
  firstName: yup
  .string()
  .required('Please specify your first name'),
  lastName: yup
  .string()
  .required('Please specify your first name'),
  birthDate: yup
  .date()
});
 
const INITIAL_USER = {
  firstName: '',
  lastName: '',
  email: ''
}
 
const useUser = () => useSelector(({user: {user, status}}) => ({
  user,
  status
}));
 
const UserDetail = () => {
 
  const router = useRouter();
  const dispatch = useDispatch();
  const {user, status} = useUser();
  const [birthDate, setBirthDate] = React.useState(null);
  const {id} = router.query;
 
  useEffect(() => {
 
    if (id && !isNaN(id)) {
      dispatch(retrieveUser(id));
    }
  }, [id]);
 
  useEffect(() => {
    if (user && user !== null) {
      console.log('formik.setValues')
      setBirthDate(moment(user.birthDate));
 
      formik.setValues({
        firstName: user.firstName,
        lastName: user.lastName,
        email: user.email
      });
    }
  }, [user]);
 
  useEffect(() => {
    return () => {
      console.log("clearing status");
      dispatch(clearUser());
      dispatch(clearUserStatus());
    };
  }, [router]);
 
  const onSubmit = (values) => {
    let newValues = {
      ...values,
      birthDate: birthDate.toISOString()
    }
 
    if (user && user.id) {
      newValues.id = user.id;
      dispatch(updateUser(newValues, router));
 
    } else {
      dispatch(createUser(newValues, router));
    }
  };
 
  const formik = useFormik({
    initialValues: INITIAL_USER,
    validationSchema: validationSchema,
    onSubmit
  });
 
  return (
      <Container maxWidth={"sm"}>
        <Alert severity="error"></Alert>
        <Box marginBottom={4}>
          <Typography
              sx={{
                textTransform: 'uppercase',
                fontWeight: 'medium',
              }}
              gutterBottom
              color={'text.secondary'}
          >
            Create User
          </Typography>
          <Typography color="text.secondary">
            Enter the details
          </Typography>
        </Box>
        <form onSubmit={formik.handleSubmit}>
          <Grid container spacing={4}>
            <Grid item xs={12}>
              <Typography variant={'subtitle2'} sx={{marginBottom: 2}}>
                Enter your email
              </Typography>
              <TextField
                  label="Email *"
                  variant="outlined"
                  name={'email'}
                  fullWidth
                  value={formik.values.email}
                  onChange={formik.handleChange}
                  error={formik.touched.email && Boolean(formik.errors.email)}
                  helperText={formik.touched.email && formik.errors.email}
              />
            </Grid>
            <Grid item xs={12}>
              <Typography variant={'subtitle2'} sx={{marginBottom: 2}}>
                Enter your firstname
              </Typography>
              <TextField
                  label="Firstname *"
                  variant="outlined"
                  name={'firstName'}
                  fullWidth
                  value={formik.values.firstName}
                  onChange={formik.handleChange}
                  error={formik.touched.firstName && Boolean(formik.errors.firstName)}
                  helperText={formik.touched.firstName && formik.errors.firstName}
              />
            </Grid>
            <Grid item xs={12}>
              <Typography variant={'subtitle2'} sx={{marginBottom: 2}}>
                Enter your lastName
              </Typography>
              <TextField
                  label="Lastname *"
                  variant="outlined"
                  name={'lastName'}
                  fullWidth
                  value={formik.values.lastName}
                  onChange={formik.handleChange}
                  error={formik.touched.lastName && Boolean(formik.errors.lastName)}
                  helperText={formik.touched.lastName && formik.errors.lastName}
              />
            </Grid>
            <Grid item xs={12}>
              <Typography variant={'subtitle2'} sx={{marginBottom: 2}}>
                Enter your birthdate
              </Typography>
              <LocalizationProvider dateAdapter={AdapterMoment}>
                <DatePicker
                    fullWidth
                    label="Birthdate"
                    value={birthDate}
                    onChange={(newValue) => {
                      setBirthDate(newValue);
                    }}
                    renderInput={(params) => <TextField {...params} variant={"outlined"} fullWidth/>}
                />
              </LocalizationProvider>
            </Grid>
            <Grid item xs={12}>
              <Typography variant="subtitle2" gutterBottom>
                Fields that are marked with * sign are required.
              </Typography>
              <Grid container spacing={2}>
                <Grid item>
                  <Button
                      size="large"
                      variant="contained"
                      color="primary"
                      type={"submit"}
                  >
                    Save
                  </Button>
                </Grid>
                <Grid item>
                  <Link href="/users">
                    <Button size="large" variant="contained" color="secondary">
                      Cancel
                    </Button>
                  </Link>
                </Grid>
              </Grid>
            </Grid>
          </Grid>
        </form>
        <Footer></Footer>
      </Container>
  );
}
 
export default UserDetail;
```

## 6. Git Repository
As always, the complete source code is available on GitHub: https://github.com/czetsuya/nextjs-redux-observable.

## 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