A todo list with React + Redux


Posted by Christy on 2022-04-12

This is how I made a todo list with React + Reduct

0. File structure

.
├── README.md
└── src
    ├── App.js
    ├── App.test.js
    ├── components
    │   ├── TodoCleanDone.js
    │   ├── TodoFilter.js
    │   ├── TodoInput.js
    │   └── TodoItem.js
    ├── index.js
    └──  redux
        ├── actionTypes.js
        ├── actions.js
        ├── reducers
        │   ├── index.js
        │   └── todo.js: CRUD logic in todo
        ├── selectors.js
        └── store.js

1. Basic set up

a. reducers folder > index.js

// reducers folder > index.js

import { combineReducers } from 'redux';
import todos from './todos';
import users from './users';

export default combineReducers({
  todoState: todos,
  users
});

b. todos.js

// todos.js

import { ADD_TODO, DELETE_TODO, CHECK_TODO } from '../actionTypes';

let todoId = 1;

const initialState = {
  todos: []
};

export default function todosReducer(state = initialState, action) {
  switch (action.type) {
    case ADD_TODO: {
      return {
        ...state,
        todos: [
          ...state.todos,
          {
            id: todoId++,
            name: action.payload.name,
            isDone: false
          }
        ]
      };
    }

    case DELETE_TODO: {
      return {
        ...state,
        todos: state.todos.filter((todo) => todo.id !== action.payload.id)
      };
    }

    case CHECK_TODO: {
      return {
        ...state,
        todos: state.todos.map((todo) => todo.id !== action.payload.id)
      };
    }
    default: {
      return state;
    }
  }
}

c. actions.js: action is an object

// actions.js

import { ADD_TODO, DELETE_TODO, CHECK_TODO, ADD_USER } from './actionTypes';

export function addTodo(name) {
  return {
    type: ADD_TODO,
    payload: {
      name
    }
  };
}

export function deleteTodo(id) {
  return {
    type: DELETE_TODO,
    payload: {
      id
    }
  };
}

export function checkTodo(id) {
  return {
    type: CHECK_TODO,
    payload: {
      id
    }
  };
}

export function addUser(name) {
  return {
    type: ADD_USER,
    payload: {
      name
    }
  };
}

d. actionTypes.js

// actionTypes.js

export const ADD_TODO = 'add_todo';
export const DELETE_TODO = 'delete_todo';
export const CHECK_TODO = 'check_todo';

e. selectors.js

// selectors.js

export const selectTodos = (store) => store.todoState.todos;

f. store.js

// store.js

import { createStore } from 'redux';
import rootReducer from './reducers';

export default createStore(rootReducer);

g. index.js

// index.js

import ReactDOM from 'react-dom';
import App from './App';
import { Provider } from 'react-redux';
import store from './redux/store';

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

h. App.js: create/delete/done/undone

// App.js

import styled from 'styled-components';
import { useState, useCallback } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { selectTodos } from './redux/selectors';
import { addTodo, deleteTodo, checkTodo } from './redux/actions';

const TodoWrapper = styled.div`
  margin: 0 auto;
  text-align: center;
  padding: 30px;
`;

const Title = styled.div`
  color: #666;
  font-size: 30px;
  padding: 10px;
`;

const CreateToto = styled.div``;

const Input = styled.input`
  width: 300px;
  height: 24px;
`;

const AddButton = styled.button`
  font-size: 12px;
  margin-left: 10px;
  padding: 5px;
`;

const TodoList = styled.div`
  text-align: center;
  margin: 0 auto;
`;

const TodoItem = styled.div`
  display: flex;
  justify-content: space-between;
  text-align: center;
  margin: 5px auto;
  padding: 8px;
  width: 500px;
  border: 1px solid #ccc;
  border-radius: 3px;
`;

const ButtonWrapper = styled.div``;

const DeleteButton = styled.button`
  margin-left: 10px;
  font-size: 12px;
  padding: 5px;
`;

