Your Web News in One Place

Help Webnuz

Referal links:

Sign up for GreenGeeks web hosting
February 27, 2022 01:28 pm GMT

Todo list with React, Redux, TypeScript and drag and drop

In this article I will present how to make simple ToDo application with React, Redux, TypeScript and drag and drop library.

Basically, Redux is overkill to that simple application as ToDo application but a lot of technical tasks before "job Interview" or even sometimes during live coding require that approach to check your knowledge about Redux + React.

I will use React + TypeScript, I prefer that style of coding on the frontend but if you want you can use JavaScript without TS :)

To styling components I will use MUI with version 5.4.0 and to the drag and drop events I will use react-beautiful-dnd and redux-toolkit for redux.

Requirements:

basic knowledge about JavaScript/TypeScript
basic knowledge React
basic knowledge Redux
basic knowledge about concept css-in-js
These points are not required but if you know how these things works it will be better for your because I will not explain how
React / Typescript / [MUI(https://mui.com/)] works.

So, the first step is creating simple Create-React-App with TS template and redux.

npx create-react-app react-todo-list-redux --template redux-typescript

After installation install another required dependencies.

npm i @emotion/react @emotion/styled @mui/icons-material @mui/material react-beautiful-dnd uuid

and to devDependencies

npm i @types/react-beautiful-dnd @types/uuid -D

After installation all required dependencies your application folder should look similar to this:

started-package

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/91fbze49yq1eczrotbkw.png)

and you can not delete nessesary files/folders for this project as
App.css
App.test.tsx
index.css
logo.svg
serviceWorker.ts
setupTests.ts
features folder
app folder

We will not use them :)

After that create folder redux and inside this folder create another folder called store and inside store folder create file index.ts.

cd src && mkdir redux && cd redux && mkdir store && cd store && touch index.ts

and copy this code below to store/index.ts

import { configureStore, combineReducers } from '@reduxjs/toolkit';export const store = configureStore({  reducer: combineReducers({}),});export type StoreDispatch = typeof store.dispatch;export type StoreState = ReturnType<typeof store.getState>;

The code above is storing your reducers and combine them to use in our application but for now reducers are empty.

The next step is start project
If you deleted files you probably will get some errors about deleted files. Just remove every not necessary imports in the

App.tsx and in the index.tx files.

To index.tsx import CssBaseLine component from @mui/material/CssBaseline and add as a child to the <Provider />. 

This component provides normalized styling to the components.

import CssBaseline from '@mui/material/CssBaseline';

app-and-index-photo

After that type in your console:

npm start

If everything is going well, you will see blank page and after that open your console and you probably see error called Store does not have a valid reducer. Make sure the argument passed to combineReducers is an object whose values are reducers.

  • this error is complety fine at the moment because we don't have any reducers yet!

Create new folders

We are gonna create new folders called components, types and slice folder in the src directory. Inside types folder create index.ts file.

In the root directory type:

cd src && mkdir components && mkdir types && cd types && touch index.ts && cd ../redux && mkdir slice

Basic Styling in App.tsx
Open file App.tsx and add this code bellow to your file. This is a basic styling for with container and grid.

// src/App.tsx

import Typography from '@mui/material/Typography';import Container from '@mui/material/Container';import Grid from '@mui/material/Grid';function App() {  return (    <Container>      <Typography textAlign='center' variant='h3' mt={3} mb={5}>        This is a ToDo APP with Redux      </Typography>      <Grid container spacing={3} justifyContent='center'>        <Grid item md={4}>          ...        </Grid>        <Grid item md={4}>          ...        </Grid>        <Grid item md={4}>          ...        </Grid>      </Grid>    </Container>  );}export default App;

Create new files in components folder
Go to the components folder and insiade create new folder called columns and inside this folder create 3 files called:
ToDo.tsx,
InProgress.tsx
and Done.tsx.

After after that go to folder components and create file called ColumnLayout.tsx and copy code below inside.

// src/components/ColumnLayout.tsx

