Introduction
In this comprehensive guide, I’ll show you how to build a professional Task Manager application in React that connects to a real public API. We’ll use JSONPlaceholder – a free fake API perfect for learning and prototyping. Our app will feature a clean modern design, smooth animations, and robust state management using React Hooks.
What You’ll Learn:
- How to consume real REST APIs in React
- Building clean, responsive UIs with modern CSS
- Implementing complex state management with React Hooks
- Creating reusable, animated components
- Handling API errors gracefully
- Adding search and filtering capabilities
- Implementing responsive design patterns
Project Overview
We’re building “TaskFlow” – a professional task management app that:
- Fetches tasks from JSONPlaceholder API
- Allows creating, updating, and deleting tasks (simulated)
- Features a clean modern design with Tailwind CSS
- Includes smooth animations and transitions
- Works with real API endpoints
- Supports search and filtering
- Has responsive design for all screen sizes
Project Structure
src/
├── index.js
├── App.js
├── index.css
├── components/
│ ├── TaskCard.js
│ ├── LoadingShimmer.js
│ ├── EmptyState.js
│ ├── ErrorWidget.js
│ ├── Header.js
│ ├── TaskStats.js
│ ├── AddTaskModal.js
│ └── FilterModal.js
├── hooks/
│ ├── useTasks.js
│ ├── useDebounce.js
│ └── useLocalStorage.js
├── services/
│ ├── api.js
│ └── apiService.js
├── utils/
│ ├── constants.js
│ ├── helpers.js
│ └── formatters.js
└── styles/
└── animations.css
Setup and Dependencies
{
"name": "taskflow-react",
"version": "1.0.0",
"private": true,
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"axios": "^1.6.0",
"react-icons": "^4.11.0",
"react-hot-toast": "^2.4.1",
"framer-motion": "^10.16.5",
"date-fns": "^2.30.0",
"tailwindcss": "^3.3.0"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"devDependencies": {
"react-scripts": "5.0.1",
"autoprefixer": "^10.4.15",
"postcss": "^8.4.31"
}
}
// tailwind.config.js
module.exports = {
content: [
"./src/**/*.{js,jsx,ts,tsx}",
],
theme: {
extend: {
colors: {
primary: {
50: '#f5f3ff',
100: '#ede9fe',
200: '#ddd6fe',
300: '#c4b5fd',
400: '#a78bfa',
500: '#8b5cf6',
600: '#7c3aed',
700: '#6d28d9',
800: '#5b21b6',
900: '#4c1d95',
},
success: {
50: '#f0fdf4',
500: '#10b981',
600: '#059669',
},
warning: {
50: '#fffbeb',
500: '#f59e0b',
600: '#d97706',
},
danger: {
50: '#fef2f2',
500: '#ef4444',
600: '#dc2626',
}
},
animation: {
'slide-in': 'slideIn 0.3s ease-out',
'fade-in': 'fadeIn 0.5s ease-out',
'shimmer': 'shimmer 2s infinite linear',
},
keyframes: {
slideIn: {
'0%': { transform: 'translateY(-10px)', opacity: 0 },
'100%': { transform: 'translateY(0)', opacity: 1 },
},
fadeIn: {
'0%': { opacity: 0 },
'100%': { opacity: 1 },
},
shimmer: {
'0%': { backgroundPosition: '-200px 0' },
'100%': { backgroundPosition: '200px 0' },
}
}
},
},
plugins: [],
}
Step 1: API Service Layer
// src/services/api.js
import axios from 'axios';
const API_BASE_URL = 'https://jsonplaceholder.typicode.com';
const api = axios.create({
baseURL: API_BASE_URL,
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
});
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response) {
switch (error.response.status) {
case 401:
break;
case 404:
break;
case 429:
break;
default:
break;
}
}
return Promise.reject(error);
}
);
export default api;
// src/services/apiService.js
import api from './api';
class ApiService {
async getTodos() {
try {
const response = await api.get('/todos');
return response.data;
} catch (error) {
throw this.handleError(error);
}
}
async getTodo(id) {
try {
const response = await api.get(`/todos/${id}`);
return response.data;
} catch (error) {
throw this.handleError(error);
}
}
async createTodo(todo) {
try {
const response = await api.post('/todos', todo);
return response.data;
} catch (error) {
throw this.handleError(error);
}
}
async updateTodo(id, todo) {
try {
const response = await api.put(`/todos/${id}`, todo);
return response.data;
} catch (error) {
throw this.handleError(error);
}
}
async deleteTodo(id) {
try {
await api.delete(`/todos/${id}`);
return { success: true };
} catch (error) {
throw this.handleError(error);
}
}
handleError(error) {
if (error.response) {
return {
message: error.response.data?.message || 'Request failed',
status: error.response.status,
data: error.response.data,
};
} else if (error.request) {
return {
message: 'Network error. Please check your connection.',
status: 0,
};
} else {
return {
message: error.message,
status: -1,
};
}
}
}
export default new ApiService();
Step 2: Task Model and Utilities
// src/utils/constants.js
export const TASK_PRIORITY = {
LOW: 'low',
MEDIUM: 'medium',
HIGH: 'high',
URGENT: 'urgent',
};
export const TASK_CATEGORIES = [
'Work',
'Personal',
'Shopping',
'Health',
'Finance',
'Education',
];
export const PRIORITY_COLORS = {
[TASK_PRIORITY.LOW]: {
bg: 'bg-success-50',
text: 'text-success-600',
border: 'border-success-200',
},
[TASK_PRIORITY.MEDIUM]: {
bg: 'bg-warning-50',
text: 'text-warning-600',
border: 'border-warning-200',
},
[TASK_PRIORITY.HIGH]: {
bg: 'bg-danger-50',
text: 'text-danger-600',
border: 'border-danger-200',
},
[TASK_PRIORITY.URGENT]: {
bg: 'bg-red-50',
text: 'text-red-700',
border: 'border-red-200',
},
};
// src/utils/helpers.js
import { format, isToday, isTomorrow, isYesterday, differenceInDays } from 'date-fns';
export const formatDueDate = (date) => {
if (!date) return 'No due date';
if (isToday(date)) return 'Today';
if (isTomorrow(date)) return 'Tomorrow';
if (isYesterday(date)) return 'Yesterday';
const diffDays = differenceInDays(date, new Date());
if (diffDays > 0) return `In ${diffDays} days`;
if (diffDays < 0) return `${Math.abs(diffDays)} days ago`;
return format(date, 'MMM d, yyyy');
};
export const generateTaskId = () => {
return `TASK-${Date.now().toString(36)}-${Math.random().toString(36).substr(2, 9)}`;
};
export const filterTasks = (tasks, searchQuery, filters) => {
return tasks.filter(task => {
if (searchQuery && !task.title.toLowerCase().includes(searchQuery.toLowerCase())) {
return false;
}
if (filters.priority && task.priority !== filters.priority) {
return false;
}
if (filters.category && task.category !== filters.category) {
return false;
}
if (filters.status === 'completed' && !task.completed) {
return false;
}
if (filters.status === 'pending' && task.completed) {
return false;
}
return true;
});
};
export const calculateStats = (tasks) => {
const total = tasks.length;
const completed = tasks.filter(task => task.completed).length;
const pending = total - completed;
const completionRate = total > 0 ? Math.round((completed / total) * 100) : 0;
return { total, completed, pending, completionRate };
};
Step 3: React Hooks for State Management
// src/hooks/useTasks.js
import { useState, useEffect, useCallback } from 'react';
import apiService from '../services/apiService';
import { filterTasks, calculateStats } from '../utils/helpers';
import toast from 'react-hot-toast';
const useTasks = () => {
const [tasks, setTasks] = useState([]);
const [filteredTasks, setFilteredTasks] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [stats, setStats] = useState({ total: 0, completed: 0, pending: 0, completionRate: 0 });
const [searchQuery, setSearchQuery] = useState('');
const [filters, setFilters] = useState({
priority: '',
category: '',
status: '',
});
const fetchTasks = useCallback(async () => {
try {
setLoading(true);
setError(null);
const data = await apiService.getTodos();
const transformedTasks = data.map(todo => ({
id: todo.id,
title: todo.title,
completed: todo.completed,
userId: todo.userId,
priority: ['low', 'medium', 'high', 'urgent'][todo.id % 4],
category: ['Work', 'Personal', 'Shopping', 'Health', 'Finance'][todo.id % 5],
dueDate: new Date(Date.now() + (todo.id % 30) * 24 * 60 * 60 * 1000),
createdAt: new Date(Date.now() - (todo.id % 365) * 24 * 60 * 60 * 1000),
notes: todo.id % 3 === 0 ? 'Additional notes for this task...' : '',
}));
setTasks(transformedTasks);
} catch (err) {
setError(err.message);
toast.error('Failed to load tasks');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
const filtered = filterTasks(tasks, searchQuery, filters);
setFilteredTasks(filtered);
setStats(calculateStats(filtered));
}, [tasks, searchQuery, filters]);
useEffect(() => {
fetchTasks();
}, [fetchTasks]);
const toggleTaskCompletion = async (taskId) => {
const task = tasks.find(t => t.id === taskId);
if (!task) return;
try {
setTasks(prev => prev.map(t =>
t.id === taskId ? { ...t, completed: !t.completed } : t
));
await apiService.updateTodo(taskId, {
...task,
completed: !task.completed,
});
toast.success(`Task marked as ${!task.completed ? 'completed' : 'pending'}`);
} catch (err) {
setTasks(prev => prev.map(t =>
t.id === taskId ? { ...t, completed: task.completed } : t
));
toast.error('Failed to update task');
}
};
const addTask = async (newTask) => {
try {
const taskToCreate = {
...newTask,
userId: 1,
};
const optimisticTask = {
id: Date.now(),
...taskToCreate,
createdAt: new Date(),
};
setTasks(prev => [optimisticTask, ...prev]);
const response = await apiService.createTodo(taskToCreate);
setTasks(prev => prev.map(t =>
t.id === optimisticTask.id
? { ...t, id: response.id }
: t
));
toast.success('Task created successfully!');
return true;
} catch (err) {
setTasks(prev => prev.filter(t => t.id !== Date.now()));
toast.error('Failed to create task');
return false;
}
};
const deleteTask = async (taskId) => {
try {
const taskToDelete = tasks.find(t => t.id === taskId);
setTasks(prev => prev.filter(t => t.id !== taskId));
await apiService.deleteTodo(taskId);
toast.success('Task deleted successfully');
} catch (err) {
setTasks(prev => [...prev, taskToDelete]);
toast.error('Failed to delete task');
}
};
const updateFilters = (newFilters) => {
setFilters(prev => ({ ...prev, ...newFilters }));
};
const clearFilters = () => {
setSearchQuery('');
setFilters({
priority: '',
category: '',
status: '',
});
};
const refreshTasks = () => {
fetchTasks();
toast.success('Tasks refreshed');
};
return {
tasks: filteredTasks,
allTasks: tasks,
loading,
error,
stats,
searchQuery,
setSearchQuery,
filters,
updateFilters,
clearFilters,
toggleTaskCompletion,
addTask,
deleteTask,
refreshTasks,
fetchTasks,
};
};
export default useTasks;
// src/hooks/useDebounce.js
import { useState, useEffect } from 'react';
const useDebounce = (value, delay) => {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
};
export default useDebounce;
Step 4: UI Components
// src/components/TaskCard.js
import React from 'react';
import { motion } from 'framer-motion';
import {
FiCheckCircle,
FiCircle,
FiCalendar,
FiTrash2,
FiFlag,
FiTag
} from 'react-icons/fi';
import { formatDueDate } from '../utils/helpers';
import { PRIORITY_COLORS } from '../utils/constants';
const TaskCard = ({
task,
onToggle,
onDelete,
onEdit
}) => {
const priorityConfig = PRIORITY_COLORS[task.priority] || PRIORITY_COLORS.medium;
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
className="bg-white rounded-xl shadow-lg hover:shadow-xl transition-shadow duration-300 overflow-hidden border border-gray-100"
>
<div className="p-5">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-2">
<span className={`px-3 py-1 rounded-full text-xs font-semibold ${priorityConfig.bg} ${priorityConfig.text} ${priorityConfig.border} border flex items-center gap-1`}>
<FiFlag size={12} />
{task.priority.charAt(0).toUpperCase() + task.priority.slice(1)}
</span>
{task.category && (
<span className="px-3 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-600 flex items-center gap-1">
<FiTag size={12} />
{task.category}
</span>
)}
</div>
<button
onClick={onEdit}
className="text-gray-400 hover:text-gray-600 transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
</svg>
</button>
</div>
<h3 className={`text-lg font-semibold mb-3 ${task.completed ? 'line-through text-gray-400' : 'text-gray-800'}`}>
{task.title}
</h3>
<div className="flex items-center justify-between text-sm text-gray-500 mb-4">
<div className="flex items-center gap-4">
<div className="flex items-center gap-1">
<FiCalendar className="w-4 h-4" />
<span>{formatDueDate(task.dueDate)}</span>
</div>
<div className="flex items-center gap-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
<span className="font-mono text-xs">ID: {task.id}</span>
</div>
</div>
</div>
<div className="flex items-center justify-between pt-4 border-t border-gray-100">
<button
onClick={onToggle}
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors ${
task.completed
? 'bg-success-50 text-success-600 hover:bg-success-100'
: 'bg-primary-50 text-primary-600 hover:bg-primary-100'
}`}
>
{task.completed ? (
<FiCheckCircle className="w-5 h-5" />
) : (
<FiCircle className="w-5 h-5" />
)}
<span className="text-sm font-medium">
{task.completed ? 'Completed' : 'Mark Complete'}
</span>
</button>
<button
onClick={onDelete}
className="p-2 text-gray-400 hover:text-red-500 transition-colors rounded-lg hover:bg-red-50"
>
<FiTrash2 className="w-5 h-5" />
</button>
</div>
</div>
{task.dueDate && (
<div className="h-1 bg-gradient-to-r from-blue-500 via-purple-500 to-pink-500 opacity-50" />
)}
</motion.div>
);
};
export default TaskCard;
// src/components/LoadingShimmer.js
import React from 'react';
const LoadingShimmer = () => {
return (
<div className="space-y-4 animate-pulse">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="bg-white rounded-xl shadow p-5">
<div className="flex justify-between mb-4">
<div className="flex gap-2">
<div className="w-20 h-6 bg-gray-200 rounded-full"></div>
<div className="w-16 h-6 bg-gray-200 rounded-full"></div>
</div>
<div className="w-6 h-6 bg-gray-200 rounded"></div>
</div>
<div className="space-y-2 mb-4">
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
<div className="h-4 bg-gray-200 rounded w-1/2"></div>
</div>
<div className="flex justify-between items-center pt-4 border-t border-gray-100">
<div className="flex gap-4">
<div className="flex items-center gap-1">
<div className="w-4 h-4 bg-gray-200 rounded"></div>
<div className="w-16 h-4 bg-gray-200 rounded"></div>
</div>
<div className="flex items-center gap-1">
<div className="w-4 h-4 bg-gray-200 rounded"></div>
<div className="w-12 h-4 bg-gray-200 rounded"></div>
</div>
</div>
<div className="w-20 h-8 bg-gray-200 rounded-lg"></div>
</div>
</div>
))}
</div>
);
};
export const StatLoadingShimmer = () => {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 animate-pulse">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="bg-white rounded-xl shadow p-5">
<div className="flex items-center justify-between mb-4">
<div className="w-10 h-10 bg-gray-200 rounded-lg"></div>
</div>
<div className="h-8 bg-gray-200 rounded w-1/2 mb-2"></div>
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
</div>
))}
</div>
);
};
export default LoadingShimmer;
// src/components/EmptyState.js
import React from 'react';
import { motion } from 'framer-motion';
import { FiInbox, FiSearch, FiFilter } from 'react-icons/fi';
const EmptyState = ({
type = 'no-tasks',
searchQuery = '',
onAction
}) => {
const getContent = () => {
switch (type) {
case 'search':
return {
icon: <FiSearch className="w-16 h-16 text-gray-300" />,
title: 'No tasks found',
description: `No tasks match "${searchQuery}". Try a different search term.`,
actionText: 'Clear Search',
};
case 'filter':
return {
icon: <FiFilter className="w-16 h-16 text-gray-300" />,
title: 'No matching tasks',
description: 'No tasks match your current filters. Try adjusting them.',
actionText: 'Clear Filters',
};
default:
return {
icon: <FiInbox className="w-16 h-16 text-gray-300" />,
title: 'No tasks yet',
description: 'Start organizing your work by creating your first task!',
actionText: 'Create Task',
};
}
};
const content = getContent();
return (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center justify-center py-16 px-4 text-center"
>
<div className="mb-6">
{content.icon}
</div>
<h3 className="text-2xl font-semibold text-gray-800 mb-2">
{content.title}
</h3>
<p className="text-gray-500 max-w-md mb-8">
{content.description}
</p>
{onAction && (
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={onAction}
className="px-6 py-3 bg-primary-600 text-white font-medium rounded-lg hover:bg-primary-700 transition-colors shadow-lg hover:shadow-xl"
>
{content.actionText}
</motion.button>
)}
</motion.div>
);
};
export default EmptyState;
// src/components/ErrorWidget.js
import React from 'react';
import { motion } from 'framer-motion';
import { FiAlertTriangle, FiRefreshCw } from 'react-icons/fi';
const ErrorWidget = ({ error, onRetry }) => {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex flex-col items-center justify-center py-16 px-4 text-center"
>
<div className="w-20 h-20 bg-red-50 rounded-full flex items-center justify-center mb-6">
<FiAlertTriangle className="w-10 h-10 text-red-500" />
</div>
<h3 className="text-2xl font-semibold text-gray-800 mb-2">
Something went wrong
</h3>
<p className="text-gray-500 max-w-md mb-6">
{error || 'An unexpected error occurred while loading tasks.'}
</p>
{onRetry && (
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={onRetry}
className="flex items-center gap-2 px-6 py-3 bg-red-600 text-white font-medium rounded-lg hover:bg-red-700 transition-colors"
>
<FiRefreshCw className="w-5 h-5" />
Try Again
</motion.button>
)}
</motion.div>
);
};
export default ErrorWidget;
// src/components/Header.js
import React, { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
FiSearch,
FiFilter,
FiX,
FiBell,
FiUser
} from 'react-icons/fi';
const Header = ({
onSearchChange,
onFilterClick,
searchQuery,
stats
}) => {
const [showSearch, setShowSearch] = useState(false);
return (
<header className="sticky top-0 z-50 bg-white/80 backdrop-blur-md border-b border-gray-100">
<div className="container mx-auto px-4 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-gradient-to-br from-primary-500 to-purple-600 rounded-xl flex items-center justify-center shadow-lg">
<span className="text-white font-bold text-xl">T</span>
</div>
<div>
<h1 className="text-2xl font-bold text-gray-800">TaskFlow</h1>
<p className="text-sm text-gray-500">
{stats.total} tasks • {stats.completed} completed
</p>
</div>
</div>
<div className="flex items-center gap-2">
<AnimatePresence>
{showSearch ? (
<motion.div
initial={{ width: 0, opacity: 0 }}
animate={{ width: 'auto', opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
className="flex items-center"
>
<div className="relative">
<FiSearch className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
<input
type="text"
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="Search tasks..."
className="pl-10 pr-10 py-2 w-64 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
autoFocus
/>
<button
onClick={() => {
setShowSearch(false);
onSearchChange('');
}}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
<FiX className="w-5 h-5" />
</button>
</div>
</motion.div>
) : (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
className="flex items-center gap-2"
>
<button
onClick={() => setShowSearch(true)}
className="p-2 text-gray-600 hover:text-primary-600 transition-colors rounded-lg hover:bg-gray-100"
>
<FiSearch className="w-5 h-5" />
</button>
<button
onClick={onFilterClick}
className="p-2 text-gray-600 hover:text-primary-600 transition-colors rounded-lg hover:bg-gray-100"
>
<FiFilter className="w-5 h-5" />
</button>
<button className="p-2 text-gray-600 hover:text-primary-600 transition-colors rounded-lg hover:bg-gray-100 relative">
<FiBell className="w-5 h-5" />
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full"></span>
</button>
<button className="p-2 text-gray-600 hover:text-primary-600 transition-colors rounded-lg hover:bg-gray-100">
<FiUser className="w-5 h-5" />
</button>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</div>
</header>
);
};
export default Header;
// src/components/TaskStats.js
import React from 'react';
import { motion } from 'framer-motion';
import {
FiCheckCircle,
FiClock,
FiTrendingUp,
FiList
} from 'react-icons/fi';
const TaskStats = ({ stats }) => {
const statCards = [
{
title: 'Total Tasks',
value: stats.total,
icon: <FiList className="w-6 h-6" />,
color: 'bg-blue-50 text-blue-600',
borderColor: 'border-blue-100',
},
{
title: 'Completed',
value: stats.completed,
icon: <FiCheckCircle className="w-6 h-6" />,
color: 'bg-green-50 text-green-600',
borderColor: 'border-green-100',
},
{
title: 'Pending',
value: stats.pending,
icon: <FiClock className="w-6 h-6" />,
color: 'bg-orange-50 text-orange-600',
borderColor: 'border-orange-100',
},
{
title: 'Completion Rate',
value: `${stats.completionRate}%`,
icon: <FiTrendingUp className="w-6 h-6" />,
color: 'bg-purple-50 text-purple-600',
borderColor: 'border-purple-100',
},
];
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
{statCards.map((stat, index) => (
<motion.div
key={stat.title}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
className={`bg-white rounded-xl shadow-lg p-5 border ${stat.borderColor} hover:shadow-xl transition-shadow`}
>
<div className="flex items-center justify-between mb-4">
<div className={`p-3 rounded-lg ${stat.color}`}>
{stat.icon}
</div>
</div>
<div className="text-3xl font-bold text-gray-800 mb-1">
{stat.value}
</div>
<div className="text-sm text-gray-500">
{stat.title}
</div>
{stat.title === 'Completion Rate' && (
<div className="mt-4">
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${stats.completionRate}%` }}
transition={{ duration: 1, ease: 'easeOut' }}
className="h-full bg-gradient-to-r from-purple-500 to-pink-500 rounded-full"
/>
</div>
</div>
)}
</motion.div>
))}
</div>
);
};
export default TaskStats;
// src/components/AddTaskModal.js
import React, { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { FiX, FiCalendar, FiTag, FiFlag } from 'react-icons/fi';
import { TASK_PRIORITY, TASK_CATEGORIES } from '../utils/constants';
const AddTaskModal = ({ isOpen, onClose, onSubmit }) => {
const [formData, setFormData] = useState({
title: '',
description: '',
priority: TASK_PRIORITY.MEDIUM,
category: TASK_CATEGORIES[0],
dueDate: '',
});
const [errors, setErrors] = useState({});
const handleSubmit = (e) => {
e.preventDefault();
const newErrors = {};
if (!formData.title.trim()) {
newErrors.title = 'Title is required';
}
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
onSubmit(formData);
handleClose();
};
const handleClose = () => {
setFormData({
title: '',
description: '',
priority: TASK_PRIORITY.MEDIUM,
category: TASK_CATEGORIES[0],
dueDate: '',
});
setErrors({});
onClose();
};
if (!isOpen) return null;
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
onClick={handleClose}
>
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }}
className="bg-white rounded-2xl shadow-2xl w-full max-w-lg max-h-[90vh] overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between p-6 border-b border-gray-100">
<h2 className="text-2xl font-bold text-gray-800">Add New Task</h2>
<button
onClick={handleClose}
className="p-2 text-gray-400 hover:text-gray-600 transition-colors rounded-lg hover:bg-gray-100"
>
<FiX className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 overflow-y-auto">
<div className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Task Title *
</label>
<input
type="text"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
className={`w-full px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent ${
errors.title ? 'border-red-300' : 'border-gray-300'
}`}
placeholder="Enter task title..."
/>
{errors.title && (
<p className="mt-1 text-sm text-red-600">{errors.title}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Description
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows="3"
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent resize-none"
placeholder="Add a description (optional)..."
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2 flex items-center gap-2">
<FiFlag className="w-4 h-4" />
Priority
</label>
<div className="grid grid-cols-4 gap-2">
{Object.values(TASK_PRIORITY).map((priority) => (
<button
key={priority}
type="button"
onClick={() => setFormData({ ...formData, priority })}
className={`px-4 py-3 rounded-lg text-sm font-medium transition-colors ${
formData.priority === priority
? 'bg-primary-100 text-primary-700 border-2 border-primary-300'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 border-2 border-transparent'
}`}
>
{priority.charAt(0).toUpperCase() + priority.slice(1)}
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2 flex items-center gap-2">
<FiTag className="w-4 h-4" />
Category
</label>
<select
value={formData.category}
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
>
{TASK_CATEGORIES.map((category) => (
<option key={category} value={category}>
{category}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2 flex items-center gap-2">
<FiCalendar className="w-4 h-4" />
Due Date
</label>
<input
type="date"
value={formData.dueDate}
onChange={(e) => setFormData({ ...formData, dueDate: e.target.value })}
min={new Date().toISOString().split('T')[0]}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
</div>
</div>
<div className="flex gap-3 mt-8 pt-6 border-t border-gray-100">
<button
type="button"
onClick={handleClose}
className="flex-1 px-6 py-3 border border-gray-300 text-gray-700 font-medium rounded-lg hover:bg-gray-50 transition-colors"
>
Cancel
</button>
<button
type="submit"
className="flex-1 px-6 py-3 bg-primary-600 text-white font-medium rounded-lg hover:bg-primary-700 transition-colors shadow-lg hover:shadow-xl"
>
Add Task
</button>
</div>
</form>
</motion.div>
</motion.div>
</AnimatePresence>
);
};
export default AddTaskModal;
// src/components/FilterModal.js
import React from 'react';
import { motion } from 'framer-motion';
import { FiX, FiFilter } from 'react-icons/fi';
import { TASK_PRIORITY, TASK_CATEGORIES } from '../utils/constants';
const FilterModal = ({ isOpen, onClose, filters, onFilterChange, onClear }) => {
if (!isOpen) return null;
const handleFilterChange = (key, value) => {
onFilterChange({ ...filters, [key]: value === filters[key] ? '' : value });
};
const handleClear = () => {
onClear();
onClose();
};
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
onClick={onClose}
>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
className="bg-white rounded-2xl shadow-2xl w-full max-w-md"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between p-6 border-b border-gray-100">
<div className="flex items-center gap-3">
<div className="p-2 bg-primary-50 rounded-lg">
<FiFilter className="w-5 h-5 text-primary-600" />
</div>
<h2 className="text-2xl font-bold text-gray-800">Filter Tasks</h2>
</div>
<button
onClick={onClose}
className="p-2 text-gray-400 hover:text-gray-600 transition-colors rounded-lg hover:bg-gray-100"
>
<FiX className="w-5 h-5" />
</button>
</div>
<div className="p-6 space-y-8">
<div>
<h3 className="text-lg font-semibold text-gray-800 mb-4">Priority</h3>
<div className="grid grid-cols-4 gap-2">
{Object.values(TASK_PRIORITY).map((priority) => (
<button
key={priority}
onClick={() => handleFilterChange('priority', priority)}
className={`px-4 py-3 rounded-lg text-sm font-medium transition-all ${
filters.priority === priority
? 'bg-primary-100 text-primary-700 border-2 border-primary-300 scale-105'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 border-2 border-transparent'
}`}
>
{priority.charAt(0).toUpperCase() + priority.slice(1)}
</button>
))}
</div>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-800 mb-4">Status</h3>
<div className="grid grid-cols-3 gap-2">
{['all', 'completed', 'pending'].map((status) => (
<button
key={status}
onClick={() => handleFilterChange('status', status === 'all' ? '' : status)}
className={`px-4 py-3 rounded-lg text-sm font-medium capitalize transition-all ${
(status === 'all' && !filters.status) || filters.status === status
? 'bg-primary-100 text-primary-700 border-2 border-primary-300 scale-105'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 border-2 border-transparent'
}`}
>
{status}
</button>
))}
</div>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-800 mb-4">Category</h3>
<div className="flex flex-wrap gap-2">
{TASK_CATEGORIES.map((category) => (
<button
key={category}
onClick={() => handleFilterChange('category', category)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${
filters.category === category
? 'bg-primary-100 text-primary-700 border-2 border-primary-300 scale-105'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 border-2 border-transparent'
}`}
>
{category}
</button>
))}
</div>
</div>
</div>
<div className="p-6 border-t border-gray-100">
<div className="flex gap-3">
<button
onClick={handleClear}
className="flex-1 px-6 py-3 border border-gray-300 text-gray-700 font-medium rounded-lg hover:bg-gray-50 transition-colors"
>
Clear All
</button>
<button
onClick={onClose}
className="flex-1 px-6 py-3 bg-primary-600 text-white font-medium rounded-lg hover:bg-primary-700 transition-colors"
>
Apply Filters
</button>
</div>
</div>
</motion.div>
</motion.div>
);
};
export default FilterModal;
Step 5: Main App Component
// src/App.js
import React, { useState } from 'react';
import { Toaster } from 'react-hot-toast';
import { motion, AnimatePresence } from 'framer-motion';
import useTasks from './hooks/useTasks';
import useDebounce from './hooks/useDebounce';
import Header from './components/Header';
import TaskStats from './components/TaskStats';
import TaskCard from './components/TaskCard';
import LoadingShimmer, { StatLoadingShimmer } from './components/LoadingShimmer';
import EmptyState from './components/EmptyState';
import ErrorWidget from './components/ErrorWidget';
import AddTaskModal from './components/AddTaskModal';
import FilterModal from './components/FilterModal';
import { FiPlus } from 'react-icons/fi';
import './styles/animations.css';
function App() {
const {
tasks,
loading,
error,
stats,
searchQuery,
setSearchQuery,
filters,
updateFilters,
clearFilters,
toggleTaskCompletion,
addTask,
deleteTask,
refreshTasks,
} = useTasks();
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const [isFilterModalOpen, setIsFilterModalOpen] = useState(false);
const debouncedSearch = useDebounce(searchQuery, 300);
const handleToggleTask = (taskId) => {
toggleTaskCompletion(taskId);
};
const handleDeleteTask = (taskId) => {
if (window.confirm('Are you sure you want to delete this task?')) {
deleteTask(taskId);
}
};
const handleAddTask = async (taskData) => {
const success = await addTask({
...taskData,
completed: false,
userId: 1,
createdAt: new Date(),
});
if (success) {
setIsAddModalOpen(false);
}
};
const handleEditTask = (taskId) => {
console.log('Edit task:', taskId);
};
const getEmptyStateType = () => {
if (debouncedSearch) return 'search';
if (Object.values(filters).some(Boolean)) return 'filter';
return 'no-tasks';
};
const handleEmptyStateAction = () => {
if (debouncedSearch) {
setSearchQuery('');
} else if (Object.values(filters).some(Boolean)) {
clearFilters();
} else {
setIsAddModalOpen(true);
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100">
<Toaster
position="top-right"
toastOptions={{
duration: 3000,
style: {
background: '#363636',
color: '#fff',
},
success: {
style: {
background: '#10B981',
},
},
error: {
style: {
background: '#EF4444',
},
},
}}
/>
<Header
onSearchChange={setSearchQuery}
onFilterClick={() => setIsFilterModalOpen(true)}
searchQuery={searchQuery}
stats={stats}
/>
<main className="container mx-auto px-4 py-8">
{loading ? (
<StatLoadingShimmer />
) : (
<TaskStats stats={stats} />
)}
<div className="bg-white/50 backdrop-blur-sm rounded-2xl shadow-lg p-5">
{error ? (
<ErrorWidget error={error} onRetry={refreshTasks} />
) : loading && tasks.length === 0 ? (
<LoadingShimmer />
) : tasks.length === 0 ? (
<EmptyState
type={getEmptyStateType()}
searchQuery={debouncedSearch}
onAction={handleEmptyStateAction}
/>
) : (
<AnimatePresence>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{tasks.map((task) => (
<TaskCard
key={task.id}
task={task}
onToggle={() => handleToggleTask(task.id)}
onDelete={() => handleDeleteTask(task.id)}
onEdit={() => handleEditTask(task.id)}
/>
))}
</div>
</AnimatePresence>
)}
</div>
</main>
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={() => setIsAddModalOpen(true)}
className="fixed bottom-8 right-8 w-14 h-14 bg-gradient-to-br from-primary-500 to-purple-600 text-white rounded-full shadow-2xl hover:shadow-3xl flex items-center justify-center z-40"
>
<FiPlus className="w-6 h-6" />
</motion.button>
<AddTaskModal
isOpen={isAddModalOpen}
onClose={() => setIsAddModalOpen(false)}
onSubmit={handleAddTask}
/>
<FilterModal
isOpen={isFilterModalOpen}
onClose={() => setIsFilterModalOpen(false)}
filters={filters}
onFilterChange={updateFilters}
onClear={clearFilters}
/>
</div>
);
}
export default App;
Step 6: Index Files
// src/index.js
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
/* src/index.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply antialiased text-gray-800;
}
}
@layer components {
.glass-effect {
@apply backdrop-blur-md bg-white/70 border border-white/20;
}
}
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
@apply bg-gray-100 rounded-full;
}
::-webkit-scrollbar-thumb {
@apply bg-gray-400 rounded-full hover:bg-gray-500;
}
::selection {
@apply bg-primary-500/20 text-primary-700;
}
/* src/styles/animations.css */
@keyframes float {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}
@keyframes pulse-subtle {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
.float-animation {
animation: float 3s ease-in-out infinite;
}
.pulse-subtle {
animation: pulse-subtle 2s ease-in-out infinite;
}
Running the Application
- Create a new React app:
npx create-react-app taskflow-react cd taskflow-react
- Install dependencies:
npm install axios react-icons react-hot-toast framer-motion date-fns npm install -D tailwindcss autoprefixer postcss npx tailwindcss init -p
- Configure Tailwind CSS:
Updatetailwind.config.jswith the configuration provided above. - Update index.css:
Replace the defaultsrc/index.csswith the provided CSS. - Create the project structure and files:
Create the folders and files as shown in the project structure. - Start the development server:
npm start
Key Features Implemented
- Real API Integration: Connected to JSONPlaceholder REST API
- Clean Modern Design: Tailwind CSS with gradient effects and animations
- Advanced State Management: Custom hooks with optimistic updates
- Search with Debouncing: Real-time search with 300ms debounce
- Filtering System: Multi-criteria filtering with clear options
- Loading States: Shimmer effects and skeleton loaders
- Error Handling: Graceful error states with retry functionality
- Empty States: Contextual empty states with actions
- Responsive Design: Mobile-first responsive layout
- Animations: Smooth transitions with Framer Motion
- Toasts: Success/error notifications
- Statistics Dashboard: Visual task completion metrics
Best Practices Followed
- Component Reusability: Created reusable, composable components
- Custom Hooks: Extracted logic into reusable hooks
- Error Boundaries: Graceful error handling throughout
- Optimistic Updates: Immediate UI feedback for actions
- Debouncing: Prevented API spam on search
- Accessibility: Semantic HTML and proper ARIA labels
- Performance: Memoized calculations and efficient renders
- Type Safety: Consistent data structures and validation
Next Steps for Production
- Add Redux or React Query for more complex state management
- Implement proper authentication
- Add unit and integration tests
- Implement service workers for offline support
- Add drag-and-drop for task reordering
- Implement calendar view
- Add export functionality (CSV/PDF)
- Implement real-time updates with WebSockets
This React application demonstrates modern best practices for API consumption, state management, and UI/UX design. The patterns shown here can be adapted to build any type of data-driven React application.

























Leave a Comment
Your email address will not be published. Required fields are marked with *