const CheckButton = styled.button`
  margin-left: 10px;
  font-size: 12px;
  padding: 5px;

  ${(props) =>
    props.$isDone &&
    `
    text-decoration: line-through;
  `}
`;

export default function App() {
  const todos = useSelector(selectTodos);
  const dispatch = useDispatch();
  const [value, setValue] = useState('');

  const handleInputTodo = useCallback((e) => {
    setValue(e.target.value);
  }, []);

  return (
    <TodoWrapper>
      <Title>Todo List</Title>
      <CreateToto>
        <Input value={value} onChange={handleInputTodo} />
        <AddButton
          onClick={() => {
            dispatch(addTodo(value));
            setValue('');
          }}>
          add todo
        </AddButton>
      </CreateToto>
      <TodoList>
        {todos.map((todo) => (
          <TodoItem $isDone key={todo.id} todo-id={todo.id}>
            {todo.name}
            <ButtonWrapper>
              <DeleteButton onClick={() => dispatch(deleteTodo(todo.id))}>delete</DeleteButton>
              <CheckButton onClick={() => dispatch(checkTodo(todo.id))}>Done</CheckButton>
            </ButtonWrapper>
          </TodoItem>
        ))}
      </TodoList>
    </TodoWrapper>
  );
}

2. Let's start with todos.js、actions.js、actionTypes.js、App.js

Create/delete tood

a. export const ADD_TODO = 'add_todo';

b. Write a function to do something in action

import { ADD_TODO } from './actionTypes';

export function addTodo(name) {
  return {
    type: ADD_TODO,
    payload: {
      name
    }
  };
}

c. it's about logic in todos

import { ADD_TODO, DELETE_TODO } from '../actionTypes';

let todoId = 1;

const initialState = {
  todos: []
};

export default function todosReducer(state = initialState, action) {
  switch (action.type) {
    case ADD_TODO: {
      return {
        ...state,
        todos: [
          ...state.todos,
          {
            id: todoId++,
            name: action.payload.name,
            isDone: false
          }
        ]
      };
    }

    case DELETE_TODO: {
      return {
        ...state,
        todos: state.todos.filter((todo) => todo.id !== action.payload.id)
      };
    }
    default: {
      return state;
    }
  }
}

d. error log: react.development.js:1476 Uncaught Error: Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for one of the following reasons...

Don't use useRef() in reducer function or it will show above error log

3. done/undone

a. export const CHECK_TODO = 'check_todo';

b. action

export function checkTodo(id) {
  return {
    type: CHECK_TODO,
    payload: {
      id
    }
  };
}

c. reducer

case CHECK_TODO: {
  return {
    ...state,
    todos: state.todos.map((todo) => {
      if (todo.id !== action.payload.id) return todo;
      return {
        ...todo,
        isDone: !todo.isDone
      };
    })
  };
}

d. App.js

  • force line break: word-wrap: break-word;
// App.js

// css
const TodoContent = styled.div`
  width: 100%;
  max-width: 240px;
  word-wrap: break-word;
  line-height: 26px;

  ${(props) =>
    props.$isDone &&
    `
    text-decoration: line-through;
  `}
`;

// logic
<TodoList>
  {todos.map((todo) => (
    <TodoItem key={todo.id}>
      <TodoContent $isDone={todo.isDone}>{todo.name}</TodoContent>
      <ButtonWrapper>
        <DeleteButton onClick={() => dispatch(deleteTodo(todo.id))}>delete</DeleteButton>
        <CheckButton onClick={() => dispatch(checkTodo(todo.id))}>
          {todo.isDone ? 'Undone' : 'Done'}
        </CheckButton>
      </ButtonWrapper>
    </TodoItem>
  ))}
</TodoList>

4. Clear completed todo

It's similar with delete, keep todo.isDone !== true, the same step

a. export const CLEAR_COMPLETED_TODO = 'clear_completed_todo';

b. action

export function clearCompletedTodo(id) {
  return {
    type: CLEAR_COMPLETED_TODO,
    payload: {
      id
    }
  };
}

c. reducer