import React, { useState } from 'react';import Button from '@mui/material/Button';import TextField from '@mui/material/TextField';import Box from '@mui/material/Box';import List from '@mui/material/List';import ListItem from '@mui/material/ListItem';import ListItemText from '@mui/material/ListItemText';import Checkbox from '@mui/material/Checkbox';import IconButton from '@mui/material/IconButton';import DeleteIcon from '@mui/icons-material/Delete';import Alert from '@mui/material/Alert';import Collapse from '@mui/material/Collapse';import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';import { useDispatch } from 'react-redux';import { StoreDispatch } from '../redux/store';import { IColumnLayoutProps } from '../types';const ColumnLayout: React.FC<IColumnLayoutProps> = ({  labelText,  addHandler,  removeHandler,  completedHandler,  selectorState,  droppableId,}) => {  const [isError, setIsError] = useState({    isShow: false,    text: '',  });  const [textDescription, setTextDescription] = useState('');  const dispatch = useDispatch<StoreDispatch>();  const handleOnChange = ({    target: { value },  }: React.ChangeEvent<HTMLInputElement>) => {    setTextDescription(value);    setIsError({      isShow: value.length > 200,      text:        value.length > 200          ? 'The input value cannot be more than 200 characters'          : '',    });  };  const handleOnBlur = () => {    setIsError({ ...isError, isShow: false });  };  const handleOnClick = () => {    if (!isError) {      dispatch(addHandler(textDescription));      setTextDescription('');    }  };  const handleInputKeyDown = ({    target,    key,  }: React.KeyboardEvent<HTMLInputElement>) => {    if (key === 'Enter') {      if (        (target as HTMLInputElement).value.length > 0 &&        (target as HTMLInputElement).value.length <= 200      ) {        handleOnClick();      } else {        setIsError({          isShow: true,          text: 'The input value cannot be empty',        });      }    }  };  return (    <Box borderRadius={1} width='100%' sx={{ boxShadow: 2, p: 3 }}>      <TextField        fullWidth        label={labelText}        onChange={handleOnChange}        onBlur={handleOnBlur}        onKeyDown={handleInputKeyDown}        value={textDescription}        variant='outlined'        size='small'      />      <Collapse in={isError.isShow}>        <Alert severity='error' sx={{ my: 1 }}>          {isError.text}        </Alert>      </Collapse>      <Box width='100%' display='flex' justifyContent='center'>        <Button          size='medium'          sx={{ my: 1, maxWidth: 200 }}          variant='outlined'          color='primary'          fullWidth          onClick={handleOnClick}          disabled={            textDescription.length === 0 || textDescription.length > 200          }        >          Add Item        </Button>      </Box>      <List sx={{ minHeight: '300px' }}>        {selectorState.map(          ({ id, text, isFinished, createdAt, updatedAt }, index: number) => {            return (              <ListItem                sx={{                  position: 'relative',                  border: '1px solid #989898',                  bgcolor: '#fff',                  my: 1,                  borderRadius: '3px',                  '& .MuiTypography-root': {                    display: 'flex',                    alignItems: 'center',                  },                }}              >                <ListItemText                  sx={{                    textDecoration: isFinished ? 'line-through' : 'none',                    wordBreak: 'break-word',                  }}                >                  <IconButton sx={{ p: 1, mr: 1 }}>                    <ArrowDownwardIcon />                  </IconButton>                  <Box                    component='span'                    width='100%'                    position='absolute'                    top='0'                    fontSize='.7rem'                  >                    {updatedAt ? 'Updated' : 'Created'} at:{' '}                    {updatedAt || createdAt}                  </Box>                  <Box component='span' width='100%'>                    {text}                  </Box>                  <Box display='flex' component='span'>                    <IconButton onClick={() => dispatch(removeHandler(id))}>                      <DeleteIcon />                    </IconButton>                    <Checkbox                      edge='end'                      value={isFinished}                      checked={isFinished}                      inputProps={{ 'aria-label': 'controlled' }}                      onChange={() =>                        dispatch(                          completedHandler({                            isFinished: !isFinished,                            id,                            updatedAt: new Date().toLocaleString(),                          })                        )                      }                    />                  </Box>                </ListItemText>              </ListItem>            );          }        )}      </List>    </Box>  );};export default ColumnLayout;

As an explanation

Code above contains styling, events for each column ToDo, In Progress and Done. It's also contains props which will be used in the redux reducers to store states and update everything dynamically.

After that go to the types folder and open index.ts and copy this code below which contains model for state reducers, types for ColumnLayout component and types ActionSlice/TUpdateTextShowed for PayloadAction from @reduxjs/toolkit:

// src/types/index.ts

import { AnyAction } from '@reduxjs/toolkit';export interface IModel {  id: string;  text: string;  isFinished: boolean;  createdAt?: string;  updatedAt?: string;  isTextShowed?: boolean;}export type TActionSlice = Omit<IModel, 'text'>;export type TUpdateTextShowed = Omit<TActionSlice, 'isFinished'>;export interface IColumnLayoutProps {  labelText?: string;  addHandler: (v: string) => AnyAction;  removeHandler: (v: string) => AnyAction;  completedHandler: (v: TActionSlice) => AnyAction;  selectorState: IModel[];  droppableId: string;  updateTextShowed: (v: TUpdateTextShowed) => AnyAction;}

Update columns files
Go to files Done.tsx, ToDo.tsx and InProgress.tsx in columns folder and copy code below and paste into them:
// src/components/columns/InProgress.tsx

import Typography from '@mui/material/Typography';import { useSelector } from 'react-redux';import { StoreState } from '../../redux/store';import { inProgressSlice } from '../../redux/slice/inProgress';import ColumnLayout from '../ColumnLayout';export function InProgressColumn() {  const { inProgress } = useSelector((state: StoreState) => state);  const {    actions: { completeStatus, remove, add, updateTextShowed },  } = inProgressSlice;  return (    <>      <Typography mb={3}>All inProgress tasks: {inProgress.length}</Typography>      <ColumnLayout        droppableId='inProgress'        labelText="Type 'in progress' item"        completedHandler={completeStatus}        removeHandler={remove}        addHandler={add}        selectorState={inProgress}        updateTextShowed={updateTextShowed}      />    </>  );}

// src/components/columns/Done.tsx

import Typography from '@mui/material/Typography';import { useSelector } from 'react-redux';import { StoreState } from '../../redux/store';import { doneSlice } from '../../redux/slice/done';import ColumnLayout from '../ColumnLayout';export function DoneColumn() {  const { done } = useSelector((state: StoreState) => state);  const {    actions: { completeStatus, remove, add, updateTextShowed },  } = doneSlice;  return (    <>      <Typography mb={3}>All done tasks: {done.length}</Typography>      <ColumnLayout        droppableId='done'        labelText="Type 'done' item"        completedHandler={completeStatus}        removeHandler={remove}        addHandler={add}        selectorState={done}        updateTextShowed={updateTextShowed}      />    </>  );}

// src/components/columns/ToDo.tsx

import Typography from '@mui/material/Typography';import { useSelector } from 'react-redux';import { StoreState } from '../../redux/store';import { todoSlice } from '../../redux/slice/todo';import ColumnLayout from '../ColumnLayout';export function ToDoColumn() {  const { todo } = useSelector((state: StoreState) => state);  const {    actions: { completeStatus, remove, add, updateTextShowed },  } = todoSlice;  return (    <>      <Typography mb={3}>All todo tasks: {todo.length}</Typography>      <ColumnLayout        droppableId='todo'        labelText="Type 'to do' item"        completedHandler={completeStatus}        removeHandler={remove}        addHandler={add}        selectorState={todo}        updateTextShowed={updateTextShowed}      />    </>  );}

As an explanation
Code above contains resuable ColumnLayout component which have props for update redux state.

After that go to App.tsx file and change every ... to the column components.
// src/App.tsx

import Typography from '@mui/material/Typography';import Container from '@mui/material/Container';import Grid from '@mui/material/Grid';import { ToDoColumn } from './components/columns/ToDo';import { InProgressColumn } from './components/columns/InProgress';import { DoneColumn } from './components/columns/Done';function App() {  return (    <Container>      <Typography textAlign='center' variant='h3' mt={3} mb={5}>        This is a ToDo APP with Redux      </Typography>      <Grid container spacing={3} justifyContent='center'>        <Grid item md={4}>          <ToDoColumn />        </Grid>        <Grid item md={4}>          <InProgressColumn />        </Grid>        <Grid item md={4}>          <DoneColumn />        </Grid>      </Grid>    </Container>  );}export default App;

