What is this post about ?
Hello fellow humanoids. Today we will try to implement a basic todo app with Zustand in React Js. This post won't be focused much on the styling rather the bare minimum logic required.
Check out the app here : Todo App - Zustand
Content
- What is Zustand ?
- Zustand store structure
- Todo App - Adding Todos
- Todo App - Adding todo items
Lets go deep dive into each one and explore how it was implemented.
What is Zustand ?
A small, fast and scalable bearbones state-management solution using simplified flux principles. Has a comfy api based on hooks, isn't boilerplatey or opinionated.
Don't disregard it because it's cute. It has quite the claws, lots of time was spent to deal with common pitfalls, like the dreaded zombie child problem, react concurrency, and context loss between mixed renderers. It may be the one state-manager in the React space that gets all of these right.
Check it out-Zustand
Zustand store
import create from "zustand"; | |
import { v4 as uuidv4 } from "uuid"; | |
const setAddTodo = (set) => (todo) => | |
set((state) => ({ todos: [...state.todos, todo] })); | |
const setEditTodo = (set) => (id, todo) => | |
set((state) => { | |
const todos = state.todos.map((_t) => { | |
if (_t.id === id) { | |
console.log("Editing todo", todo); | |
return { ...todo, v: todo?.v ?? 0 + 1 }; | |
} | |
return _t; | |
}); | |
return { todos }; | |
}); | |
const setCreateNewTodo = (set) => () => { | |
const newKey = uuidv4(); | |
set((state) => ({ | |
todos: [ | |
...state.todos.map((t) => ({ ...t, isSelected: false })), | |
{ | |
id: newKey, | |
name: `Default Title - ${state.todos.length + 1}`, | |
items: [], | |
isSelected: true, | |
}, | |
], | |
})); | |
}; | |
const setSelectCurrentTodo = (set) => (todoId) => { | |
set(({ todos }) => ({ | |
todos: todos.map((t) => { | |
if (t.id === todoId) { | |
const newTodo = { ...t, isSelected: true }; | |
return newTodo; | |
} | |
return { ...t, isSelected: false }; | |
}), | |
})); | |
}; | |
const setAddNewTodoItem = (set) => (tId, value) => { | |
set((state) => { | |
const todos = state.todos.map((t) => { | |
if (t.id === tId) { | |
return { | |
...t, | |
items: [ | |
...t.items, | |
{ | |
id: uuidv4(), | |
value, | |
completed: false, | |
}, | |
], | |
}; | |
} | |
return t; | |
}); | |
return { | |
todos, | |
}; | |
}); | |
}; | |
const setCompleteTask = (set) => (tId, ItemId, value) => { | |
set((state) => { | |
return { | |
todos: state.todos.map((_t) => { | |
if (tId === _t.id) { | |
return { | |
..._t, | |
items: _t.items.map((_i) => { | |
if (_i.id === ItemId) { | |
return { ..._i, completed: value }; | |
} | |
return _i; | |
}), | |
}; | |
} | |
return _t; | |
}), | |
}; | |
}); | |
}; | |
const setDeleteTodo = (set) => (tId) => { | |
set((state) => { | |
return { | |
todos: state.todos.filter((_t) => _t.id !== tId), | |
}; | |
}); | |
}; | |
const getLocalStorage = (key) => JSON.parse(window.localStorage.getItem(key)); | |
export const useStore = create((set) => ({ | |
todos: getLocalStorage("todos") ?? [], | |
addTodo: setAddTodo(set), | |
editTodo: setEditTodo(set), | |
deleteTodo: setDeleteTodo(set), | |
createNewTodo: setCreateNewTodo(set), | |
selectCurrentTodo: setSelectCurrentTodo(set), | |
addNewTodoItem: setAddNewTodoItem(set), | |
completeTask: setCompleteTask(set), | |
})); |
Todo App - Adding Todos
We would add new Todo using
Add New Todo
button
import { useToasts } from "react-toast-notifications"; | |
import { ActiveTodos } from "./ActiveTodos"; | |
import "./App.css"; | |
import { CurrentTodoSection } from "./CurrentTodoSection"; | |
import { useStore } from "./store"; | |
const ADD_TODO_CONTENT = "Added new todo!"; | |
export const DELETE_TODO_CONTENT = "Deleted todo!"; | |
function App() { | |
const createNew = useStore((state) => state.createNewTodo); | |
const { addToast } = useToasts(); | |
const addNewTodo = () => { | |
createNew(); | |
addToast(ADD_TODO_CONTENT, { appearance: "success", autoDismiss: true }); | |
}; | |
return ( | |
<div className="App"> | |
<header>Todo App with Zustand</header> | |
<div className="content-container"> | |
<div className="content-sidebar"> | |
<div className="content-sidebar-item"> | |
<button className="btn-add-todo" onClick={addNewTodo}> | |
Add New Todo | |
</button> | |
</div> | |
<div className="content-sidebar-item content-sidebar-title"> | |
Active Todos | |
</div> | |
<ActiveTodos /> | |
</div> | |
<div className="content-main"> | |
<CurrentTodoSection /> | |
</div> | |
</div> | |
</div> | |
); | |
} | |
export default App; |
Todo App - Adding todo items
Let's create a basic comment component with basic utility functions
import { useEffect } from "react"; | |
import { useToasts } from "react-toast-notifications"; | |
import crossIcon from "./assets/cancel.png"; | |
import { useStore } from "./store"; | |
import { DELETE_TODO_CONTENT } from "./App"; | |
export const ActiveTodos = () => { | |
const todos = useStore( | |
(state) => state.todos, | |
(oldV, newV) => JSON.stringify(oldV) === JSON.stringify(newV) | |
); | |
const { addToast } = useToasts(); | |
const deleteTodo = useStore((state) => state.deleteTodo); | |
const setCurrentTodo = useStore((state) => state.selectCurrentTodo); | |
const setLocalStorage = (key, value) => window.localStorage.setItem(key, JSON.stringify(value)); | |
useEffect(() => { | |
return () => { | |
console.log("Writing todos... local storage"); | |
setLocalStorage("todos", todos); | |
}; | |
}); | |
const onDelete = (tId) => { | |
deleteTodo(tId); | |
addToast(DELETE_TODO_CONTENT, { appearance: "warning", autoDismiss: true }); | |
}; | |
const perCent = (t) => { | |
const completedTodos = t.items.filter((i) => i.completed).length; | |
const totalTodos = t.items.length; | |
return totalTodos > 0 | |
? `${parseInt((completedTodos / totalTodos) * 100)}` | |
: 0; | |
}; | |
const getStyle = (t) => { | |
const per = perCent(t); | |
console.log("per", per); | |
if (per > 0) { | |
return { | |
background: `linear-gradient( | |
to right, | |
darkcyan 0%, | |
darkcyan ${per}%, | |
darkcyan ${per}%, | |
lightcyan 100% | |
)`, | |
}; | |
} | |
return {}; | |
}; | |
return ( | |
<> | |
{todos.map((_t) => ( | |
<div | |
key={_t.id} | |
className={`content-sidebar-item todo-sidebar-item ${_t.isSelected && "content-sidebar-item-selected"}`} | |
onClick={() => setCurrentTodo(_t.id)} | |
style={getStyle(_t)} | |
> | |
<div>{_t.name}</div> | |
<div> | |
<img | |
src={crossIcon} | |
className="todo-item-delete" | |
alt="todo-delete-item-icon" | |
onClick={() => onDelete(_t.id)} /> | |
</div> | |
</div> | |
))} | |
</> | |
); | |
}; |
import { useState } from "react"; | |
import { useStore } from "./store"; | |
import { EditableText } from "./EditableText"; | |
export const CurrentTodoSection = () => { | |
const todos = useStore((state) => state.todos); | |
const addNewTodoItem = useStore((state) => state.addNewTodoItem); | |
const editTodo = useStore((state) => state.editTodo); | |
const setCompleteTask = useStore((state) => state.completeTask); | |
const [inputText, setInputText] = useState(""); | |
const currentTodo = todos.filter((t) => t.isSelected)?.[0] ?? {}; | |
const onEditFieldTodoTitle = (text) => { | |
editTodo(currentTodo.id, { ...currentTodo, name: text }); | |
}; | |
const onEditTodoItem = (tId, itemId, value) => { | |
console.log("Editing Item task", tId, itemId, value); | |
editTodo(tId, { | |
...currentTodo, | |
items: currentTodo.items.map((_i) => { | |
if (_i.id === itemId) { | |
return { ..._i, value }; | |
} | |
return _i; | |
}), | |
}); | |
}; | |
return Object.keys(currentTodo).length > 0 ? ( | |
<div className="todo-main"> | |
<div className="todo-title"> | |
<EditableText | |
value={currentTodo.name} | |
onEditField={onEditFieldTodoTitle} /> | |
</div> | |
<div className="todo-playground"> | |
<div className="todo-input-area"> | |
<input | |
type="text" | |
value={inputText} | |
onChange={(e) => setInputText(e.target.value)} /> | |
<button | |
onClick={() => { | |
addNewTodoItem(currentTodo.id, inputText); | |
setInputText(""); | |
}} | |
> | |
Insert | |
</button> | |
</div> | |
<div className="todo-list"> | |
{currentTodo.items.map((_i) => { | |
return ( | |
<div className="todo-list-item" key={_i.id}> | |
<input | |
type="checkbox" | |
id={_i.id} | |
name={_i.id} | |
checked={_i.completed} | |
onChange={() => { | |
currentTodo.id && | |
setCompleteTask(currentTodo.id, _i.id, !_i.completed); | |
}} /> | |
<label for={_i.id}> | |
<EditableText | |
value={_i.value} | |
onEditField={(text) => onEditTodoItem(currentTodo.id, _i.id, text)} /> | |
</label> | |
</div> | |
); | |
})} | |
</div> | |
</div> | |
</div> | |
) : null; | |
}; |
import { useEffect, useState } from "react"; | |
import { useToasts } from "react-toast-notifications"; | |
import editIcon from "./assets/edit.png"; | |
export const EditableText = (props) => { | |
const [showEdit, setShowEdit] = useState(false); | |
const [onEdit, setOnEdit] = useState(false); | |
const [inputText, setInputText] = useState(props.value); | |
const { addToast } = useToasts(); | |
useEffect(() => { | |
setInputText(props.value); | |
}, [props.value]); | |
const onMouseEnter = () => { | |
setShowEdit(true); | |
}; | |
const onMouseLeave = () => setShowEdit(false); | |
const onAdd = () => { | |
props.onEditField(inputText); | |
setOnEdit(false); | |
setShowEdit(false); | |
addToast("Todo item edited", { | |
appearance: "info", | |
autoDismiss: true, | |
}); | |
}; | |
return ( | |
<div | |
className="editable-text" | |
onMouseEnter={onMouseEnter} | |
onMouseLeave={onMouseLeave} | |
> | |
{!onEdit ? ( | |
<> | |
<div className="editable-text-child">{props.value}</div> | |
{showEdit && ( | |
<img | |
src={editIcon} | |
className="editable-text-edit" | |
alt="edit-icon" | |
onClick={(e) => { | |
e.stopPropagation(); | |
setOnEdit(true); | |
}} /> | |
)} | |
</> | |
) : ( | |
<> | |
<input | |
className="editable-text-input" | |
value={inputText} | |
onChange={(e) => setInputText(e.target.value)} /> | |
<button onClick={onAdd}>Save</button> | |
</> | |
)} | |
</div> | |
); | |
}; |
Conclusion
This app was made as part of learning new components which are used in real life applications.
Stay safe and lend a hand to another :)
Top comments (0)