First of all to start the project create a folder and open in your code editor.
Run this command
npx create-next-app@latest
What is your project named? my-app
Would you like to use TypeScript? No / Yes
Would you like to use ESLint? No / Yes
Would you like to use Tailwind CSS? No / Yes
Would you like to use `src/` directory? No / Yes
Would you like to use App Router? (recommended) No / Yes
Would you like to customize the default import alias (@/*)? No / Yes
What import alias would you like configured? @/*
TypeScript: Yes
ESLint: Yes
Tailwind: CSS: Yes
Src directory: No
App Router: Yes
import alias: No
You can see the porject on githbub
The libraries we will use in this project
We will use zustand and react-hot-toast in this project.
To install clsx runthis code in your terminal
npm install zustand
To install react-hot toast runthis code in your terminal
npm install react-hot-toast
Now we can add shadcn-ui to our TodoApp project
npx shadcn-ui@latest init
We will use form, input, dialog and button components form shadcn-ui.
To install these components run this command
npx shadcn-ui@latest add button input form dialog
Your Components folder will look like that after install shadcn-ui components
İmplement ToastProvider in layout.tsx
To do that create a folder in root directory name providers.And create a new file name toast-provider.tsx in this folder.
posviders/toast-provider.tsx
"use client"
import { Toaster } from "react-hot-toast"
export const ToastProvider = () => {
return <Toaster />
}
Add ToastProvider component in to our layout.tsx
app/layout.tsx
import type { Metadata } from "next"
import { Inter } from "next/font/google"
import "./globals.css"
import { ToastProvider } from "@/providers/toast.provider"
const inter = Inter({ subsets: ["latin"] })
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html lang="en">
<body className={inter.className}>
<ToastProvider />
{children}
</body>
</html>
)
}
Create types.ts file in our root directory.
types.ts
export interface Todo {
id: number
title: string
description: string
done: boolean
}
We will use Todo types whereever we need it.
Now create a Modal component inside components/ui folder name modal.tsx
components/ui/modal.tsx
copy and paste the code inside of your modal.tsx
"use client"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
interface ModalProps {
title: string
description?: string
isOpen: boolean
onClose: () => void
children?: React.ReactNode
}
const Modal: React.FC<ModalProps> = ({
title,
description,
isOpen,
onClose,
children,
}) => {
const onChange = (open: boolean) => {
if (!open) {
onClose()
}
}
return (
<Dialog open={isOpen} onOpenChange={onChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div>{children}</div>
</DialogContent>
</Dialog>
)
}
export { Modal }
Your Components folder will look like that after creating modal.tsx
We will keep our todo, todos and todo modal states with zustand. To do that first create a hook folder inside your root directory.
Crete use-todo-modal.tsx in hooks folder.
hooks/use-todo.modal.tsx
import { create } from "zustand"
interface useTodoModalProps {
isOpen: boolean
onOpen: () => void
onClose: () => void
}
export const useTodoModal = create<useTodoModalProps>((set) => ({
isOpen: false,
onOpen: () => set({ isOpen: true }),
onClose: () => set({ isOpen: false }),
}))
We will use todo modal component for adding and updating todos. And keep the states of todo modal components globally and use them whereever you need it without props drilling.
Now Create other hooks that we need
Create an another file inside of your hooks folder name use-todos.tsx. useTodos hooks will keep our todos array and update our todos aray with new todos.
hooks/use-todos.tsx
import { create } from "zustand"
import { Todo } from "@/types"
interface TodoStore {
todos: Todo[]
setTodos: (todos: Todo[]) => void
}
export const useTodos = create<TodoStore>((set) => ({
todos: [],
setTodos: (todos) => set({ todos }),
}))
Create useSelectedTodo Hook
And finally we need another hook for the application to understand which todo is selected. To do that create use-selected-todo.tsx inside of your hooks folder.
hooks/use-selected-todo.tsx
import { create } from "zustand"
import { Todo } from "@/types"
interface TodoStore {
todos: Todo[]
setTodos: (todos: Todo[]) => void
}
export const useTodos = create<TodoStore>((set) => ({
todos: [],
setTodos: (todos) => set({ todos }),
}))
Now your hooks folder will look like that
Lets Create Add Todo Form
Create a file with name add-todo-form.tsx inside of your components folder.
components/add-todo-form.tsx
"use client"
import * as z from "zod"
import { zodResolver } from "@hookform/resolvers/zod"
import { useTodoModal } from "@/hooks/use-todo-modal"
import { useTodos } from "@/hooks/use-todos"
import { Todo } from "@/types"
import { Modal } from "@/components/ui/modal"
import { useForm } from "react-hook-form"
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from "@/components/ui/form"
import { Label } from "@/components/ui/label"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { useSelectedTodo } from "@/hooks/use-selected-todo"
import { useEffect } from "react"
import toast from "react-hot-toast"
let nextId = 0
const formSchema = z.object({
title: z.string().min(1, "Title is Required"),
description: z.string().min(1, "Description is Required"),
})
interface AddTodoFormProps {
initialData: Todo | null
}
const AddTodoForm: React.FC<AddTodoFormProps> = ({ initialData }) => {
const todoModal = useTodoModal()
const setTodos = useTodos((state) => state.setTodos)
const todos = useTodos((state) => state.todos)
const setTodo = useSelectedTodo((state) => state.setTodo)
const selectedTodo = useSelectedTodo((state) => state.todo)
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
title: initialData?.title || "",
description: initialData?.description || "",
},
})
const { isSubmitting, isValid } = form.formState
const title = initialData ? "Update Todo" : "Create Todo"
const description = initialData
? "update todo with form"
: "Create todo with form"
const toastMessage = initialData ? "Todo Updated" : "Todo Created"
const action = initialData ? "Update" : "Create"
const onSubmit = async (values: z.infer<typeof formSchema>) => {
if (initialData) {
const updatedTodos = todos.map((todo) =>
todo.id === initialData.id ? { ...todo, ...values } : todo
)
setTodos(updatedTodos)
todoModal.onClose()
setTodo(null)
} else {
const newTodo = {
...values,
id: nextId++,
done: false,
}
setTodos([...todos, newTodo])
form.reset()
todoModal.onClose()
}
toast.success(toastMessage)
}
useEffect(() => {
if (selectedTodo === null) {
form.setValue("title", "")
form.setValue("description", "")
} else if (initialData !== null) {
form.setValue("title", initialData?.title)
form.setValue("description", initialData?.description)
}
}, [selectedTodo, form, initialData])
return (
<Modal
isOpen={todoModal.isOpen}
title={title}
description={description}
onClose={todoModal.onClose}
>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<Label>Title</Label>
<FormControl>
<Input placeholder="Todo Title" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<Label>Description</Label>
<FormControl>
<Input placeholder="Todo Description" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="w-full justify-end flex gap-x-2">
<Button
type="button"
variant="outline"
disabled={isSubmitting}
onClick={todoModal.onClose}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting || !isValid}>
{action}
</Button>
</div>
</form>
</Form>
</Modal>
)
}
export { AddTodoForm }
Create TodoApp Component
components/todo-app.tsx
"use client"
import { AddTodoForm } from "./add-todo-form"
import { TodoList } from "./todo-list"
import { useSelectedTodo } from "@/hooks/use-selected-todo"
export const TodoApp = () => {
const selectedTodo = useSelectedTodo((state) => state.todo)
return (
<div className="space-y-4">
<AddTodoForm initialData={selectedTodo} />
<TodoList />
</div>
)
}
Create TodoList Component
components/todo-list.tsx
import { useTodos } from "@/hooks/use-todos"
import { TodoItem } from "./todo-item"
export const TodoList = () => {
const todos = useTodos((state) => state.todos)
return (
<div className="flex flex-col gap-4 mt-10 pt-10">
{todos.map((todo) => (
<TodoItem key={todo.id} todo={todo} />
))}
</div>
)
}
Create AlertModal component
Before Cretaing TodoItem component create AlertModal component. We will show this alert modal to the user before deleting todo item. create a file inside your components folder name alert-modal.tsx
components/alert-modal.tsx
import { Button } from "./ui/button"
import { Modal } from "./ui/modal"
interface AlertModalProps {
disabled: boolean
open: boolean
onClose: () => void
onConfirm: () => void
}
const AlertModal: React.FC<AlertModalProps> = ({
disabled,
open,
onClose,
onConfirm,
}) => {
return (
<Modal
title="Are You Sure to delete"
description="This action can not be undone!"
isOpen={open}
onClose={onClose}
>
<div className="flex items-center justify-end gap-x-2">
<Button onClick={onClose} disabled={disabled} variant="outline">
Cancel
</Button>
<Button onClick={onConfirm} disabled={disabled} variant="destructive">
Delete
</Button>
</div>
</Modal>
)
}
export { AlertModal }
Create TodoItem Component
components/todo-item.tsx
"use client"
import { Todo } from "@/types"
import { Button } from "./ui/button"
import { Check, Pencil, Trash } from "lucide-react"
import { useState } from "react"
import { useTodos } from "@/hooks/use-todos"
import { AlertModal } from "./alert-modal"
import { useSelectedTodo } from "@/hooks/use-selected-todo"
import { useTodoModal } from "@/hooks/use-todo-modal"
import { cn } from "@/lib/utils"
import toast from "react-hot-toast"
interface TodoItemProps {
todo: Todo
}
export const TodoItem: React.FC<TodoItemProps> = ({ todo }) => {
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
const todos = useTodos((state) => state.todos)
const setTodos = useTodos((state) => state.setTodos)
const [done, setDone] = useState(false)
const setTodo = useSelectedTodo((state) => state.setTodo)
const todoModal = useTodoModal()
const onDeleteTodo = async (todoId: number) => {
try {
setLoading(true)
setTodos(todos.filter((todo) => todo.id !== todoId))
toast.success("Todo Deleted")
} catch (error) {
toast.error("Something Went Wrong")
} finally {
setLoading(false)
setOpen(false)
}
}
const handleChange = (newTodo: Todo) => {
setDone((prev) => !prev)
if (todo.done) {
toast.success("Todo undone")
} else {
toast.success("Todo done")
}
setTodos(
todos.map((todo) =>
todo.id === newTodo.id ? { ...todo, done: !todo.done } : todo
)
)
}
return (
<>
<AlertModal
open={open}
onClose={() => setOpen(false)}
disabled={loading}
onConfirm={() => onDeleteTodo(todo.id)}
/>
<div
className={cn(
"flex gap-2 bg-white p-4 rounded-md border",
todo.done && "bg-teal-50"
)}
>
<Button
onClick={() => handleChange(todo)}
size="icon"
variant="outline"
className={cn(
todo.done &&
"bg-teal-500 text-white hover:bg-teal-600 hover:text-white"
)}
>
<Check className="w-4 h-4" />
</Button>
<div>
<h4
className={cn(
"text-xl font-semibold tracking-tight",
todo.done && "line-through"
)}
>
{todo.title}
</h4>
<p className=" font-semibold tracking-tight">{todo.description}</p>
</div>
<div className="flex flex-col gap-2 ml-auto">
<Button
onClick={() => setOpen(true)}
variant="destructive"
size="icon"
>
<Trash className="w-4 h-4" />
</Button>
<Button
onClick={() => {
setTodo(todo)
todoModal.onOpen()
}}
variant="outline"
size="icon"
>
<Pencil className="w-4 h-4" />
</Button>
</div>
</div>
</>
)
}
Add Todo App inside your Home Page
app/page.tsx
import { TodoApp } from "@/components/todo-app"
export default function Home() {
return (
<div className="max-w-[600px] mx-auto my-6 py-6 px-4">
<TodoApp />
</div>
)
}
Now start your application
npm run dev
Crate TodoClient Component
You need to add a button to open todo modal component.
To do that create a new file inside your components folder name client.tsx.
components/client.tsx
"use client"
import { useTodoModal } from "@/hooks/use-todo-modal"
import { Button } from "./ui/button"
export const TodoClient = () => {
const todoModal = useTodoModal()
return (
<div className="flex items-center justify-between">
<h1 className="text-3xl font-semibold tracking-tighter">
Todo App Next.js 14
</h1>
<Button
onClick={() => {
todoModal.onOpen()
}}
>
Add Todo
</Button>
</div>
)
}
Add TodoClient Component inside of Todoapp Component like that
Your TodoApp component will look like that
component/todo-app.tsx
"use client"
import { AddTodoForm } from "./add-todo-form"
import { TodoClient } from "./client"
import { TodoList } from "./todo-list"
import { useSelectedTodo } from "@/hooks/use-selected-todo"
export const TodoApp = () => {
const selectedTodo = useSelectedTodo((state) => state.todo)
return (
<div className="space-y-4">
<TodoClient />
<AddTodoForm initialData={selectedTodo} />
<TodoList />
</div>
)
}
Great Job! Now you create a Todo App with Next.js 14 , shadcn-ui and using zustand which is a state anagement library. if you have any problem with codes you can check my github respository.
You can see the porject on githbub