Skip to main content

Next.js To Do UI

Command Line

Terminal
npx create-next-app@latest
npm run dev

Configuration

.env.example
NEXT_PUBLIC_API_URL=http://localhost:8000

Pages

pages/index.js
import Head from 'next/head'
import Layout from '../components/layout';
import ToDoList from '../components/todo-list';

export default function Home() {
return (
<div>
<Head>
<title>Full Stack Book To Do</title>
<meta name="description" content="Full Stack Book To Do" />
<link rel="icon" href="/favicon.ico" />
</Head>
<Layout>
<ToDoList />
</Layout>
</div>
)
}

Components

components/layout.js
import styles from '../styles/layout.module.css'

export default function Layout(props) {
return (
<div className={styles.layout}>
<h1 className={styles.title}>To Do</h1>
{props.children}
</div>
)
}
components/todo-list.js
import styles from '../styles/todo-list.module.css'
import { useState, useEffect, useCallback, useRef } from 'react'
import { debounce } from 'lodash'
import ToDo from './todo'

export default function ToDoList() {
const [todos, setTodos] = useState(null)
const [mainInput, setMainInput] = useState('')
const [filter, setFilter] = useState()
const didFetchRef = useRef(false)

useEffect(() => {
if (didFetchRef.current === false) {
didFetchRef.current = true
fetchTodos()
}
}, [])

async function fetchTodos(completed) {
let path = '/todos'
if (completed !== undefined) {
path = `/todos?completed=${completed}`
}
const res = await fetch(process.env.NEXT_PUBLIC_API_URL + path)
const json = await res.json()
setTodos(json)
}

const debouncedUpdateTodo = useCallback(debounce(updateTodo, 500), [])

function handleToDoChange(e, id) {
const target = e.target
const value = target.type === 'checkbox' ? target.checked : target.value
const name = target.name
const copy = [...todos]
const idx = todos.findIndex((todo) => todo.id === id)
const changedToDo = {
...todos[idx],
[name]: value
}
copy[idx] = changedToDo
debouncedUpdateTodo(changedToDo)
setTodos(copy)
}

async function updateTodo(todo) {
const data = {
name: todo.name,
completed: todo.completed
}
const res = await fetch(process.env.NEXT_PUBLIC_API_URL + `/todos/${todo.id}`, {
method: 'PUT',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
}
})
}

async function addToDo(name) {
const res = await fetch(process.env.NEXT_PUBLIC_API_URL + `/todos/`, {
method: 'POST',
body: JSON.stringify({
name: name,
completed: false
}),
headers: {
'Content-Type': 'application/json'
}
})
if (res.ok) {
const json = await res.json();
const copy = [...todos, json]
setTodos(copy)
}
}

async function handleDeleteToDo(id) {
const res = await fetch(process.env.NEXT_PUBLIC_API_URL + `/todos/${id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
}
})
if (res.ok) {
const idx = todos.findIndex((todo) => todo.id === id)
const copy = [...todos]
copy.splice(idx, 1)
setTodos(copy)
}
}

function handleMainInputChange(e) {
setMainInput(e.target.value)
}

function handleKeyDown(e) {
if (e.key === 'Enter') {
if (mainInput.length > 0) {
addToDo(mainInput)
setMainInput('')
}
}
}

function handleFilterChange(value) {
setFilter(value)
fetchTodos(value)
}

return (
<div className={styles.container}>
<div className={styles.mainInputContainer}>
<input className={styles.mainInput} placeholder="What needs to be done?" value={mainInput} onChange={(e) => handleMainInputChange(e)} onKeyDown={handleKeyDown}></input>
</div>
{!todos && (
<div>Loading...</div>
)}
{todos && (
<div>
{todos.map((todo) => {
return (
<ToDo todo={todo} onDelete={handleDeleteToDo} onChange={handleToDoChange} />
)
})}
</div>
)}
<div className={styles.filters}>
<button className={`${styles.filterBtn} ${filter === undefined && styles.filterActive}`} onClick={() => handleFilterChange()}>All</button>
<button className={`${styles.filterBtn} ${filter === false && styles.filterActive}`} onClick={() => handleFilterChange(false)}>Active</button>
<button className={`${styles.filterBtn} ${filter === true && styles.filterActive}`} onClick={() => handleFilterChange(true)}>Completed</button>
</div>
</div>
)
}
components/todo.js
import Image from 'next/image'
import styles from '../styles/todo.module.css'

export default function ToDo(props) {
const { todo, onChange, onDelete } = props;
return (
<div className={styles.toDoRow} key={todo.id}>
<input className={styles.toDoCheckbox} name="completed" type="checkbox" checked={todo.completed} value={todo.completed} onChange={(e) => onChange(e, todo.id)}></input>
<input className={styles.todoInput} autoComplete='off' name="name" type="text" value={todo.name} onChange={(e) => onChange(e, todo.id)}></input>
<button className={styles.deleteBtn} onClick={() => onDelete(todo.id)}><Image src="/material-symbols_delete-outline-sharp.svg" width="24px" height="24px" /></button>
</div>
)
}

Styles

styles/layout.module.css
.layout {
width: 300px;
margin: 20px;
}

.title {
text-align: center;
font-size: 24px;
margin: 10px;
}
styles/todo-list.module.css
.container {
width: 300px;
border: 1px solid black;
}

.mainInputContainer {
width: 100%;
margin: 20px 0;
}

.mainInput {
padding: 5px;
border: 1px solid black;
margin: auto;
display: block;
width: 260px;
height: 40px;
}

.filters {
display: flex;
justify-content: space-between;
padding: 20px;
margin-top: 20px;
border-top: 1px solid black;
}

.filterBtn {
background: none;
border: none;
cursor: pointer;
}

.filterActive {
text-decoration: underline;
}
styles/todo.module.css
.todoInput {
padding: 5px;
border: 1px solid black;
width: 194px;
height: 40px;
margin: 5px;
}

.toDoRow {
display: flex;
flex-direction: row;
align-items: center;
margin: 5px 20px;
}

.deleteBtn {
background: none;
border: 0;
cursor: pointer;
}