Create slices in the redux folder
If you run your application you will see a few errors about not exists code. We will gonna fix this now.

Go to the redux and slice folder and create 3 files called: done.ts, inProgress.ts and todo.ts.

Each files contains own state from createSlice() method and actions with reducers. So we need to create methods that allows to add/update/remove state in the columns.
// src/redux/slice/done.ts

import { createCustomSlice } from './customSlice';export const doneSlice = createCustomSlice('done');// src/redux/slice/inProgress.tsimport { createCustomSlice } from './customSlice';export const inProgressSlice = createCustomSlice('progress');// src/redux/slice/todo.tsimport { createCustomSlice } from './customSlice';export const todoSlice = createCustomSlice('todo');and create file customSlice.ts in the slice folderimport { createSlice, PayloadAction } from '@reduxjs/toolkit';import { v4 as uuidv4 } from 'uuid';import { TActionSlice, TUpdateTextShowed, IModel } from '../../types';const initialState: IModel[] = [];export const createCustomSlice = (name: string) => {  const {    actions: { add, remove, completeStatus, reorder, update, updateTextShowed },    reducer,  } = createSlice({    name,    initialState,    reducers: {      add: {        reducer: (state, action: PayloadAction<IModel>) => {          state.push(action.payload);        },        prepare: (text: string) => ({          payload: {            id: uuidv4(),            text,            isFinished: false,            createdAt: new Date().toLocaleString(),            isTextShowed: false,          } as IModel,        }),      },      update(state, action) {        state.splice(          action.payload.destination.index,          0,          action.payload.filterState        );      },      remove(state, action: PayloadAction<string>) {        const index = state.findIndex(({ id }) => id === action.payload);        state.splice(index, 1);      },      completeStatus(state, action: PayloadAction<TActionSlice>) {        const index = state.findIndex(({ id }) => id === action.payload.id);        state[index].isFinished = action.payload.isFinished;        state[index].updatedAt = action.payload.updatedAt;      },      updateTextShowed(state, action: PayloadAction<TUpdateTextShowed>) {        const index = state.findIndex(({ id }) => id === action.payload.id);        state[index].isTextShowed = action.payload.isTextShowed;      },      reorder(state, action) {        const [removed] = state.splice(action.payload.source.index, 1);        state.splice(action.payload.destination.index, 0, removed);      },    },  });  return {    actions: { add, remove, completeStatus, reorder, update, updateTextShowed },    reducer,  };};

This file contains all nessesary reducers, actions and initial state required to propery works redux state mangement to update store. Each file corresponds to own state.

After update files in the slice folder, you need to go to to the store folder and in the index.ts file import reducers from slice folder and combine them together in the store.
// src/redix/store/index.ts

import { configureStore, combineReducers } from "@reduxjs/toolkit";import { doneSlice } from "../slice/done";import { inProgressSlice } from "../slice/inProgress";import { todoSlice } from "../slice/todo";export const store = configureStore({  reducer: combineReducers({    done: doneSlice.reducer,    inProgress: inProgressSlice.reducer,    todo: todoSlice.reducer,  }),});export type StoreDispatch = typeof store.dispatch;export type StoreState = ReturnType<typeof store.getState>;

If everything went well you should have a working ToDo app but still without drag and drop!

basic-app

Update files about drag and drop!
We need to modify a few files to get working drag and drop in our simple application. We are using react-beautiful-dnd which required a DragDropContext as parent component to each Draggable component.

So first, you need to go to App.tsx and replace existing code with this:
// src/App.tsx

