Build a React Dashboard with TypeScript — Full Project Tutorial
In this tutorial, you will learn about Build a React Dashboard with TypeScript. We cover key concepts, practical examples, and best practices to help you master this topic.
Build a full-stack TypeScript dashboard application with React frontend and Express backend — this project combines React components, hooks, API integration, state management, and data visualization into a production-quality application.
What You'll Learn
- Full-stack TypeScript project structure
- React Query for API data fetching
- Typed context and state management
- Chart.js with TypeScript types
- Authentication flow with JWT
- Deployment configuration
Why It Matters
Building a complete full-stack application ties together every TypeScript skill from this course — typed React components, Express API routes, database access, authentication, and testing — all in a single project that mirrors real-world applications.
Real-World Use
The Doda Browser admin dashboard — tracking installs, crashes, and performance across millions of devices — uses the same architecture. React frontend consuming a TypeScript Express API with typed data flowing end-to-end.
Learning Path
flowchart LR A[Project: REST API] --> B[Project: Dashboard] B --> C[Project: CLI Tool] B --> D[You Are Here] C --> E[Migration from JS] D --> F[Ecosystem Overview]
Project Overview
We'll build a Task Dashboard — a React frontend that consumes the Task API from lesson 55:
- Login/register page
- Task list with CRUD operations
- Task statistics with charts
- User profile page
- React Query for data fetching
- Typed context for auth state
Project Structure
task-dashboard/
src/
api/
client.ts
components/
Layout.tsx
TaskCard.tsx
TaskForm.tsx
hooks/
useAuth.ts
useTasks.ts
pages/
Dashboard.tsx
Login.tsx
Tasks.tsx
Profile.tsx
types/
index.ts
context/
AuthContext.tsx
App.tsx
main.tsx
package.json
tsconfig.json
vite.config.ts
Step 1: Setup
npm create vite@latest task-dashboard -- --template react-ts
cd task-dashboard
npm install react-router-dom @tanstack/react-query chart.js react-chartjs-2 axios
Step 2: Types
// src/types/index.ts
export interface User {
id: string;
email: string;
name: string;
}
export interface Task {
id: string;
title: string;
description?: string;
completed: boolean;
priority: number; // 0-5
dueDate?: string;
createdAt: string;
updatedAt: string;
}
export interface CreateTaskInput {
title: string;
description?: string;
priority?: number;
dueDate?: string;
}
export interface AuthResponse {
token: string;
user: User;
}
export interface TaskStats {
total: number;
completed: number;
pending: number;
overdue: number;
byPriority: { priority: number; count: number }[];
}
Step 3: API Client
// src/api/client.ts
import axios from 'axios';
import type { Task, CreateTaskInput, AuthResponse, TaskStats } from '../types';
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api',
});
// Attach auth token to every request
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
export const auth = {
login: (email: string, password: string) =>
api.post<AuthResponse>('/auth/login', { email, password }).then((r) => r.data),
register: (email: string, password: string, name?: string) =>
api.post<AuthResponse>('/auth/register', { email, password, name }).then((r) => r.data),
profile: () => api.get<User>('/auth/profile').then((r) => r.data),
};
export const tasks = {
list: () => api.get<Task[]>('/tasks').then((r) => r.data),
get: (id: string) => api.get<Task>(`/tasks/${id}`).then((r) => r.data),
create: (input: CreateTaskInput) =>
api.post<Task>('/tasks', input).then((r) => r.data),
update: (id: string, input: Partial<CreateTaskInput & { completed: boolean }>) =>
api.put<Task>(`/tasks/${id}`, input).then((r) => r.data),
delete: (id: string) => api.delete(`/tasks/${id}`).then((r) => r.data),
stats: () => api.get<TaskStats>('/tasks/stats').then((r) => r.data),
};
Step 4: Auth Context
// src/context/AuthContext.tsx
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import type { User } from '../types';
import { auth } from '../api/client';
interface AuthContextType {
user: User | null;
login: (email: string, password: string) => Promise<void>;
register: (email: string, password: string, name?: string) => Promise<void>;
logout: () => void;
loading: boolean;
}
const AuthContext = createContext<AuthContextType | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const token = localStorage.getItem('token');
if (token) {
auth.profile()
.then(setUser)
.catch(() => localStorage.removeItem('token'))
.finally(() => setLoading(false));
} else {
setLoading(false);
}
}, []);
const login = async (email: string, password: string) => {
const response = await auth.login(email, password);
localStorage.setItem('token', response.token);
setUser(response.user);
};
const register = async (email: string, password: string, name?: string) => {
const response = await auth.register(email, password, name);
localStorage.setItem('token', response.token);
setUser(response.user);
};
const logout = () => {
localStorage.removeItem('token');
setUser(null);
};
return (
<AuthContext.Provider value={{ user, login, register, logout, loading }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
}
Step 5: React Query Hooks
// src/hooks/useTasks.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { tasks } from '../api/client';
import type { CreateTaskInput, Task } from '../types';
export function useTasks() {
return useQuery<Task[]>({
queryKey: ['tasks'],
queryFn: tasks.list,
});
}
export function useCreateTask() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: CreateTaskInput) => tasks.create(input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
},
});
}
export function useUpdateTask() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, ...input }: { id: string } & Partial<CreateTaskInput & { completed: boolean }>) =>
tasks.update(id, input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
},
});
}
export function useDeleteTask() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => tasks.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
},
});
}
export function useTaskStats() {
return useQuery({
queryKey: ['tasks', 'stats'],
queryFn: tasks.stats,
});
}
Step 6: Dashboard Page with Charts
// src/pages/Dashboard.tsx
import { useTaskStats } from '../hooks/useTasks';
import { Doughnut, Bar } from 'react-chartjs-2';
import {
Chart as ChartJS,
ArcElement,
Tooltip,
Legend,
CategoryScale,
LinearScale,
BarElement,
} from 'chart.js';
ChartJS.register(ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement);
export default function Dashboard() {
const { data: stats, isLoading, error } = useTaskStats();
if (isLoading) return <div>Loading dashboard...</div>;
if (error) return <div>Failed to load stats</div>;
if (!stats) return <div>No data</div>;
const completionData = {
labels: ['Completed', 'Pending', 'Overdue'],
datasets: [
{
data: [stats.completed, stats.pending, stats.overdue],
backgroundColor: ['#22c55e', '#eab308', '#ef4444'],
},
],
};
const priorityData = {
labels: stats.byPriority.map((p) => `Priority ${p.priority}`),
datasets: [
{
label: 'Tasks by Priority',
data: stats.byPriority.map((p) => p.count),
backgroundColor: '#3b82f6',
},
],
};
return (
<div>
<h1 className="text-2xl font-bold mb-6">Dashboard</h1>
<div className="grid grid-cols-3 gap-4 mb-8">
<div className="bg-white p-4 rounded shadow">
<p className="text-gray-600">Total Tasks</p>
<p className="text-3xl font-bold">{stats.total}</p>
</div>
<div className="bg-white p-4 rounded shadow">
<p className="text-gray-600">Completed</p>
<p className="text-3xl font-bold text-green-600">{stats.completed}</p>
</div>
<div className="bg-white p-4 rounded shadow">
<p className="text-gray-600">Overdue</p>
<p className="text-3xl font-bold text-red-600">{stats.overdue}</p>
</div>
</div>
<div className="grid grid-cols-2 gap-6">
<div className="bg-white p-4 rounded shadow">
<h2 className="font-semibold mb-4">Completion Status</h2>
<Doughnut data={completionData} />
</div>
<div className="bg-white p-4 rounded shadow">
<h2 className="font-semibold mb-4">Tasks by Priority</h2>
<Bar data={priorityData} />
</div>
</div>
</div>
);
}
Common Mistakes
1. Not handling loading and error states in every component
React Query provides isLoading and error — always render fallback UI for both states to prevent blank screens.
2. Calling hooks conditionally
React hooks must be called in the same order every render. Never put useQuery inside if statements or loops.
3. Not invalidating queries after mutations
After creating, updating, or deleting a task, invalidate the task query so the UI reflects changes immediately.
4. Storing raw JWT tokens without refresh handling
Tokens expire. Implement token refresh logic or redirect to login when the API returns 401.
5. Using any for props in shared components
Every component should have typed props. Create TypeScript interfaces for each component's props.
6. Not separating API types from UI types
API responses may differ from what the UI needs. Create separate interfaces or use transformation functions.
7. Over-fetching data for chart components
Chart components may only need aggregated data, not full lists. Create a dedicated /stats endpoint.
Practice Questions
Why use React Query over plain fetch calls? React Query provides caching, automatic refetching, loading/error states, and stale-while-revalidate — features you'd need to build manually with plain fetch.
How does the AuthContext protect routes? The context provides the current user or null. Route guards check the user and redirect to login if not authenticated.
What's the
VITE_API_URLenvironment variable for? Vite usesVITE_prefixed env variables. This lets you set the API URL for development vs production without hardcoding.How do you type Chart.js chart data? Chart.js types are included in
react-chartjs-2. Define data asChartData<'doughnut'>orChartData<'bar'>for strict typing.Why invalidate queries after mutations? Invalidating marks cached data as stale, triggering a refetch. This ensures all components displaying task data update automatically.
Challenge
Add these features to the dashboard: task filtering by priority/status, dark mode toggle with context, activity log page showing recent actions, and a user settings page with avatar upload.
FAQ
Project Summary
You've built a full-stack TypeScript dashboard application! This project demonstrates:
- React with TypeScript components
- React Query for API data management
- Auth context with JWT
- Chart.js data visualization
- Full-stack type safety
- Clean project architecture
The complete project is ready to extend with real features for a production application.
What's Next
You've built a full-stack dashboard with TypeScript. Now build a CLI tool with {{< ref "57-project-cli-tool" >}}, or learn how to migrate existing JavaScript projects to TypeScript with {{< ref "58-migration-from-js" >}}.
For an overview of the TypeScript ecosystem, see {{< ref "59-ecosystem-overview" >}}.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro