Build a complete Task Management App with React, Next.js, and TypeScript
Frontend development is the practice of building the user interface (UI) and user experience (UX) of web applications. It's what users see and interact with in their browsers.
Structure and content - the skeleton of your webpage
Styling and layout - the appearance and design
Interactivity and logic - the behavior and functionality
Today's frontend development has evolved beyond vanilla HTML, CSS, and JavaScript. We use powerful frameworks and tools that make development faster, more maintainable, and scalable.
A JavaScript library for building user interfaces using reusable components. Think of components as LEGO blocks - you build complex UIs by combining smaller, reusable pieces.
A React framework that adds powerful features like server-side rendering, routing, and optimization out of the box. It's like React with superpowers.
JavaScript with type safety. It catches errors before you run your code and makes your code more predictable and easier to maintain.
A utility-first CSS framework that lets you style elements directly in your HTML/JSX using pre-defined classes. No need to write custom CSS files.
A fully functional Task Management Application. This project will teach you the fundamentals of React, state management, component composition, and TypeScript - all essential skills for modern frontend development.
Before we write any code, we need to set up our development environment. Next.js provides a command-line tool that creates a new project with all the necessary configuration files and folder structure.
Think of this like setting up a kitchen before cooking - you need the right tools, ingredients, and workspace organized before you start.
Open your terminal and run the following command. This will create a new Next.js project with TypeScript, Tailwind CSS, and other modern tools pre-configured.
npx create-next-app@latest task-manager
# When prompted, choose these options:
ā Would you like to use TypeScript? Yes
ā Would you like to use ESLint? Yes
ā Would you like to use Tailwind CSS? Yes
ā Would you like to use App Router? Yes
ā Would you like to customize the default import alias? No
cd task-manager
npm run devš” What's happening? This command downloads and sets up a complete Next.js project with TypeScript for type safety, Tailwind for styling, and the App Router for modern routing.
After setup, your project will have this structure. Let's understand what each folder does:
task-manager/
āāā app/ # Your application pages and layouts
ā āāā layout.tsx # Root layout (wraps all pages)
ā āāā page.tsx # Home page (main entry point)
ā āāā globals.css # Global styles
āāā components/ # Reusable UI components (we'll create this)
āāā types/ # TypeScript type definitions (we'll create this)
āāā public/ # Static files (images, fonts, etc.)
āāā node_modules/ # Installed dependencies
āāā package.json # Project configuration and dependenciesapp/ - Contains your pages. Each folder becomes a route in your app.
components/ - Reusable pieces of UI that you can use across multiple pages.
types/ - TypeScript definitions that describe the shape of your data.
First, let's create the folders we'll need and define our Task type. A "type" in TypeScript is like a blueprint that describes what properties an object should have.
Create the folders:
mkdir components
mkdir typesCreate types/task.ts:
export interface Task {
id: string; // Unique identifier for each task
title: string; // Task name/title
description: string; // Optional details about the task
completed: boolean; // Is the task done? true or false
createdAt: Date; // When was the task created
}TypeScript helps catch errors before you run your code. If you try to use a Task object without an 'id' property, TypeScript will warn you immediately. This saves hours of debugging!
In React, everything is a component. A component is a reusable piece of UI that can have its own logic and appearance. Think of components like custom HTML tags that you create.
For example, instead of writing the same form HTML everywhere, you create a TaskForm component and reuse it wherever you need a task form.
"State" is data that can change over time. In our form, the text you type is state - it changes as you type. React's useState hook lets us store and update this changing data.
Example: When you type "Buy groceries" in the input field, useState stores that text. When you type more, useState updates it. When you submit, we use that stored text to create a new task.
Create a new file components/TaskForm.tsx and add this code:
'use client';
import { useState } from 'react';
import { Task } from '@/types/task';
interface TaskFormProps {
onAddTask: (task: Omit<Task, 'id' | 'createdAt'>) => void;
}
export default function TaskForm({ onAddTask }: TaskFormProps) {
// State to store the task title as user types
const [title, setTitle] = useState('');
// State to store the task description as user types
const [description, setDescription] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); // Prevent page reload on form submit
// Don't create empty tasks
if (!title.trim()) return;
// Call the parent component's function to add the task
onAddTask({
title: title.trim(),
description: description.trim(),
completed: false,
});
// Clear the form after submission
setTitle('');
setDescription('');
};
return (
<form onSubmit={handleSubmit} className="mb-8 space-y-4">
<div>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Task title..."
className="w-full px-4 py-2 border rounded-lg
focus:outline-none focus:ring-2 focus:ring-blue-500
dark:bg-gray-800 dark:border-gray-700"
/>
</div>
<div>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Task description (optional)..."
rows={3}
className="w-full px-4 py-2 border rounded-lg
focus:outline-none focus:ring-2 focus:ring-blue-500
dark:bg-gray-800 dark:border-gray-700"
/>
</div>
<button
type="submit"
className="w-full bg-blue-500 hover:bg-blue-600
text-white px-4 py-2 rounded-lg
transition-colors font-medium"
>
Add Task
</button>
</form>
);
}'use client'
Tells Next.js this component runs in the browser (client-side) because it uses interactive features like useState.
useState('')
Creates a state variable initialized with an empty string. Returns [value, setValue] - the current value and a function to update it.
onChange handler
Runs every time the user types. Updates the state with the new input value.
e.preventDefault()
Stops the form from reloading the page (default browser behavior).
Tailwind classes
Utility classes for styling: w-full (full width), px-4 (padding horizontal), rounded-lg (rounded corners), etc.
Component composition is building complex UIs by combining smaller components. Our TaskItem component will display a single task, and we'll use multiple TaskItem components to show a list.
This is like building with LEGO - each TaskItem is a brick, and we stack them to create the full list.
Create components/TaskItem.tsx:
'use client';
import { Task } from '@/types/task';
interface TaskItemProps {
task: Task;
onToggle: (id: string) => void;
onDelete: (id: string) => void;
}
export default function TaskItem({ task, onToggle, onDelete }: TaskItemProps) {
return (
<div className="bg-white dark:bg-gray-800 border rounded-lg p-4
hover:shadow-md transition-shadow">
<div className="flex items-start gap-3">
{/* Checkbox to mark task complete/incomplete */}
<input
type="checkbox"
checked={task.completed}
onChange={() => onToggle(task.id)}
className="mt-1 h-5 w-5 rounded border-gray-300
text-blue-500 focus:ring-blue-500 cursor-pointer"
/>
<div className="flex-1">
{/* Task title with strikethrough if completed */}
<h3 className={`text-lg font-semibold transition-all ${
task.completed
? 'line-through text-gray-400'
: 'text-gray-900 dark:text-gray-100'
}`}>
{task.title}
</h3>
{/* Show description if it exists */}
{task.description && (
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
{task.description}
</p>
)}
{/* Show when task was created */}
<p className="text-xs text-gray-400 mt-2">
Created: {new Date(task.createdAt).toLocaleDateString()}
</p>
</div>
{/* Delete button */}
<button
onClick={() => onDelete(task.id)}
className="text-red-500 hover:text-red-700 px-3 py-1 rounded
hover:bg-red-50 dark:hover:bg-red-950
transition-colors text-sm font-medium"
>
Delete
</button>
</div>
</div>
);
}Props Destructuring
We receive task, onToggle, and onDelete from the parent component. This is how components communicate.
Conditional Rendering
The {task.description && <p>...}</p>} only shows the description if it exists (not empty).
Conditional Styling
The title gets a line-through style when task.completed is true. This is dynamic styling based on state.
Event Handlers
onClick and onChange call functions passed from the parent, allowing the parent to update the task list.
The main app component manages the "source of truth" - the complete list of tasks. All other components receive data from here and send updates back up. This is called "lifting state up".
Think of it like this: The main app is the manager who keeps the master list. TaskForm tells the manager "add this task", TaskItem tells the manager "mark this complete" or "delete this". The manager updates the list and tells everyone about the changes.
Replace the content of app/page.tsx with:
'use client';
import { useState } from 'react';
import TaskForm from '@/components/TaskForm';
import TaskItem from '@/components/TaskItem';
import { Task } from '@/types/task';
export default function Home() {
// Main state: array of all tasks
const [tasks, setTasks] = useState<Task[]>([]);
// Filter state: which tasks to show
const [filter, setFilter] = useState<'all' | 'active' | 'completed'>('all');
// Function to add a new task
const addTask = (taskData: Omit<Task, 'id' | 'createdAt'>) => {
const newTask: Task = {
...taskData,
id: crypto.randomUUID(), // Generate unique ID
createdAt: new Date(), // Set creation time
};
setTasks([newTask, ...tasks]); // Add to beginning of array
};
// Function to toggle task completion
const toggleTask = (id: string) => {
setTasks(tasks.map(task =>
task.id === id
? { ...task, completed: !task.completed } // Toggle this task
: task // Keep other tasks unchanged
));
};
// Function to delete a task
const deleteTask = (id: string) => {
setTasks(tasks.filter(task => task.id !== id));
};
// Filter tasks based on current filter
const filteredTasks = tasks.filter(task => {
if (filter === 'active') return !task.completed;
if (filter === 'completed') return task.completed;
return true; // 'all' shows everything
});
return (
<main className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
<div className="container mx-auto px-4 max-w-3xl">
<h1 className="text-4xl font-bold mb-8 text-center
bg-gradient-to-r from-blue-500 to-cyan-500
bg-clip-text text-transparent">
Task Manager
</h1>
{/* Task creation form */}
<TaskForm onAddTask={addTask} />
{/* Filter buttons */}
<div className="flex gap-2 mb-6 flex-wrap">
<button
onClick={() => setFilter('all')}
className={`px-4 py-2 rounded-lg transition-colors font-medium ${
filter === 'all'
? 'bg-blue-500 text-white'
: 'bg-white dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
All ({tasks.length})
</button>
<button
onClick={() => setFilter('active')}
className={`px-4 py-2 rounded-lg transition-colors font-medium ${
filter === 'active'
? 'bg-blue-500 text-white'
: 'bg-white dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
Active ({tasks.filter(t => !t.completed).length})
</button>
<button
onClick={() => setFilter('completed')}
className={`px-4 py-2 rounded-lg transition-colors font-medium ${
filter === 'completed'
? 'bg-blue-500 text-white'
: 'bg-white dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
Completed ({tasks.filter(t => t.completed).length})
</button>
</div>
{/* Task list */}
<div className="space-y-3">
{filteredTasks.length === 0 ? (
<div className="text-center py-12 bg-white dark:bg-gray-800
rounded-lg border-2 border-dashed">
<p className="text-gray-500 text-lg">
{filter === 'all'
? 'No tasks yet. Add one above!'
: `No ${filter} tasks.`}
</p>
</div>
) : (
filteredTasks.map(task => (
<TaskItem
key={task.id}
task={task}
onToggle={toggleTask}
onDelete={deleteTask}
/>
))
)}
</div>
</div>
</main>
);
}Array Methods
map() - Transform each item in an array. We use it to update a specific task.
filter() - Keep only items that match a condition. We use it to show/hide tasks and delete tasks.
Spread Operator (...)
Creates a copy of an object/array. {...task, completed: !task.completed} copies the task and changes only the completed property.
Conditional Rendering
Show different UI based on conditions. If no tasks, show a message. Otherwise, show the list.
Key Prop
React needs a unique "key" for each item in a list to track changes efficiently. We use task.id.
You've built a complete, functional task management app! Run npm run devand open http://localhost:3000 to see it in action.