case CLEAR_COMPLETED_TODO: {
  return {
    ...state,
    todos: state.todos.filter((todo) => todo.isDone !== true)
  };
}

d. App.js

<ClearCompleted onClick={(todo) => dispatch(clearCompletedTodo(todo.isDone))}>
  Clear Completed
</ClearCompleted>

5. Filter todo(all, undone, done)

a. Filter todo with state

I didn't use dispatch() but with filter method, if the status is global then do it with dispatch(), otherwise do it with state.

// App.js Filter state

import styled from 'styled-components';
import { useState, useCallback } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { selectTodos } from './redux/selectors';
import { addTodo, deleteTodo, checkTodo, clearCompletedTodo } from './redux/actions';

const TodoWrapper = styled.div`
  margin: 30px auto;
  text-align: center;
  padding: 30px;
  border: 1px solid #ccc;
  border-radius: 3px;
  max-width: 560px;
  width: 100%;
`;

const Title = styled.div`
  color: #666;
  font-size: 30px;
  padding: 10px;
`;

const CreateToto = styled.div``;

const Input = styled.input`
  width: 300px;
  height: 24px;
`;

const AddButton = styled.button`
  font-size: 12px;
  margin-left: 10px;
  padding: 5px;
  width: 60px;
`;

const SelectTodo = styled.div`
  padding: 20px;
`;

const TodoAllButton = styled.button`
  margin-left: 10px;
  width: 80px;
  padding: 3px;
`;

const TodoActiveButton = styled.button`
  margin-left: 10px;
  width: 80px;
  padding: 3px;
`;

const TodoCompletedButton = styled.button`
  margin-left: 10px;
  width: 80px;
  padding: 3px;
`;

const TodoList = styled.div`
  text-align: center;
  margin: 0 auto;
`;

const TodoItem = styled.div`
  display: flex;
  justify-content: space-between;
  text-align: center;
  margin: 5px auto;
  padding: 8px;
  width: 100%;
  max-width: 400px;
  border: 1px solid #ccc;
  border-radius: 3px;
`;

const TodoContent = styled.div`
  width: 100%;
  max-width: 240px;
  word-wrap: break-word;
  line-height: 26px;

  ${(props) =>
    props.$isDone &&
    `
    text-decoration: line-through;
  `}
`;

const ButtonWrapper = styled.div``;

const DeleteButton = styled.button`
  margin-left: 10px;
  font-size: 12px;
  padding: 5px;
  height: 28px;
  width: 60px;
`;

const CheckButton = styled.button`
  margin-left: 10px;
  font-size: 12px;
  padding: 5px;
  height: 28px;
  width: 60px;
`;

const ClearCompleted = styled.button`
  height: 28px;
  width: 160px;
  margin-top: 10px;
`;

export default function App() {
  const todos = useSelector(selectTodos);
  const dispatch = useDispatch();
  const [value, setValue] = useState('');
  const [filter, setFilter] = useState('all');

  const handleInputTodo = useCallback((e) => {
    setValue(e.target.value);
  }, []);

  const filterAll = () => {
    setFilter('all');
  };

  const filterDone = () => {
    setFilter('done');
  };

  const filterUndone = () => {
    setFilter('undone');
  };

  return (
    <TodoWrapper>
      <Title>Todo List</Title>
      <CreateToto>
        <Input value={value} onChange={handleInputTodo} />
        <AddButton
          onClick={() => {
            if (value) {
              dispatch(addTodo(value));
            }
            setValue('');
          }}>
          Add
        </AddButton>
      </CreateToto>
      <SelectTodo>
        <TodoAllButton onClick={filterAll}>All</TodoAllButton>
        <TodoActiveButton onClick={filterUndone}>Active</TodoActiveButton>
        <TodoCompletedButton onClick={filterDone}>Completed</TodoCompletedButton>
      </SelectTodo>
      <TodoList>
        {todos
          .filter((todo) => {
            if (filter === 'all') return todo;
            return filter === 'done' ? todo.isDone : !todo.isDone;
          })
          .map((todo) => (
            <TodoItem key={todo.id}>
              <TodoContent $isDone={todo.isDone}>{todo.name}</TodoContent>
              <ButtonWrapper>
                <DeleteButton onClick={() => dispatch(deleteTodo(todo.id))}>delete</DeleteButton>
                <CheckButton onClick={() => dispatch(checkTodo(todo.id))}>
                  {todo.isDone ? 'Undone' : 'Done'}
                </CheckButton>
              </ButtonWrapper>
            </TodoItem>
          ))}
      </TodoList>
      <ClearCompleted onClick={(todo) => dispatch(clearCompletedTodo(todo.isDone))}>
        Clear Completed
      </ClearCompleted>
    </TodoWrapper>
  );
}