import Container from '@mui/material/Container';import Grid from '@mui/material/Grid';import Typography from '@mui/material/Typography';import { DragDropContext, DropResult } from 'react-beautiful-dnd';import { useDispatch, useSelector } from 'react-redux';import { ToDoColumn } from './components/columns/ToDo';import { DoneColumn } from './components/columns/Done';import { InProgressColumn } from './components/columns/InProgress';import { todoSlice as todo } from './redux/slice/todo';import { inProgressSlice as inProgress } from './redux/slice/inProgress';import { doneSlice as done } from './redux/slice/done';import { StoreState } from './redux/store';import { IModel } from './types';type TAllSilces = 'todo' | 'inProgress' | 'done';function App() {  const dispatch = useDispatch();  const appState = useSelector((state: StoreState) => state);  const onDragEnd = (result: DropResult) => {    if (!result.destination) {      return;    }    const { destination, source, draggableId } = result;    const allSlices = { todo, inProgress, done };    if (destination.droppableId === source.droppableId) {      dispatch(        allSlices[destination.droppableId as TAllSilces].actions.reorder(result)      );    } else {      const [filterState] = (        (appState as any)[source.droppableId] as IModel[]      ).filter(({ id }) => id === draggableId);      dispatch(        allSlices[source.droppableId as TAllSilces].actions.remove(draggableId)      );      dispatch(        allSlices[destination.droppableId as TAllSilces].actions.update({          ...result,          filterState,        })      );    }  };  return (    <Container>      <Typography textAlign='center' variant='h3' mt={3} mb={5}>        This is a ToDo APP with Redux      </Typography>{' '}      <Grid container spacing={3} justifyContent='center'>        <DragDropContext onDragEnd={(res) => onDragEnd(res)}>          <Grid item md={4}>            <ToDoColumn />          </Grid>          <Grid item md={4}>            <InProgressColumn />          </Grid>          <Grid item md={4}>            <DoneColumn />          </Grid>        </DragDropContext>      </Grid>    </Container>  );}export default App;

The above file was updated about DragDropContext as a mentioned eariler and also with onDragEnd function which is a function when dragging and dropping are over.

This function also check current id's of droppable with destination and source. If destination is equal to source and indexes are different so it gona change position of dragging element in the list. If destinatnion and source is not equal so there are dynamic funtions where depends where droppableId is equal that will fire update and remove reducers to update store.

Next you have to go to the ColumnLayout.tsx file and replace existing code with this:

// src/components/ColumnLayout.tsx

