Yann Thibodeau
Yann Thibodeau's Blog

Yann Thibodeau's Blog

Creating a todo application using React (part 4)

Creating a todo application using React (part 4)

Yann Thibodeau's photo
Yann Thibodeau
·Oct 15, 2020·

19 min read

This article is the fourth part of the series Creating the modern developer stack template. I definitely recommend reading the first one before this to get more context.

Introduction

Hello folks,

In this article, I won't be doing the usual format seen in previous articles where I would investigate multiple different technologies and do the implementation. I've already decided that I will be using React for this one. I will highlight a few reasons as to why and then proceed to integrate the todo API we wrote in the previous blog post.

Why React

React is by far the most popular framework:

image.png

This has the advantage of easily finding help for commonly encountered problems, there already exist many built components from the open-source community that you can use for your project and it has a less restrictive framework compared to angular and Vue.js. That being said I think this liberty can be a hidden ticking bomb. If the developers of your project all go in different directions, some write class components, others write functional components, some solve a problem using this pattern while others solve the exact same problem with a different pattern. It can get absolutely messy. I believe this pain point can be alleviated by enforcing strict rules that won't allow developers to go in all directions. That being said not everything can be enforced automatically, thus you must do thorough code reviews.

For those reasons, I believe React is a good pick for the current state of web frameworks.

Implementation in Stator

Start off by generating a new react project:

nx generate lib --name=webapp --no-interactive --framework=react

Now install the tools we will be using:

npm i --save @material-ui/core @material-ui/icons @material-ui/lab @reduxjs/toolkit axios clsx

As you've noticed, we will be using material-ui as it will provide us with existing components for faster development. For our global state management, we will use redux-toolkit as it reduces the boilerplate commonly found using redux.

Let's first start off by creating utility functions to even further reduce the required code to integrate with a CRUD API interface. We create a thunk-factory which will allow us to generate typed actions for our store:

import { createAsyncThunk } from "@reduxjs/toolkit"
import { RootEntity } from "@stator/models"
import { AxiosResponse } from "axios"

import { environment } from "../../environments/environment"
import { http } from "../../services/http"

const handleRequest = async <T>(requestFn: Promise<AxiosResponse<T>>) => {
  try {
    return (await requestFn).data
  } catch (error) {
    const errorMessage = error.response ? error.response.data.message.join("\n") : error.message
    throw Error(errorMessage)
  }
}

export const thunkFactory = <T extends RootEntity>(baseEndpoint: string) => {
  const httpClient = http(environment.apiUrl)

  return {
    get: createAsyncThunk(`${baseEndpoint}/get`, async (entity: T) => {
      return await handleRequest(httpClient.get<T>(`${baseEndpoint}/${entity.id}`))
    }),
    getAll: createAsyncThunk(`${baseEndpoint}/get`, async () => {
      return await handleRequest(httpClient.get<T[]>(baseEndpoint))
    }),
    post: createAsyncThunk(`${baseEndpoint}/post`, async (entity: T) => {
      return await handleRequest(httpClient.post<T>(baseEndpoint, entity))
    }),
    put: createAsyncThunk(`${baseEndpoint}/put`, async (entity: T) => {
      return await handleRequest(httpClient.put<T>(`${baseEndpoint}/${entity.id}`, entity))
    }),
    delete: createAsyncThunk(`${baseEndpoint}/delete`, async (entity: T) => {
      return await handleRequest(httpClient.delete<void>(`${baseEndpoint}/${entity.id}`))
    }),
  }
}

export type ThunkFactoryType = ReturnType<typeof thunkFactory>

Now for our state let's create a generic interface that will allow us to manage loading states and set the data we've got from the actions in the store.

slice-state.ts:

import { RootEntity } from "@stator/models"

export interface SliceState<T> {
  entities: T[]
  status: {
    get: { ids: Map<number, boolean> }
    getAll: { loading: boolean }
    post: { loading: boolean }
    put: { ids: Map<number, boolean> }
    delete: { ids: Map<number, boolean> }
    error: Error
  }
}

export const getInitialSliceState = <T extends SliceState<TEntity>, TEntity>(): T => {
  return {
    entities: [],
    status: {
      get: { ids: {} },
      getAll: { loading: false },
      post: { loading: false },
      put: { ids: {} },
      delete: { ids: {} },
      error: null,
    },
  } as T
}

Now create a factory for the slices slice-reducer-factory.ts:

import { PayloadAction } from "@reduxjs/toolkit"
import { RootEntity } from "@stator/models"

import { SliceState } from "./slice-state"
import { ThunkFactoryType } from "./thunk-factory"

export type PayloadEntityAction<T> = PayloadAction<T, string, { arg: T }>
export type PayloadErrorAction<T> = PayloadAction<T, string, { arg: T }, Error>

