用 React + Redux 做一個 todo list 吧


Posted by Christy on 2021-12-29

本文為 Lidemy W23 作業一「用 React Redux 做一個 todo list」的實作過程

零、前情提要

看完 Dan 哥的 Presentational and Container Components 以後,決定要用 useDispatch() 串接 React Redux,因此把之前練習 connect() 的方式改回來

先不用 component 包起來,而是直接全部寫在 App.js 裡,等實作功能都完成後,再去優化

  • reducers 裡面都是 pure function,不會在裡面呼叫 API,也不會在裡面寫 local storage,唯一會做的事就是「回傳一個新的狀態」。

  • action 只是一個 js 的物件而已

  • store 的資料跟 dispatch action 分開的好處是,方便做測試

  • global 的資料才適合存在 redux 裡面,其他的放在 component 就好了;例如使用者資料,就是每個 component 都會用到的東西

資料夾結構:

  • SRC folder

    • redux folder

      • reducers folder

        • index.js: 利用 combineReducers 把 reducers 放在一起

        • todos.js: todo 相關的邏輯,例如新增、刪除等

        • users.js: 使用者相關的邏輯,例如新增使用者

      • actions.js: action creators 的函式們

      • actionTypes.js: action constants 的規範字元

      • selectors.js: 把資料從 store 取出來

      • store.js: 創建 store

    • App.js

    • index.js

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
});

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;
    }
  }
}

actions.js: action 只是一個 js 的物件而已

// 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
    }
  };
}

actionTypes.js

// actionTypes.js

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

selectors.js

// selectors.js

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

store.js

// store.js

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

export default createStore(rootReducer);

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')
);

App.js (新增、刪除跟著影片一起做了,完成 / 未完成做到一半)

// 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>
  );
}

實作 todo 主要會用到 todos.js、actions.js、actionTypes.js、App.js,讓我們開始吧

一、新增、刪除 todo 功能

  1. 規範字元 export const ADD_TODO = 'add_todo';

  2. 把要做的事在 action 裡面寫成函式

import { ADD_TODO } from './actionTypes';

export function addTodo(name) {
  return {
    type: ADD_TODO,
    payload: {
      name
    }
  };
}
  1. 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;
    }
  }
}
  1. 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 下略

不能在 reducer 的函式裡面用 useRef(),會出現上面的錯誤訊息

二、完成 / 未完成

  1. 定義規範字元 export const CHECK_TODO = 'check_todo';

  2. 行動

export function checkTodo(id) {
  return {
    type: CHECK_TODO,
    payload: {
      id
    }
  };
}
  1. reducer
case CHECK_TODO: {
  return {
    ...state,
    todos: state.todos.map((todo) => {
      if (todo.id !== action.payload.id) return todo;
      return {
        ...todo,
        isDone: !todo.isDone
      };
    })
  };
}
  1. App.js

    • 強迫換行: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>

三、清空完成的 todo

跟刪除類似,如果 todo.isDone !== true 就留下來

一樣的步驟:

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>

四、篩選 todo(全部、未完成、已完成)

1. 這個用原本 state 的方式去做了

這個我沒有用 dispatch(),而是用 filter 的方式做,但是什麼時候要用 dispatch() 什麼時候不要用啊?就是狀態是 global 時候用 dispatch() 的方式,其他時候用 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>
  );
}

小優化 1:如果輸入框沒有填或者是有空白,就不給輸入:if (!value.trim()) return

小優化 2:輸入框 enter 自動輸入

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

2. 試著把 filter 變成狀態放在 reducers 裡面,用這個方式篩選 todo

原本用下次的做法,但跟我一開始的想法不太一樣,做了一天還是做不出來,我覺得我還是只會做「畫面改變三次就是三種狀態」的後來那一個


版本 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 我想要實作的是:在 filter 這個 store 裡面,有三種情形,全部、完成、未完成,遇到的困難是:在這裡面我不知道怎麼寫「把 todo 先篩選後顯示」也就是 .filter(...).map(...) 跟之前用的方法一樣,我不知道該怎麼做

reducers 的 filter 裡面,好像不能夠把動作一次寫完?因為 App.js 裡面的 .map() 才是主要的顯示關鍵


版本 B: 把 filter 的三種狀態放在 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>
  );
}

註:上傳作業前有稍微優化一下程式碼,但是忘記 react 客家精神避免重複 render 沒有用 memo, useCallback 那些包好包滿...










Related Posts

關於 console.log 印出物件時所需注意的

關於 console.log 印出物件時所需注意的

SQL-injection lab(3)

SQL-injection lab(3)

Styled-components

Styled-components


Comments