Optimization 1. Trim blank or unfilled in input box: if (!value.trim()) return

Optimization 2. Press enter after fill in input box

const handleKeyPress = useCallback((e) => {
  if (!value.trim()) return;
  if (e.key === 'Enter') {
    dispatch(addTodo(e.target.value));
    setValue('');
  }
});

b. Put filter as state in reducers to select todo

"one UI = one state", below method is different from what I thought at first beginning. I don't get that after stuck a whole day.


Version A:

a. actionTypes: export const SET_FILTER = 'set_filter';

b. actions:

export function filterTodo(filter) {
  return {
    type: SET_FILTER,
    payload: {
      filter
    }
  };
}

c. reducers folder > filters:

import { SET_FILTER } from '../actionTypes';

const initialFilter = {
  filters: 'All'
};

export default function filterReducer(state = initialFilter, action) {
  switch (action.type) {
    case SET_FILTER: {
      return action.payload.filter;
    }

    default: {
      return state;
    }
  }
}

d. reducers folder > index.js:

import { combineReducers } from 'redux';
import todos from './todos';
import users from './users';
import filters from './filters';

export default combineReducers({
  todoState: todos,
  filters,
  users
});

e. selectors:

export const selectFilters = (store) => store.filters.filters;

f. App.js

f.1 const filters = useSelector(selectFilters);

f.2 What I am trying to do here is put three conditions(all, done, undone) in filter store. The problem I met here is I don't know how to select todo and show, I think it's .filter(...).map(...)


Version B: Put three filter conditions in reducers

a. actionTypes:

export const FILTER_ALL = 'filter_all';
export const FILTER_DONE = 'filter_done';
export const FILTER_UNDONE = 'filter_undone';

b. actions:

export function filterAll() {
  return {
    type: FILTER_ALL
  };
}

export function filterDone() {
  return {
    type: FILTER_DONE
  };
}

export function filterUndone() {
  return {
    type: FILTER_UNDONE
  };
}

c. reducers > todos

case FILTER_ALL: {
  return {
    ...state,
    filter: 'all'
  };
}

case FILTER_DONE: {
  return {
    ...state,
    filter: 'done'
  };
}

case FILTER_UNDONE: {
  return {
    ...state,
    filter: 'undone'
  };
}

d. selectors:

export const selectFilters = (store) => store.todoState.filter;

e. App.js

import styled from 'styled-components';
import { useState, useCallback } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { selectTodos, selectFilters } from './redux/selectors';
import {
  addTodo,
  deleteTodo,
  checkTodo,
  clearCompletedTodo,
  filterAll,
  filterDone,
  filterUndone
} from './redux/actions';

const TodoWrapper = styled.div`
  margin: 30px auto;
  text-align: center;
  padding: 30px;
  border: 1px solid #ccc;
  border-radius: 3px;
  max-width: 560px;
  width: 100%;
`;

const Title = styled.div`
  color: #666;
  font-size: 30px;
  padding: 10px;
`;

const CreateToto = styled.div``;

const Input = styled.input`
  width: 300px;
  height: 24px;
`;

const AddButton = styled.button`
  font-size: 12px;
  margin-left: 10px;
  padding: 5px;
  width: 60px;
`;