export const sliceReducerFactory = <T extends RootEntity, TSliceState extends SliceState<T>>(
  thunks: ThunkFactoryType
) => {
  return {
    // GET
    [thunks.get.pending as any]: (state: TSliceState, action: PayloadEntityAction<T>) => {
      state.status.get.ids[action.meta.arg.id] = true
      state.status.error = null
    },
    [thunks.get.fulfilled as any]: (state: TSliceState, action: PayloadEntityAction<T>) => {
      // This function ensures that we keep the position on the fetched item if already existing
      const entities = [...state.entities]
      const index = entities.findIndex(entity => entity.id === action.payload.id)
      if (index === -1) {
        entities.push(action.payload)
      } else {
        entities[index] = action.payload
      }

      state.entities = entities
      state.status.get.ids[action.meta.arg.id] = false
    },
    [thunks.get.rejected as any]: (state: TSliceState, action: PayloadErrorAction<T>) => {
      state.status.get.ids[action.meta.arg.id] = false
      state.status.error = action.error
    },

    // GET ALL
    [thunks.getAll.pending as any]: (state: TSliceState) => {
      state.status.getAll.loading = true
      state.status.error = null
    },
    [thunks.getAll.fulfilled as any]: (state: TSliceState, action: PayloadAction<T[]>) => {
      state.entities = action.payload
      state.status.getAll.loading = false
    },
    [thunks.getAll.rejected as any]: (state: TSliceState, action: PayloadErrorAction<T>) => {
      state.status.getAll.loading = false
      state.status.error = action.error
    },

    // POST
    [thunks.post.pending as any]: (state: TSliceState) => {
      state.status.post.loading = true
      state.status.error = null
    },
    [thunks.post.fulfilled as any]: (state: TSliceState, action: PayloadAction<T>) => {
      state.entities = [...state.entities, action.payload]
      state.status.post.loading = false
    },
    [thunks.post.rejected as any]: (state: TSliceState, action: PayloadErrorAction<T>) => {
      state.status.post.loading = false
      state.status.error = action.error
    },

    // PUT
    [thunks.put.pending as any]: (state: TSliceState, action: PayloadEntityAction<T>) => {
      state.status.put.ids[action.meta.arg.id] = true
      state.status.error = null
    },
    [thunks.put.fulfilled as any]: (state: TSliceState, action: PayloadAction<T>) => {
      state.entities = state.entities.map(entity => (entity.id === action.payload.id ? action.payload : entity))
      state.status.put.ids[action.payload.id] = false
    },
    [thunks.put.rejected as any]: (state: TSliceState, action: PayloadErrorAction<T>) => {
      state.status.put.ids[action.meta.arg.id] = false
      state.status.error = action.error
    },

    // DELETE
    [thunks.delete.pending as any]: (state: TSliceState, action: PayloadEntityAction<T>) => {
      state.status.delete.ids[action.meta.arg.id] = true
      state.status.error = null
    },
    [thunks.delete.fulfilled as any]: (state: TSliceState, action: PayloadEntityAction<T>) => {
      state.entities = state.entities.filter(entity => entity.id !== action.meta.arg.id)
      state.status.delete.ids[action.meta.arg.id] = false
    },
    [thunks.delete.rejected as any]: (state: TSliceState, action: PayloadErrorAction<T>) => {
      state.status.delete.ids[action.meta.arg.id] = false
      state.status.error = action.error
    },
  }
}

Because we have created those factories, we can now very easily use those to create efficient integrations with any models. Here is the thunk implementation for our todo app:

import { Todo } from "@stator/models"

import { thunkFactory } from "../utils/thunk-factory"

export const todoThunks = {
  ...thunkFactory<Todo>("/todos"),
}

Let's create our slices:

import { Slice, createSlice } from "@reduxjs/toolkit"
import { Todo } from "@stator/models"

import { sliceReducerFactory } from "../utils/slice-reducer-factory"
import { SliceState, getInitialSliceState } from "../utils/slice-state"
import { todoThunks } from "./todos.thunk"

export interface TodoState extends SliceState<Todo> {}

export const todoSlice: Slice = createSlice({
  name: "todos",
  initialState: getInitialSliceState<TodoState, Todo>(),
  reducers: {},
  extraReducers: {
    ...sliceReducerFactory<Todo, TodoState>(todoThunks),
  },
})

As you have noticed creating those factories has proven helpful since we simply need to add one-liners to get all of our CRUD operations generated.

Now to get everything connected together we need a store, let's start by creating the root-reducer.ts:

import { combineReducers } from "@reduxjs/toolkit"

import { todoSlice } from "./todos/todos.slice"

const rootReducer = combineReducers({
  todoReducer: todoSlice.reducer,
})

export type RootState = ReturnType<typeof rootReducer>

