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.