const FilterTodoWrapper = styled.div`
  padding: 20px;
`;

const TodoAllButton = styled.button`
  margin-left: 10px;
  width: 80px;
  padding: 3px;
`;

const TodoActiveButton = styled.button`
  margin-left: 10px;
  width: 80px;
  padding: 3px;
`;

const TodoCompletedButton = styled.button`
  margin-left: 10px;
  width: 80px;
  padding: 3px;
`;

const TodoList = styled.div`
  text-align: center;
  margin: 0 auto;
`;

const TodoItem = styled.div`
  display: flex;
  justify-content: space-between;
  text-align: center;
  margin: 5px auto;
  padding: 8px;
  width: 100%;
  max-width: 400px;
  border: 1px solid #ccc;
  border-radius: 3px;
`;

const TodoContent = styled.div`
  width: 100%;
  max-width: 240px;
  word-wrap: break-word;
  line-height: 26px;
  text-align: left;

  ${(props) =>
    props.$isDone &&
    `
    text-decoration: line-through;
  `}
`;

const ButtonWrapper = styled.div``;

const DeleteButton = styled.button`
  margin-left: 10px;
  font-size: 12px;
  padding: 5px;
  height: 28px;
  width: 60px;
`;

const CheckButton = styled.button`
  margin-left: 10px;
  font-size: 12px;
  padding: 5px;
  height: 28px;
  width: 60px;
`;

const ClearCompleted = styled.button`
  height: 28px;
  width: 160px;
  margin-top: 10px;
`;

export default function App() {
  const todos = useSelector(selectTodos);
  const filters = useSelector(selectFilters);
  const dispatch = useDispatch();
  const [value, setValue] = useState('');

  const handleInputTodo = useCallback((e) => {
    setValue(e.target.value);
  }, []);

  const handleKeyPress = (e) => {
    if (!value.trim()) return;
    if (e.key === 'Enter') {
      dispatch(addTodo(value));
      setValue('');
    }
  };

  return (
    <TodoWrapper>
      <Title>Todo List</Title>
      <CreateToto>
        <Input value={value} onChange={handleInputTodo} onKeyPress={handleKeyPress} />
        <AddButton
          onClick={() => {
            if (!value.trim()) return;
            dispatch(addTodo(value));
            setValue('');
          }}>
          Add
        </AddButton>
      </CreateToto>

      <FilterTodoWrapper>
        <ButtonWrapper>
          <TodoAllButton onClick={() => dispatch(filterAll('all'))}>All</TodoAllButton>
          <TodoActiveButton onClick={() => dispatch(filterUndone('undone'))}>
            Active
          </TodoActiveButton>
          <TodoCompletedButton onClick={() => dispatch(filterDone('done'))}>
            Completed
          </TodoCompletedButton>
        </ButtonWrapper>
      </FilterTodoWrapper>

      <TodoList>
        {todos
          .filter((todo) => {
            if (filters === 'all') return todo;
            if (filters === 'done') return todo.isDone;
            return !todo.isDone;
          })
          .map((todo) => (
            <TodoItem key={todo.id}>
              <TodoContent $isDone={todo.isDone}>{todo.content}</TodoContent>
              <ButtonWrapper>
                <DeleteButton onClick={() => dispatch(deleteTodo(todo.id))}>delete</DeleteButton>
                <CheckButton onClick={() => dispatch(checkTodo(todo.id))}>
                  {todo.isDone ? 'Undone' : 'Done'}
                </CheckButton>
              </ButtonWrapper>
            </TodoItem>
          ))}
      </TodoList>
      <ClearCompleted onClick={(todo) => dispatch(clearCompletedTodo(todo.isDone))}>
        Clear Completed
      </ClearCompleted>
    </TodoWrapper>
  );
}

Note: I should use memo, useCallback in react as best as possible.










Related Posts

React 多環境建置開發

React 多環境建置開發

JavaScript ES6

JavaScript ES6

W8_實作練習 + 作業檢討

W8_實作練習 + 作業檢討


Comments