export default rootReducer

Finally, our store:

import { configureStore } from "@reduxjs/toolkit"

import rootReducer from "./root-reducer"

const store = configureStore({
  reducer: rootReducer,
})

export type AppDispatch = typeof store.dispatch

export default store

We must now create our application which will consist of 2 components: App which will handle of the core logic and LoadingFab which will handle loading around fabs.

app.tsx:

import {
  Card,
  CardContent,
  CircularProgress,
  List,
  ListItem,
  ListItemSecondaryAction,
  ListItemText,
  Snackbar,
  TextField,
} from "@material-ui/core"
import { SnackbarCloseReason } from "@material-ui/core/Snackbar/Snackbar"
import { Add, Delete, Done, Edit } from "@material-ui/icons"
import { Alert } from "@material-ui/lab"
import { Todo } from "@stator/models"
import clsx from "clsx"
import React, { ChangeEvent, useEffect, useState } from "react"
import { useDispatch, useSelector } from "react-redux"

import { LoadingIconButton } from "../loading-fab/loading-icon-button"
import { RootState } from "../redux/root-reducer"
import { AppDispatch } from "../redux/store"
import { TodoState } from "../redux/todos/todos.slice"
import { todoThunks } from "../redux/todos/todos.thunk"
import { useAppStyles } from "./app.styles"

export const App = () => {
  const classes = useAppStyles()

  const dispatch = useDispatch<AppDispatch>()
  const todoState = useSelector<RootState, TodoState>((state: RootState) => state.todoReducer)
  const [todoCreateText, setTodoCreateText] = useState("")
  const [todoEditTextMap, setTodoEditTextMap] = useState(new Map<number, string>())
  const [todoEditIdMap, setTodoEditIdMap] = useState(new Map<number, boolean>())
  const [errorAlertOpened, setErrorAlertOpened] = useState(!!todoState.status.error)

  useEffect(() => {
    dispatch(todoThunks.getAll())
  }, [dispatch])

  useEffect(() => {
    setTodoEditTextMap(
      todoState.entities.reduce(
        (container, todo) => ({ ...container, [todo.id]: todo.text }),
        new Map<number, string>()
      )
    )
  }, [todoState.entities])

  useEffect(() => setErrorAlertOpened(!!todoState.status.error), [todoState.status.error])

  const onTodoCreateChange = (event: ChangeEvent<HTMLInputElement>) => setTodoCreateText(event.target.value)

  const onTodoUpdateChange = (todo: Todo) => (event: ChangeEvent<HTMLInputElement>) => {
    return setTodoEditTextMap({ ...todoEditTextMap, [todo.id]: event.target.value })
  }

  const onTodoCreate = async () => {
    const response = await dispatch(todoThunks.post({ text: todoCreateText }))
    if (!response.type.includes("rejected")) {
      setTodoCreateText("")
    }
  }

  const onTodoEditClick = async (todo: Todo) => {
    if (todoEditIdMap[todo.id]) {
      await dispatch(todoThunks.put({ ...todo, text: todoEditTextMap[todo.id] }))
      setTodoEditIdMap(todoEditIdMap => ({
        ...todoEditIdMap,
        [todo.id]: false,
      }))
    } else {
      setTodoEditIdMap(todoEditIdMap => ({
        ...todoEditIdMap,
        [todo.id]: true,
      }))
    }
  }

  const onTodoCreateKeyPress = () => async (event: React.KeyboardEvent<HTMLDivElement>) => {
    if (event.key === "Enter") {
      await onTodoCreate()
    }
  }

  const onTodoUpdateKeyPress = (todo: Todo) => async (event: React.KeyboardEvent<HTMLDivElement>) => {
    if (event.key === "Enter") {
      await onTodoEditClick(todo)
    }
  }

  const onErrorAlertClose = (event: React.SyntheticEvent, reason?: SnackbarCloseReason) => {
    if (reason !== "clickaway") {
      setErrorAlertOpened(false)
    }
  }

  return (
    <div className={clsx(classes.app, classes.cardContainer)}>
      <Snackbar
        open={errorAlertOpened}
        autoHideDuration={3000}
        onClose={onErrorAlertClose}
        anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
      >
        <Alert severity="error" onClose={onErrorAlertClose}>
          {todoState.status.error?.message}
        </Alert>
      </Snackbar>
      <Card>
        <CardContent className={classes.addTodoContainer}>
          {!todoState.status.getAll.loading && (
            <>
              <TextField
                id="create-text-field"
                label="Todo"
                value={todoCreateText}
                onChange={onTodoCreateChange}
                onKeyPress={onTodoCreateKeyPress()}
              />
              <LoadingIconButton Icon={Add} onClick={onTodoCreate} loading={todoState.status.post.loading} />
            </>
          )}
        </CardContent>
      </Card>
      <Card>
        <CardContent className={classes.cardContent}>
          {todoState.status.getAll.loading ? (
            <CircularProgress className={classes.getLoadingProgress} />
          ) : (
            <List>
              {todoState.entities.map(todo => (
                <ListItem key={todo.id}>
                  {todoEditIdMap[todo.id] && (
                    <TextField
                      placeholder="Todo"
                      value={todoEditTextMap[todo.id]}
                      onChange={onTodoUpdateChange(todo)}
                      onKeyPress={onTodoUpdateKeyPress(todo)}
                      className={classes.updateTextField}
                      data-testid="edit-text-field"
                      fullWidth
                    />
                  )}
                  {!todoEditIdMap[todo.id] && <ListItemText data-testid="todo-text" primary={todo.text} />}
                  <ListItemSecondaryAction className={classes.listItemSecondaryAction}>
                    <LoadingIconButton
                      className="edit-icon-button"
                      loading={todoState.status.put.ids[todo.id]}
                      Icon={todoEditIdMap[todo.id] ? Done : Edit}
                      onClick={() => onTodoEditClick(todo)}
                    />
                    <LoadingIconButton
                      className="delete-icon-button"
                      loading={todoState.status.delete.ids[todo.id]}
                      Icon={Delete}
                      onClick={() => dispatch(todoThunks.delete(todo))}
                    />
                  </ListItemSecondaryAction>
                </ListItem>
              ))}
            </List>
          )}
        </CardContent>
      </Card>
    </div>
  )
}

