Skip to content

Build a React Dashboard with TypeScript — Full Project Tutorial

DodaTech Updated 2026-06-28 8 min read

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

  1. 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.

  2. 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.

  3. What's the VITE_API_URL environment variable for? Vite uses VITE_ prefixed env variables. This lets you set the API URL for development vs production without hardcoding.

  4. How do you type Chart.js chart data? Chart.js types are included in react-chartjs-2. Define data as ChartData<'doughnut'> or ChartData<'bar'> for strict typing.

  5. 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

Should I use Vite or Create React App for new projects?

Vite. CRA is deprecated. Vite provides faster builds, better TypeScript support, and modern ESM-based development.

How do I deploy a React TypeScript app?

Run vite build to produce optimized static files in dist/. Deploy to Vercel, Netlify, Cloudflare Pages, or any static host.

What's the best TypeScript state management for React?

React Query for server state, Context for global app state (auth, theme), and useState/useReducer for local component state. Avoid Redux for most apps.

How do I handle environment variables in Vite?

Prefix with VITE_: VITE_API_URL=http://localhost:3000/api. Access with import.meta.env.VITE_API_URL.

Can I use this project with Next.js?

Yes. The same React components work in Next.js. Move API calls to server-side functions and use Server Components where possible.

How do I add real-time updates?

Use WebSockets (Socket.io) to push task updates. React Query can refetch when receiving a Websocket message.

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