import React, { useState } from 'react';import Button from '@mui/material/Button';import TextField from '@mui/material/TextField';import Box from '@mui/material/Box';import List from '@mui/material/List';import ListItem from '@mui/material/ListItem';import ListItemText from '@mui/material/ListItemText';import Checkbox from '@mui/material/Checkbox';import IconButton from '@mui/material/IconButton';import DeleteIcon from '@mui/icons-material/Delete';import Alert from '@mui/material/Alert';import Collapse from '@mui/material/Collapse';import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';import { useDispatch } from 'react-redux';import { Droppable, Draggable } from 'react-beautiful-dnd';import { StoreDispatch } from '../redux/store';import { IColumnLayoutProps } from '../types';const ColumnLayout: React.FC<IColumnLayoutProps> = ({  labelText,  addHandler,  removeHandler,  completedHandler,  selectorState,  droppableId,  updateTextShowed,}) => {  const [isError, setIsError] = useState({    isShow: false,    text: '',  });  const [textDescription, setTextDescription] = useState('');  const dispatch = useDispatch<StoreDispatch>();  const handleOnChange = ({    target: { value },  }: React.ChangeEvent<HTMLInputElement>) => {    setTextDescription(value);    setIsError({      isShow: value.length > 200,      text:        value.length > 200          ? 'The input value cannot be more than 200 characters'          : '',    });  };  const handleOnBlur = () => {    setIsError({ ...isError, isShow: false });  };  const handleOnClick = () => {    if (!isError.isShow) {      dispatch(addHandler(textDescription));      setTextDescription('');    }  };  const handleInputKeyDown = ({    target,    key,  }: React.KeyboardEvent<HTMLInputElement>) => {    if (key === 'Enter') {      if (        (target as HTMLInputElement).value.length > 0 &&        (target as HTMLInputElement).value.length <= 200      ) {        handleOnClick();      } else {        setIsError({          isShow: true,          text: 'The input value cannot be empty',        });      }    }  };  return (    <Box borderRadius={1} width='100%' sx={{ boxShadow: 2, p: 3 }}>      <TextField        fullWidth        label={labelText}        onChange={handleOnChange}        onBlur={handleOnBlur}        onKeyDown={handleInputKeyDown}        value={textDescription}        variant='outlined'        size='small'      />      <Collapse in={isError.isShow}>        <Alert severity='error' sx={{ my: 1 }}>          {isError.text}        </Alert>      </Collapse>      <Box width='100%' display='flex' justifyContent='center'>        <Button          size='medium'          sx={{ my: 1, maxWidth: 200 }}          variant='outlined'          color='primary'          fullWidth          onClick={handleOnClick}          onKeyDown={({ key }) => key === 'Enter' && handleOnClick()}          disabled={            textDescription.length === 0 || textDescription.length > 200          }        >          Add Item        </Button>      </Box>      <Droppable droppableId={droppableId}>        {(provided) => (          <List            sx={{              minHeight: '300px',              li: {                flexDirection: 'column',              },              '& .MuiListItemText-root': {                width: '100%',              },            }}            ref={provided.innerRef}            {...provided.droppableProps}          >            {selectorState.map(              (                { id, text, isFinished, createdAt, updatedAt, isTextShowed },                index: number              ) => (                <Draggable key={id} draggableId={id} index={index}>                  {(provided, snapshot) => (                    <ListItem                      sx={{                        transition: '.3s ease background-color',                        color: snapshot.isDragging ? '#fff' : '#000',                        bgcolor: snapshot.isDragging ? '#000' : '#fff',                        position: 'relative',                        border: '1px solid #989898',                        my: 1,                        borderRadius: '3px',                        '& .MuiTypography-root': {                          display: 'flex',                          alignItems: 'center',                        },                      }}                      ref={provided.innerRef}                      {...provided.draggableProps}                      {...provided.dragHandleProps}                    >                      <ListItemText                        sx={{                          textDecoration: isFinished ? 'line-through' : 'none',                          wordBreak: 'break-word',                        }}                      >                        <IconButton                          sx={{ p: 1, mr: 1 }}                          onClick={() =>                            dispatch(                              updateTextShowed({                                id,                                isTextShowed: !isTextShowed,                              })                            )                          }                        >                          <ArrowDownwardIcon                            sx={{                              color: snapshot.isDragging ? '#fff' : '#000',                              transform: !isTextShowed ? 'rotate(180deg)' : '',                            }}                          />                        </IconButton>                        <Box                          component='span'                          width='100%'                          position='absolute'                          top='0'                          fontSize='.7rem'                        >                          {updatedAt ? 'Updated' : 'Created'} at:{' '}                          {updatedAt || createdAt}                        </Box>                        <Box component='span' width='100%'>                          {text}                        </Box>                        <Box display='flex' component='span'>                          <IconButton                            onClick={() => dispatch(removeHandler(id))}                          >                            <DeleteIcon                              sx={{                                color: snapshot.isDragging ? '#fff' : '#000',                              }}                            />                          </IconButton>                          <Checkbox                            edge='end'                            value={isFinished}                            checked={isFinished}                            inputProps={{ 'aria-label': 'controlled' }}                            onChange={() =>                              dispatch(                                completedHandler({                                  isFinished: !isFinished,                                  id,                                  updatedAt: new Date().toLocaleString(),                                })                              )                            }                          />                        </Box>                      </ListItemText>                      <Collapse in={isTextShowed}>                        You can add here some content{' '}                        <span role='img' aria-label='emoji'>                                                  </span>                      </Collapse>                    </ListItem>                  )}                </Draggable>              )            )}            {provided.placeholder}          </List>        )}      </Droppable>    </Box>  );};export default ColumnLayout;

The file above was updated with Draggable and Droppable components. Draggable component required unique draggableId to recognize which element is current dragging and index to update state when drag will end. Whereas, the Droppable component required droppableId which also is unique and recognize a place when the components are dragged and dropped.

The app structure should looks similar to this in the end:

app-strucutre

Conclusion
This application is very simple and contains basic approach how to use Redux + React + Drag And Drop. You can freely modify this code and if you find some bugs let me know.


Original Link: https://dev.to/sananayab/todo-list-with-react-redux-typescript-and-drag-and-drop-53e5

Share this article:    Share on Facebook
View Full Article

Dev To

An online community for sharing and discovering great ideas, having debates, and making friends

More About this Source Visit Dev To