export default App

Because we use the store, we must dispatch events. Those events will automatically set the appropriate loading states for us. For example, when we update a todo, we can access it's loading state simply by getting it from our state: todoState.status.put.ids[todo.id]. Updating a todo couldn't be easier: await dispatch(todoThunks.put({ ...todo, text: todoEditTextMap[todo.id] })). Everything is strongly typed, so if you try to pass an argument that doesn't exist, you will get an error from the TypeScript compiler. That's what I would call some very reactive code which is the whole point of using React in the first place. Mission accomplished.

Let's add some style so things look good. I recommend adding the styles in another file so your main file doesn't get too big app.styles.tsx:

import { Theme } from "@material-ui/core"
import { makeStyles } from "@material-ui/core/styles"

export const useAppStyles = makeStyles((theme: Theme) => ({
  app: {
    fontFamily: "sans-serif",
    minWidth: 300,
    maxWidth: 600,
    margin: "50px auto",
  },
  cardContainer: {
    display: "grid",
    gridGap: theme.spacing(2),
  },
  addTodoContainer: {
    display: "grid",
    gridTemplateColumns: "1fr auto",
    gridGap: theme.spacing(2),
    alignItems: "center",
    paddingLeft: theme.spacing(4),
    paddingRight: theme.spacing(4),
  },
  cardContent: {
    display: "grid",
  },
  getLoadingProgress: {
    justifySelf: "center",
  },
  listItemSecondaryAction: {
    display: "grid",
    gridTemplateColumns: "1fr 1fr",
  },
  updateTextField: {
    marginRight: theme.spacing(9),
  },
}))

Now the last little remaining part for our app is the loading-icon-button.tsx:

import { CircularProgress, IconButton, Theme } from "@material-ui/core"
import { makeStyles } from "@material-ui/core/styles"
import clsx from "clsx"
import React, { ComponentType } from "react"
import { FC } from "react"

const useStyles = makeStyles((theme: Theme) => ({
  root: {
    margin: theme.spacing(1),
    position: "relative",
  },
  progress: {
    position: "absolute",
    top: 2,
    left: 2,
    zIndex: 1,
  },
}))

interface Props {
  loading: boolean
  Icon: ComponentType
  onClick: () => void
  className?: string
}

export const LoadingIconButton: FC<Props> = props => {
  const classes = useStyles()

  return (
    <div className={clsx(classes.root, props.className)}>
      <IconButton edge="end" onClick={props.onClick} disabled={props.loading}>
        <props.Icon />
      </IconButton>
      {props.loading && <CircularProgress size={45} className={classes.progress} />}
    </div>
  )
}

There you have it, a fully complete todo app which should look something like this:

todo-demo.gif

Conclusion

As you've noticed, using good patterns will save you time. If you identify similar code being written in your application, ask yourself "Can this code be abstracted?". If the answer is yes, I would recommend doing it as soon as possible in order to avoid code duplication. Through this series, we now have created a complete application. For the last part, we will glue everything together using open-source tools. If you want to see the source code for this, check out stator.

You can read the next and final article here.

 
Share this

Impressum

Full-stack developer with solid experience in the startup scene. I built SAAS from the ground up to achieve a high level of availability and scalability. Participated in multiple hackathons, always finishing first or second.