Skip to main content

🌀️ Module 4 Project: Weather Dashboard

Congratulations on completing Module 4! πŸŽ‰ Now it's time to put everything together and build a real-world application. You'll create a beautiful, functional weather dashboard that fetches real data from a weather API, caches results, handles errors gracefully, and provides an excellent user experience. This project combines useEffect, custom hooks, data fetching, API integration, error handling, and more. By the end, you'll have a portfolio-worthy project that demonstrates your mastery of React's data fetching ecosystem. Let's build something amazing! β˜€οΈ

🎯 Project Goals

By completing this project, you will:

  • Build a complete, real-world React application from scratch
  • Integrate with the OpenWeatherMap API
  • Implement city search with debouncing
  • Display current weather and 5-day forecast
  • Create custom hooks for weather data fetching
  • Implement client-side caching with localStorage
  • Handle loading states and errors gracefully
  • Build a responsive, mobile-friendly UI
  • Use TypeScript for type safety throughout
  • Apply all best practices from Module 4

Estimated Time: 3-4 hours

Difficulty: Intermediate

Project Guide

πŸ“‹ Project Overview

You're building a weather dashboard that shows current weather and forecasts for cities around the world. Users can search for cities, view detailed weather information, and save their favorite locations.

Features We'll Build

Core Features (Must Have)

  • πŸ” City Search - Search for cities with debounced input
  • 🌑️ Current Weather - Temperature, conditions, humidity, wind speed
  • πŸ“… 5-Day Forecast - Daily high/low temperatures and conditions
  • πŸ’Ύ Caching - Cache weather data to reduce API calls
  • ⚠️ Error Handling - Graceful error messages and retry options
  • ⏳ Loading States - Skeleton screens and spinners
  • πŸ“± Responsive Design - Mobile-friendly layout

Advanced Features (Should Have)

  • ⭐ Favorites - Save favorite cities to localStorage
  • πŸ”„ Auto-Refresh - Update weather data every 10 minutes
  • 🌍 Geolocation - Get weather for user's current location
  • 🎨 Dynamic Backgrounds - Change UI based on weather conditions
  • πŸ“Š Weather Charts - Visualize temperature trends

Bonus Features (Nice to Have)

  • πŸŒ“ Dark Mode - Toggle between light and dark themes
  • 🌑️ Unit Toggle - Switch between Celsius and Fahrenheit
  • 🌐 Multiple Languages - i18n support
  • πŸ“ Map View - Show city on a map
  • πŸ”” Weather Alerts - Notifications for severe weather

What You'll Practice

βœ… Module 4 Concepts

  • useEffect - Fetching data, cleanup, dependency arrays
  • Data Fetching - Async/await, loading states, error handling
  • Custom Hooks - useWeather, useFavorites, useDebounce
  • Advanced Patterns - Debouncing, caching, race conditions
  • API Integration - Service layer, TypeScript types, environment variables

Final Result Preview

Your completed weather dashboard will look something like this:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  🌀️ Weather Dashboard                      β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚  β”‚ πŸ” Search cities...                   β”‚ β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β”‚                                             β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”‚
β”‚  β”‚ πŸ“ San Francisco, CA                β”‚   β”‚
β”‚  β”‚                                     β”‚   β”‚
β”‚  β”‚    β˜€οΈ                              β”‚   β”‚
β”‚  β”‚    22Β°C                            β”‚   β”‚
β”‚  β”‚    Sunny                           β”‚   β”‚
β”‚  β”‚                                     β”‚   β”‚
β”‚  β”‚    πŸ’§ Humidity: 65%                β”‚   β”‚
β”‚  β”‚    πŸ’¨ Wind: 12 km/h                β”‚   β”‚
β”‚  β”‚    πŸ‘οΈ Visibility: 10 km            β”‚   β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚
β”‚                                             β”‚
β”‚  πŸ“… 5-Day Forecast                          β”‚
β”‚  β”Œβ”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”                     β”‚
β”‚  β”‚Monβ”‚Tueβ”‚Wedβ”‚Thuβ”‚Friβ”‚                     β”‚
β”‚  β”‚β˜€οΈ β”‚β›…β”‚πŸŒ§οΈβ”‚β›ˆοΈ β”‚β˜€οΈ β”‚                     β”‚
β”‚  β”‚24Β°β”‚22Β°β”‚19Β°β”‚18Β°β”‚23Β°β”‚                     β”‚
β”‚  β””β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”˜                     β”‚
β”‚                                             β”‚
β”‚  ⭐ Favorites: NYC, London, Tokyo          β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Application Architecture

Before writing a line of code, it helps to see how the pieces fit together. Notice the layering: components never talk to the network directly β€” they lean on custom hooks, which lean on a service layer, which is the only part that knows the API even exists. That separation is what keeps the app testable and lets us slot a cache in front of the API without any component knowing.

graph TD A[App Component] --> B[Search Bar] A --> C[Current Weather Display] A --> D[Forecast List] A --> E[Favorites Bar] B --> F[useDebounce Hook] B --> G[Weather Service] C --> H[useWeather Hook] D --> H H --> G H --> I[Cache Layer] E --> J[useFavorites Hook] J --> K[localStorage] G --> L[OpenWeatherMap API] style A fill:#667eea,stroke:#333,stroke-width:2px,color:#fff style H fill:#764ba2,stroke:#333,stroke-width:2px,color:#fff style G fill:#f093fb,stroke:#333,stroke-width:2px,color:#fff

πŸ’‘ Learning Approach

This project guide is structured to walk you through building the app step by step. Each section includes:

  • Clear objectives - What you'll build in this section
  • Code examples - Complete, working code you can use
  • Explanations - Why we're doing things this way
  • Challenges - Optional extensions to try on your own

Tip: Try to implement features yourself first, then check the provided solutions!

βš™οΈ Setting Up the Project

Let's create our project with Vite and set up the initial structure.

Step 1: Create the Project

# Create a new Vite project with React and TypeScript
npm create vite@latest weather-dashboard -- --template react-ts

# Navigate into the project
cd weather-dashboard

# Install dependencies
npm install

# Start the development server
npm run dev

Step 2: Install Additional Dependencies

# Optional: Install date formatting library
npm install date-fns

# Optional: Install icons (if you want to use React Icons)
npm install react-icons

Step 3: Clean Up Default Files

Remove the default Vite boilerplate:

  • Delete src/App.css
  • Delete src/index.css (we'll create our own)
  • Clear the content of src/App.tsx

Step 4: Create Basic Styles

/* src/index.css */
:root {
    --primary: #667eea;
    --primary-dark: #5a67d8;
    --secondary: #764ba2;
    --success: #48bb78;
    --danger: #f56565;
    --warning: #ed8936;
    --info: #4299e1;
    
    --bg-primary: #ffffff;
    --bg-secondary: #f7fafc;
    --text-primary: #2d3748;
    --text-secondary: #718096;
    --border: #e2e8f0;
    
    --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
    --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
    --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
    
    --radius: 8px;
    --spacing: 1rem;
}

* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 
                 'Helvetica Neue', Arial, sans-serif;
    background: var(--bg-secondary);
    color: var(--text-primary);
    line-height: 1.6;
}

.container {
    max-width: 1200px;
    margin: 0 auto;
    padding: 2rem 1rem;
}

.card {
    background: var(--bg-primary);
    border-radius: var(--radius);
    padding: 1.5rem;
    box-shadow: var(--shadow-md);
    margin-bottom: 1rem;
}

.btn {
    padding: 0.75rem 1.5rem;
    border: none;
    border-radius: var(--radius);
    font-size: 1rem;
    cursor: pointer;
    transition: all 0.2s;
}

.btn-primary {
    background: var(--primary);
    color: white;
}

.btn-primary:hover {
    background: var(--primary-dark);
}

.input {
    width: 100%;
    padding: 0.75rem;
    border: 1px solid var(--border);
    border-radius: var(--radius);
    font-size: 1rem;
}

.input:focus {
    outline: none;
    border-color: var(--primary);
    box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}

/* Loading spinner */
.spinner {
    border: 3px solid var(--border);
    border-top: 3px solid var(--primary);
    border-radius: 50%;
    width: 40px;
    height: 40px;
    animation: spin 1s linear infinite;
    margin: 2rem auto;
}

@keyframes spin {
    0% { transform: rotate(0deg); }
    100% { transform: rotate(360deg); }
}

/* Error message */
.error {
    background: #fff5f5;
    border: 1px solid var(--danger);
    color: var(--danger);
    padding: 1rem;
    border-radius: var(--radius);
    margin: 1rem 0;
}

/* Responsive */
@media (max-width: 768px) {
    .container {
        padding: 1rem;
    }
    
    .card {
        padding: 1rem;
    }
}

Step 5: Basic App Structure

// src/App.tsx
import './index.css';

function App() {
    return (
        <div className="container">
            <header>
                <h1 style={{ 
                    textAlign: 'center', 
                    margin: '2rem 0',
                    background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
                    WebkitBackgroundClip: 'text',
                    WebkitTextFillColor: 'transparent',
                    fontSize: '2.5rem'
                }}>
                    🌀️ Weather Dashboard
                </h1>
            </header>
            
            <main>
                <div className="card">
                    <p>Weather dashboard coming soon...</p>
                </div>
            </main>
        </div>
    );
}

export default App;

βœ… Checkpoint

At this point, you should have:

  • βœ… A working Vite + React + TypeScript project
  • βœ… Basic styles defined in index.css
  • βœ… A clean App.tsx with a header
  • βœ… The development server running at localhost:5173

You should see "Weather Dashboard" heading in your browser! πŸŽ‰

πŸ”‘ Getting an API Key

We'll use the OpenWeatherMap API, which is free and perfect for learning. Let's get your API key!

Step 1: Sign Up for OpenWeatherMap

  1. Go to OpenWeatherMap.org
  2. Click "Sign In" β†’ "Create an Account"
  3. Fill in your details and verify your email
  4. Go to "API keys" tab in your account
  5. Copy your default API key (or generate a new one)

Note: It can take up to 2 hours for your API key to activate, but usually it's instant.

Step 2: Set Up Environment Variables

Create a .env file in your project root:

# .env
VITE_WEATHER_API_KEY=your_api_key_here
VITE_WEATHER_API_URL=https://api.openweathermap.org/data/2.5

Create a .env.example for your repository:

# .env.example
VITE_WEATHER_API_KEY=
VITE_WEATHER_API_URL=https://api.openweathermap.org/data/2.5

Add .env to your .gitignore:

# .gitignore
.env
.env.local
.env.*.local

Step 3: Type Your Environment Variables

By default, import.meta.env.VITE_WEATHER_API_KEY is typed as string | undefined with no autocomplete. Since our whole app depends on this key, let's teach TypeScript about it. Extend the ImportMetaEnv interface in the vite-env.d.ts file Vite already generated for you:

// src/vite-env.d.ts
/// <reference types="vite/client" />

interface ImportMetaEnv {
    readonly VITE_WEATHER_API_KEY: string;
    readonly VITE_WEATHER_API_URL: string;
}

interface ImportMeta {
    readonly env: ImportMetaEnv;
}

Now every import.meta.env.VITE_* access is autocompleted and type-checked. This is a small file, but it pays off every time you touch config.

Step 4: Create a Configuration Module

Rather than scatter import.meta.env reads across the codebase, we centralize them in one module and validate up front. If the key is missing, we fail loudly on startup instead of getting a confusing 401 from the API later:

// src/config/env.ts
export const config = {
    weatherApiKey: import.meta.env.VITE_WEATHER_API_KEY,
    weatherApiUrl: import.meta.env.VITE_WEATHER_API_URL ?? 'https://api.openweathermap.org/data/2.5',
    isDevelopment: import.meta.env.DEV,
    isProduction: import.meta.env.PROD,
} as const;

// Fail fast: a missing key is a developer error, so surface it immediately.
if (!config.weatherApiKey) {
    throw new Error('VITE_WEATHER_API_KEY is required β€” add it to your .env file');
}

API Endpoints We'll Use

Endpoint Purpose Example
/weather Current weather /weather?q=London&appid=KEY
/forecast 5-day forecast /forecast?q=London&appid=KEY
/weather By coordinates /weather?lat=51.5&lon=-0.1&appid=KEY

⚠️ Important Notes

  • Free tier limits: 60 calls/minute, 1,000,000 calls/month
  • Units: Add &units=metric for Celsius, &units=imperial for Fahrenheit
  • Default: Temperature is in Kelvin if no units specified
  • API key: Never commit your actual API key to version control!

βœ… Checkpoint

At this point, you should have:

  • βœ… An OpenWeatherMap account with an API key
  • βœ… .env file with your API key (not committed to git)
  • βœ… .env.example file (safe to commit)
  • βœ… config/env.ts file that validates the API key

Ready to start making API calls! πŸš€

πŸ“ Project Structure

Let's organize our project with a clean, scalable structure. Here's how we'll organize our files:

weather-dashboard/
β”œβ”€β”€ src/
β”‚   β”œβ”€β”€ components/
β”‚   β”‚   β”œβ”€β”€ SearchBar.tsx
β”‚   β”‚   β”œβ”€β”€ CurrentWeather.tsx
β”‚   β”‚   β”œβ”€β”€ ForecastCard.tsx
β”‚   β”‚   β”œβ”€β”€ ForecastList.tsx
β”‚   β”‚   β”œβ”€β”€ FavoritesList.tsx
β”‚   β”‚   β”œβ”€β”€ ErrorDisplay.tsx
β”‚   β”‚   └── LoadingSpinner.tsx
β”‚   β”œβ”€β”€ hooks/
β”‚   β”‚   β”œβ”€β”€ useWeather.ts
β”‚   β”‚   β”œβ”€β”€ useDebounce.ts
β”‚   β”‚   β”œβ”€β”€ useFavorites.ts
β”‚   β”‚   └── useGeolocation.ts
β”‚   β”œβ”€β”€ services/
β”‚   β”‚   β”œβ”€β”€ weatherService.ts
β”‚   β”‚   └── cacheService.ts
β”‚   β”œβ”€β”€ types/
β”‚   β”‚   β”œβ”€β”€ weather.ts
β”‚   β”‚   └── index.ts
β”‚   β”œβ”€β”€ utils/
β”‚   β”‚   β”œβ”€β”€ formatters.ts
β”‚   β”‚   └── weatherIcons.ts
β”‚   β”œβ”€β”€ config/
β”‚   β”‚   └── env.ts
β”‚   β”œβ”€β”€ App.tsx
β”‚   β”œβ”€β”€ main.tsx
β”‚   └── index.css
β”œβ”€β”€ .env
β”œβ”€β”€ .env.example
β”œβ”€β”€ .gitignore
β”œβ”€β”€ package.json
β”œβ”€β”€ tsconfig.json
└── vite.config.ts

Create the Folder Structure

# From your project root, create all folders
mkdir -p src/components src/hooks src/services src/types src/utils src/config

πŸ’‘ Organization Philosophy

  • components/ - Reusable UI components
  • hooks/ - Custom hooks for logic reuse
  • services/ - API calls and business logic
  • types/ - TypeScript type definitions
  • utils/ - Helper functions
  • config/ - Configuration and constants

πŸ”Œ Building the API Service

Let's create our weather service that handles all API communication. We'll start with TypeScript types, then build the service layer.

Step 1: Define Weather Types

// src/types/weather.ts

// Current weather data from API
export interface WeatherData {
    coord: {
        lon: number;
        lat: number;
    };
    weather: Array<{
        id: number;
        main: string;
        description: string;
        icon: string;
    }>;
    main: {
        temp: number;
        feels_like: number;
        temp_min: number;
        temp_max: number;
        pressure: number;
        humidity: number;
    };
    wind: {
        speed: number;
        deg: number;
    };
    clouds: {
        all: number;
    };
    dt: number;
    sys: {
        country: string;
        sunrise: number;
        sunset: number;
    };
    name: string;
    visibility?: number;
}

// Forecast data from API
export interface ForecastData {
    list: Array<{
        dt: number;
        main: {
            temp: number;
            feels_like: number;
            temp_min: number;
            temp_max: number;
            humidity: number;
        };
        weather: Array<{
            id: number;
            main: string;
            description: string;
            icon: string;
        }>;
        wind: {
            speed: number;
        };
        dt_txt: string;
    }>;
    city: {
        name: string;
        country: string;
        sunrise: number;
        sunset: number;
    };
}

// Processed daily forecast
export interface DailyForecast {
    date: string;
    temp: number;
    tempMin: number;
    tempMax: number;
    description: string;
    icon: string;
    humidity: number;
    windSpeed: number;
}

// City search result
export interface City {
    name: string;
    country: string;
    lat: number;
    lon: number;
}

Step 2: Create Cache Service

// src/services/cacheService.ts

interface CacheEntry<T> {
    data: T;
    timestamp: number;
}

class CacheService {
    private cache = new Map<string, CacheEntry<any>>();
    private defaultTTL = 10 * 60 * 1000; // 10 minutes

    set<T>(key: string, data: T, ttl?: number): void {
        this.cache.set(key, {
            data,
            timestamp: Date.now()
        });
    }

    get<T>(key: string, ttl?: number): T | null {
        const entry = this.cache.get(key);
        
        if (!entry) return null;

        const age = Date.now() - entry.timestamp;
        const maxAge = ttl || this.defaultTTL;

        if (age > maxAge) {
            this.cache.delete(key);
            return null;
        }

        return entry.data;
    }

    has(key: string, ttl?: number): boolean {
        return this.get(key, ttl) !== null;
    }

    clear(): void {
        this.cache.clear();
    }

    delete(key: string): void {
        this.cache.delete(key);
    }
}

export const cacheService = new CacheService();

Step 3: Create Weather Service

βœ… Complete Weather Service

// src/services/weatherService.ts
import { config } from '@/config/env';
import { cacheService } from './cacheService';
import type { WeatherData, ForecastData, DailyForecast } from '@/types/weather';

class WeatherService {
    private baseUrl = config.weatherApiUrl;
    private apiKey = config.weatherApiKey;

    private buildUrl(endpoint: string, params: Record<string, string>): string {
        const searchParams = new URLSearchParams({
            ...params,
            appid: this.apiKey,
            units: 'metric' // Use Celsius
        });

        return `${this.baseUrl}${endpoint}?${searchParams.toString()}`;
    }

    async getCurrentWeather(city: string, signal?: AbortSignal): Promise<WeatherData> {
        const cacheKey = `weather-${city.toLowerCase()}`;

        // Check cache first
        const cached = cacheService.get<WeatherData>(cacheKey);
        if (cached) return cached;

        const url = this.buildUrl('/weather', { q: city });

        try {
            // Passing `signal` lets an in-flight request be cancelled (see the hook)
            const response = await fetch(url, { signal });

            if (!response.ok) {
                if (response.status === 404) {
                    throw new Error(`City "${city}" not found`);
                }
                throw new Error(`Failed to fetch weather data: ${response.statusText}`);
            }

            const data: WeatherData = await response.json();
            cacheService.set(cacheKey, data);
            return data;
        } catch (error) {
            // A cancelled request is not a real failure β€” re-throw it so the caller can ignore it.
            if (error instanceof DOMException && error.name === 'AbortError') throw error;
            if (error instanceof Error) throw error;
            throw new Error('An unexpected error occurred');
        }
    }

    async getWeatherByCoords(lat: number, lon: number, signal?: AbortSignal): Promise<WeatherData> {
        const cacheKey = `weather-coords-${lat}-${lon}`;

        const cached = cacheService.get<WeatherData>(cacheKey);
        if (cached) return cached;

        const url = this.buildUrl('/weather', {
            lat: lat.toString(),
            lon: lon.toString()
        });

        try {
            const response = await fetch(url, { signal });

            if (!response.ok) {
                throw new Error(`Failed to fetch weather data: ${response.statusText}`);
            }

            const data: WeatherData = await response.json();
            cacheService.set(cacheKey, data);
            return data;
        } catch (error) {
            if (error instanceof DOMException && error.name === 'AbortError') throw error;
            if (error instanceof Error) throw error;
            throw new Error('An unexpected error occurred');
        }
    }

    async getForecast(city: string, signal?: AbortSignal): Promise<DailyForecast[]> {
        const cacheKey = `forecast-${city.toLowerCase()}`;

        const cached = cacheService.get<DailyForecast[]>(cacheKey);
        if (cached) return cached;

        const url = this.buildUrl('/forecast', { q: city });

        try {
            const response = await fetch(url, { signal });

            if (!response.ok) {
                if (response.status === 404) {
                    throw new Error(`City "${city}" not found`);
                }
                throw new Error(`Failed to fetch forecast data: ${response.statusText}`);
            }

            const data: ForecastData = await response.json();

            // Raw API data arrives in 3-hour slices; collapse it to one entry per day.
            const dailyForecasts = this.processForecastData(data);
            cacheService.set(cacheKey, dailyForecasts);
            return dailyForecasts;
        } catch (error) {
            if (error instanceof DOMException && error.name === 'AbortError') throw error;
            if (error instanceof Error) throw error;
            throw new Error('An unexpected error occurred');
        }
    }

    private processForecastData(data: ForecastData): DailyForecast[] {
        // Group forecast data by day
        const dailyData = new Map<string, typeof data.list[0][]>();

        data.list.forEach(item => {
            const date = item.dt_txt.split(' ')[0]; // Get date part (YYYY-MM-DD)
            
            if (!dailyData.has(date)) {
                dailyData.set(date, []);
            }
            
            dailyData.get(date)!.push(item);
        });

        // Convert to DailyForecast array (take first 5 days)
        const forecasts: DailyForecast[] = [];
        
        Array.from(dailyData.entries()).slice(0, 5).forEach(([date, items]) => {
            // Calculate daily averages and extremes
            const temps = items.map(item => item.main.temp);
            const tempMin = Math.min(...temps);
            const tempMax = Math.max(...temps);
            const avgTemp = temps.reduce((a, b) => a + b) / temps.length;
            
            // Use midday weather data (around 12:00) if available
            const middayItem = items.find(item => item.dt_txt.includes('12:00:00')) || items[0];

            forecasts.push({
                date,
                temp: Math.round(avgTemp),
                tempMin: Math.round(tempMin),
                tempMax: Math.round(tempMax),
                description: middayItem.weather[0].description,
                icon: middayItem.weather[0].icon,
                humidity: middayItem.main.humidity,
                windSpeed: middayItem.wind.speed
            });
        });

        return forecasts;
    }
}

export const weatherService = new WeatherService();

πŸ’‘ Service Layer Benefits

  • Centralized API logic - All weather calls in one place
  • Built-in caching - Reduces API calls and improves performance
  • Error handling - Consistent error messages
  • Type safety - Full TypeScript support
  • Testability - Easy to mock for testing

⚠️ Why every method takes an AbortSignal

Imagine a user types "Lo", then quickly finishes "London". Two requests go out. If the slower "Lo" response lands after "London", it overwrites the correct data β€” a classic race condition. By threading an AbortSignal through fetch, the hook can cancel the older request before starting the new one, so only the latest search can ever win. We re-throw the resulting AbortError untouched so the calling hook knows to quietly ignore it rather than showing an error to the user.

Step 4: Create Utility Functions

// src/utils/formatters.ts
import { format } from 'date-fns';

export function formatTemperature(temp: number): string {
    return `${Math.round(temp)}Β°C`;
}

export function formatDate(dateString: string): string {
    const date = new Date(dateString);
    return format(date, 'EEE, MMM d');
}

export function formatTime(timestamp: number): string {
    return format(new Date(timestamp * 1000), 'h:mm a');
}

export function capitalizeWords(str: string): string {
    return str
        .split(' ')
        .map(word => word.charAt(0).toUpperCase() + word.slice(1))
        .join(' ');
}
// src/utils/weatherIcons.ts

// Map OpenWeatherMap icon codes to emojis
export function getWeatherEmoji(iconCode: string): string {
    const iconMap: Record<string, string> = {
        '01d': 'β˜€οΈ', // clear sky day
        '01n': 'πŸŒ™', // clear sky night
        '02d': 'β›…', // few clouds day
        '02n': '☁️', // few clouds night
        '03d': '☁️', // scattered clouds
        '03n': '☁️',
        '04d': '☁️', // broken clouds
        '04n': '☁️',
        '09d': '🌧️', // shower rain
        '09n': '🌧️',
        '10d': '🌦️', // rain day
        '10n': '🌧️', // rain night
        '11d': 'β›ˆοΈ', // thunderstorm
        '11n': 'β›ˆοΈ',
        '13d': '❄️', // snow
        '13n': '❄️',
        '50d': '🌫️', // mist
        '50n': '🌫️'
    };

    return iconMap[iconCode] || '🌀️';
}

export function getWeatherBackground(iconCode: string): string {
    const first2 = iconCode.slice(0, 2);
    
    const backgroundMap: Record<string, string> = {
        '01': 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', // clear
        '02': 'linear-gradient(135deg, #89f7fe 0%, #66a6ff 100%)', // few clouds
        '03': 'linear-gradient(135deg, #a8edea 0%, #fed6e3 100%)', // scattered clouds
        '04': 'linear-gradient(135deg, #d299c2 0%, #fef9d7 100%)', // broken clouds
        '09': 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)', // shower rain
        '10': 'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)', // rain
        '11': 'linear-gradient(135deg, #fa709a 0%, #fee140 100%)', // thunderstorm
        '13': 'linear-gradient(135deg, #e0c3fc 0%, #8ec5fc 100%)', // snow
        '50': 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)'  // mist
    };

    return backgroundMap[first2] || 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)';
}

βœ… Checkpoint

At this point, you should have:

  • βœ… Complete type definitions for weather data
  • βœ… Cache service for storing API responses
  • βœ… Weather service with getCurrentWeather, getWeatherByCoords, and getForecast methods
  • βœ… Utility functions for formatting and icons

Your API layer is ready! Time to build custom hooks. 🎣

🎣 Creating Custom Hooks

Let's create reusable custom hooks that encapsulate our app's logic.

Hook 1: useDebounce

// src/hooks/useDebounce.ts
import { useState, useEffect } from 'react';

export function useDebounce<T>(value: T, delay: number): T {
    const [debouncedValue, setDebouncedValue] = useState<T>(value);

    useEffect(() => {
        // Set up a timer to update the debounced value
        const timer = setTimeout(() => {
            setDebouncedValue(value);
        }, delay);

        // Clean up the timer if value changes before delay expires
        return () => {
            clearTimeout(timer);
        };
    }, [value, delay]);

    return debouncedValue;
}

Hook 2: useWeather

This is the heart of the app. Instead of juggling four loosely-related pieces of state (weather, forecast, loading, error) that can drift out of sync β€” what does loading: true with a non-null error even mean? β€” we model the UI as a single discriminated union. At any moment the app is in exactly one of four states, and TypeScript forces us to handle each one:

βœ… Complete useWeather Hook

// src/hooks/useWeather.ts
import { useState, useRef, useEffect, useCallback } from 'react';
import { weatherService } from '@/services/weatherService';
import type { WeatherData, DailyForecast } from '@/types/weather';

// A discriminated union: exactly ONE of these shapes is ever active.
// The `status` field is the discriminant that TypeScript narrows on.
export type WeatherState =
    | { status: 'idle' }
    | { status: 'loading' }
    | { status: 'success'; weather: WeatherData; forecast: DailyForecast[] }
    | { status: 'error'; error: string };

export function useWeather() {
    const [state, setState] = useState<WeatherState>({ status: 'idle' });

    // Holds the controller for the request currently in flight so we can cancel it.
    const controllerRef = useRef<AbortController | null>(null);

    // Shared runner: cancels any previous request, then runs the given task.
    const run = useCallback(async (
        task: (signal: AbortSignal) => Promise<{ weather: WeatherData; forecast: DailyForecast[] }>
    ) => {
        // Cancel the previous request so a stale response can't overwrite fresh data.
        controllerRef.current?.abort();
        const controller = new AbortController();
        controllerRef.current = controller;

        setState({ status: 'loading' });

        try {
            const { weather, forecast } = await task(controller.signal);
            if (controller.signal.aborted) return; // superseded by a newer request
            setState({ status: 'success', weather, forecast });
        } catch (err) {
            // Ignore cancellations β€” they're expected, not real errors.
            if (controller.signal.aborted || (err as Error).name === 'AbortError') return;
            setState({
                status: 'error',
                error: err instanceof Error ? err.message : 'Failed to fetch weather data'
            });
        }
    }, []);

    const fetchWeather = useCallback((city: string) => {
        if (!city.trim()) return;
        return run(async (signal) => {
            // Fetch current weather and forecast in parallel β€” they don't depend on each other.
            const [weather, forecast] = await Promise.all([
                weatherService.getCurrentWeather(city, signal),
                weatherService.getForecast(city, signal)
            ]);
            return { weather, forecast };
        });
    }, [run]);

    const fetchWeatherByCoords = useCallback((lat: number, lon: number) => {
        return run(async (signal) => {
            const weather = await weatherService.getWeatherByCoords(lat, lon, signal);
            // The coords endpoint gives us the city name; use it to pull the forecast.
            const forecast = await weatherService.getForecast(weather.name, signal);
            return { weather, forecast };
        });
    }, [run]);

    // Cancel any pending request if the component unmounts mid-fetch.
    useEffect(() => () => controllerRef.current?.abort(), []);

    return { state, fetchWeather, fetchWeatherByCoords };
}

πŸ’‘ Why a discriminated union beats loose booleans

With separate loading / error / data variables you can accidentally represent impossible states β€” loading and errored at once, or "success" with null data. Modelling status as 'idle' | 'loading' | 'success' | 'error' makes those states unrepresentable. When you later write if (state.status === 'success'), TypeScript narrows the type and guarantees state.weather exists inside that branch β€” no optional chaining, no null checks.

Hook 3: useFavorites

// src/hooks/useFavorites.ts
import { useState, useEffect } from 'react';

const FAVORITES_KEY = 'weather-favorites';

export function useFavorites() {
    const [favorites, setFavorites] = useState<string[]>(() => {
        try {
            const stored = localStorage.getItem(FAVORITES_KEY);
            return stored ? JSON.parse(stored) : [];
        } catch {
            return [];
        }
    });

    // Save to localStorage whenever favorites change
    useEffect(() => {
        try {
            localStorage.setItem(FAVORITES_KEY, JSON.stringify(favorites));
        } catch (error) {
            console.error('Failed to save favorites:', error);
        }
    }, [favorites]);

    const addFavorite = (city: string) => {
        setFavorites(prev => {
            if (prev.includes(city)) return prev;
            return [...prev, city];
        });
    };

    const removeFavorite = (city: string) => {
        setFavorites(prev => prev.filter(fav => fav !== city));
    };

    const isFavorite = (city: string): boolean => {
        return favorites.includes(city);
    };

    return {
        favorites,
        addFavorite,
        removeFavorite,
        isFavorite
    };
}

Hook 4: useGeolocation

// src/hooks/useGeolocation.ts
import { useState } from 'react';

interface Coordinates {
    latitude: number;
    longitude: number;
}

interface UseGeolocationReturn {
    coordinates: Coordinates | null;
    loading: boolean;
    error: string | null;
    getCurrentPosition: () => void;
}

export function useGeolocation(): UseGeolocationReturn {
    const [coordinates, setCoordinates] = useState<Coordinates | null>(null);
    const [loading, setLoading] = useState(false);
    const [error, setError] = useState<string | null>(null);

    const getCurrentPosition = () => {
        if (!navigator.geolocation) {
            setError('Geolocation is not supported by your browser');
            return;
        }

        setLoading(true);
        setError(null);

        navigator.geolocation.getCurrentPosition(
            (position) => {
                setCoordinates({
                    latitude: position.coords.latitude,
                    longitude: position.coords.longitude
                });
                setLoading(false);
            },
            (error) => {
                setError('Failed to get your location. Please check permissions.');
                setLoading(false);
            }
        );
    };

    return {
        coordinates,
        loading,
        error,
        getCurrentPosition
    };
}

πŸ’‘ Custom Hooks Benefits

  • useDebounce - Delays search queries until user stops typing
  • useWeather - Encapsulates all weather fetching logic
  • useFavorites - Manages favorite cities with localStorage
  • useGeolocation - Gets user's current location

βœ… Checkpoint

At this point, you should have:

  • βœ… useDebounce hook for delaying search input
  • βœ… useWeather hook for fetching weather data
  • βœ… useFavorites hook for managing favorite cities
  • βœ… useGeolocation hook for getting user location

All the hooks are ready! Time to build UI components. 🎨

🎨 Building Components

Now that we have our custom hooks ready, let's build the UI components that will bring our weather dashboard to life. We'll create reusable, type-safe components that handle different parts of the interface.

Component Architecture

Our dashboard will be composed of several components, each with a specific responsibility:

graph TD A[App] --> B[SearchBar] A --> C[WeatherCard] A --> D[ForecastList] A --> E[FavoritesList] D --> F[ForecastCard] E --> G[FavoriteItem] style A fill:#667eea,stroke:#333,stroke-width:2px,color:#fff style B fill:#48bb78,stroke:#333,stroke-width:2px style C fill:#48bb78,stroke:#333,stroke-width:2px style D fill:#48bb78,stroke:#333,stroke-width:2px style E fill:#48bb78,stroke:#333,stroke-width:2px

πŸ’‘ Component Responsibilities

  • SearchBar - City search with debouncing
  • WeatherCard - Display current weather details
  • ForecastCard - Single day forecast item
  • ForecastList - Container for 5-day forecast
  • FavoritesList - Manage favorite cities

πŸ” SearchBar Component

The SearchBar component handles city search with debounced input, providing real-time feedback while the user types. It uses our useDebounce hook to optimize API calls.

Create SearchBar.tsx

// src/components/SearchBar.tsx
import { useState, useEffect, type FormEvent } from 'react';
import { useDebounce } from '../hooks/useDebounce';

interface SearchBarProps {
    onSearch: (city: string) => void;
    isLoading?: boolean;
}

function SearchBar({ onSearch, isLoading = false }: SearchBarProps) {
    const [inputValue, setInputValue] = useState('');
    const debouncedValue = useDebounce(inputValue, 500);

    // Trigger search when debounced value changes
    useEffect(() => {
        if (debouncedValue.trim()) {
            onSearch(debouncedValue);
        }
    }, [debouncedValue, onSearch]);

    const handleSubmit = (e: FormEvent) => {
        e.preventDefault();
        if (inputValue.trim()) {
            onSearch(inputValue);
        }
    };

    return (
        <form onSubmit={handleSubmit} className="search-bar">
            <div className="search-input-wrapper">
                <input
                    type="text"
                    value={inputValue}
                    onChange={(e) => setInputValue(e.target.value)}
                    placeholder="Search for a city..."
                    className="search-input"
                    disabled={isLoading}
                    aria-label="City search"
                />
                {isLoading && (
                    <span className="search-spinner" aria-label="Searching">
                        πŸ”„
                    </span>
                )}
            </div>
            <button 
                type="submit" 
                className="search-button"
                disabled={isLoading || !inputValue.trim()}
            >
                πŸ” Search
            </button>
        </form>
    );
};

export default SearchBar;

βœ… SearchBar Features

  • Debounced Input - Waits 500ms after typing stops before searching
  • Manual Search - Submit button for immediate search
  • Loading State - Shows spinner and disables input during search
  • Validation - Button disabled when input is empty
  • Accessibility - Proper ARIA labels

🌑️ WeatherCard Component

The WeatherCard displays the current weather for a city, showing temperature, conditions, humidity, wind speed, and more. It uses conditional rendering to show different icons based on weather conditions.

Create WeatherCard.tsx

// src/components/WeatherCard.tsx
import type { WeatherData } from '../types/weather';

interface WeatherCardProps {
    weather: WeatherData;
    onAddToFavorites?: (city: string) => void;
    isFavorite?: boolean;
}

function WeatherCard({ weather, onAddToFavorites, isFavorite = false }: WeatherCardProps) {
    // Get weather emoji based on condition
    const getWeatherEmoji = (condition: string): string => {
        const lowerCondition = condition.toLowerCase();
        if (lowerCondition.includes('clear')) return 'β˜€οΈ';
        if (lowerCondition.includes('cloud')) return '☁️';
        if (lowerCondition.includes('rain')) return '🌧️';
        if (lowerCondition.includes('snow')) return '❄️';
        if (lowerCondition.includes('thunder')) return 'β›ˆοΈ';
        if (lowerCondition.includes('mist') || lowerCondition.includes('fog')) return '🌫️';
        return '🌀️';
    };

    // Format temperature
    const formatTemp = (temp: number): string => {
        return `${Math.round(temp)}Β°C`;
    };

    // Format time from timestamp
    const formatTime = (timestamp: number): string => {
        return new Date(timestamp * 1000).toLocaleTimeString('en-US', {
            hour: 'numeric',
            minute: '2-digit'
        });
    };

    return (
        <div className="weather-card">
            {/* Header with city name and favorite button */}
            <div className="weather-header">
                <h2>{weather.name}, {weather.sys.country}</h2>
                {onAddToFavorites && (
                    <button
                        onClick={() => onAddToFavorites(weather.name)}
                        className="favorite-button"
                        aria-label={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
                    >
                        {isFavorite ? '⭐' : 'β˜†'}
                    </button>
                )}
            </div>

            {/* Current time */}
            <p className="weather-time">
                πŸ• {new Date().toLocaleDateString('en-US', {
                    weekday: 'long',
                    year: 'numeric',
                    month: 'long',
                    day: 'numeric'
                })}
            </p>

            {/* Main weather display */}
            <div className="weather-main">
                <div className="weather-icon">
                    {getWeatherEmoji(weather.weather[0].main)}
                </div>
                <div className="weather-temp">
                    <span className="temp-value">{formatTemp(weather.main.temp)}</span>
                    <span className="feels-like">
                        Feels like {formatTemp(weather.main.feels_like)}
                    </span>
                </div>
                <div className="weather-description">
                    {weather.weather[0].description}
                </div>
            </div>

            {/* Weather details grid */}
            <div className="weather-details">
                <div className="detail-item">
                    <span className="detail-label">πŸ’§ Humidity</span>
                    <span className="detail-value">{weather.main.humidity}%</span>
                </div>

                <div className="detail-item">
                    <span className="detail-label">🌬️ Wind Speed</span>
                    <span className="detail-value">{weather.wind.speed} m/s</span>
                </div>

                <div className="detail-item">
                    <span className="detail-label">🎯 Pressure</span>
                    <span className="detail-value">{weather.main.pressure} hPa</span>
                </div>

                {/* `visibility` is optional in the API response, so guard it */}
                {typeof weather.visibility === 'number' && (
                    <div className="detail-item">
                        <span className="detail-label">πŸ‘οΈ Visibility</span>
                        <span className="detail-value">{(weather.visibility / 1000).toFixed(1)} km</span>
                    </div>
                )}

                <div className="detail-item">
                    <span className="detail-label">πŸŒ… Sunrise</span>
                    <span className="detail-value">{formatTime(weather.sys.sunrise)}</span>
                </div>

                <div className="detail-item">
                    <span className="detail-label">πŸŒ‡ Sunset</span>
                    <span className="detail-value">{formatTime(weather.sys.sunset)}</span>
                </div>
            </div>

            {/* Min/Max temperatures */}
            <div className="weather-minmax">
                <span>πŸ”» Min: {formatTemp(weather.main.temp_min)}</span>
                <span>πŸ”Ί Max: {formatTemp(weather.main.temp_max)}</span>
            </div>
        </div>
    );
};

export default WeatherCard;

πŸ’‘ WeatherCard Features

  • Dynamic Icons - Shows different emoji based on weather condition
  • Comprehensive Data - Temperature, humidity, wind, pressure, visibility
  • Sunrise/Sunset Times - Formatted local times
  • Favorites Integration - Add/remove from favorites
  • Temperature Formatting - Rounded to nearest degree
  • Feels Like - Shows perceived temperature

πŸ“… ForecastCard Component

The ForecastCard displays weather information for a single day in the forecast. It shows the date, high/low temperatures, and weather conditions.

Create ForecastCard.tsx

// src/components/ForecastCard.tsx
import { getWeatherEmoji } from '../utils/weatherIcons';
import { formatDate } from '../utils/formatters';
import type { DailyForecast } from '../types/weather';

interface ForecastCardProps {
    forecast: DailyForecast;
}

// This card renders one already-processed day. All the grouping/averaging work
// happened once in weatherService.processForecastData β€” the component just displays.
function ForecastCard({ forecast }: ForecastCardProps) {
    return (
        <div className="forecast-card">
            <div className="forecast-date">{formatDate(forecast.date)}</div>

            {/* icon is the OpenWeatherMap code (e.g. "10d"); map it to an emoji */}
            <div className="forecast-icon">{getWeatherEmoji(forecast.icon)}</div>

            <div className="forecast-temps">
                <span className="temp-high">{Math.round(forecast.tempMax)}Β°</span>
                <span className="temp-low">{Math.round(forecast.tempMin)}Β°</span>
            </div>

            <div className="forecast-description">{forecast.description}</div>

            <div className="forecast-details">
                <small>πŸ’§ {forecast.humidity}%</small>
                <small>🌬️ {forecast.windSpeed} m/s</small>
            </div>
        </div>
    );
}

export default ForecastCard;

πŸ“‹ ForecastList Component

The ForecastList component organizes and displays multiple ForecastCard components in a scrollable container.

Create ForecastList.tsx

// src/components/ForecastList.tsx
import ForecastCard from './ForecastCard';
import type { DailyForecast } from '../types/weather';

interface ForecastListProps {
    forecast: DailyForecast[];
}

// The list is purely presentational: it receives the 5 already-computed days
// from the hook and lays them out. No data massaging happens here.
function ForecastList({ forecast }: ForecastListProps) {
    if (forecast.length === 0) return null;

    return (
        <div className="forecast-list">
            <h3 className="forecast-title">πŸ“… 5-Day Forecast</h3>
            <div className="forecast-grid">
                {forecast.map((day) => (
                    <ForecastCard key={day.date} forecast={day} />
                ))}
            </div>
        </div>
    );
}

export default ForecastList;

⚠️ Forecast Data Processing

The OpenWeatherMap 5-day forecast API returns data in 3-hour intervals β€” about 40 data points. We collapse those into one entry per day (preferring the midday reading) inside weatherService.processForecastData, so this component receives a clean DailyForecast[] and never has to think about raw slices. Doing the transformation in one place keeps every component that shows forecast data consistent.

⭐ FavoritesList Component

The FavoritesList component displays the user's saved favorite cities and allows them to quickly load weather for any favorite.

Create FavoritesList.tsx

// src/components/FavoritesList.tsx

interface FavoritesListProps {
    favorites: string[];
    onSelectCity: (city: string) => void;
    onRemoveFavorite: (city: string) => void;
    currentCity?: string;
}

function FavoritesList({
    favorites,
    onSelectCity,
    onRemoveFavorite,
    currentCity
}: FavoritesListProps) {
    if (favorites.length === 0) {
        return (
            <div className="favorites-list empty">
                <p>⭐ No favorite cities yet.</p>
                <p className="favorites-hint">
                    Search for a city and click the star to add it to your favorites!
                </p>
            </div>
        );
    }

    return (
        <div className="favorites-list">
            <h3 className="favorites-title">⭐ Favorite Cities</h3>
            <div className="favorites-grid">
                {favorites.map((city) => (
                    <div 
                        key={city} 
                        className={`favorite-item ${currentCity === city ? 'active' : ''}`}
                    >
                        <button
                            onClick={() => onSelectCity(city)}
                            className="favorite-button"
                            aria-label={`View weather for ${city}`}
                        >
                            πŸ“ {city}
                        </button>
                        <button
                            onClick={(e) => {
                                e.stopPropagation();
                                onRemoveFavorite(city);
                            }}
                            className="remove-favorite"
                            aria-label={`Remove ${city} from favorites`}
                        >
                            βœ•
                        </button>
                    </div>
                ))}
            </div>
        </div>
    );
};

export default FavoritesList;

βœ… FavoritesList Features

  • Empty State - Helpful message when no favorites
  • Quick Access - Click any favorite to load its weather
  • Remove Button - Delete favorites with X button
  • Active State - Highlights currently selected city
  • Event Propagation - stopPropagation prevents conflicts

πŸ”„ Loading and Error Components

Create reusable components for loading states and error messages to provide better user feedback.

Create LoadingSpinner.tsx

// src/components/LoadingSpinner.tsx

interface LoadingSpinnerProps {
    message?: string;
}

function LoadingSpinner({ message = 'Loading...' }: LoadingSpinnerProps) {
    return (
        <div className="loading-spinner" role="status" aria-live="polite">
            <div className="spinner-icon">πŸ”„</div>
            <p className="loading-message">{message}</p>
        </div>
    );
};

export default LoadingSpinner;

Create ErrorMessage.tsx

// src/components/ErrorMessage.tsx

interface ErrorMessageProps {
    message: string;
    onRetry?: () => void;
}

function ErrorMessage({ message, onRetry }: ErrorMessageProps) {
    return (
        <div className="error-message" role="alert">
            <div className="error-icon">❌</div>
            <h3>Oops! Something went wrong</h3>
            <p>{message}</p>
            {onRetry && (
                <button onClick={onRetry} className="retry-button">
                    πŸ”„ Try Again
                </button>
            )}
        </div>
    );
};

export default ErrorMessage;

🎨 Styling the Components

Let's add CSS styles for all our components. Create a comprehensive stylesheet that makes our dashboard look professional and responsive.

Create App.css

/* src/App.css */

/* ===== Global Styles ===== */
* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 
                 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 
                 'Helvetica Neue', sans-serif;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
    min-height: 100vh;
    padding: 2rem;
}

.app-container {
    max-width: 1200px;
    margin: 0 auto;
}

/* ===== Header ===== */
.app-header {
    text-align: center;
    color: white;
    margin-bottom: 2rem;
}

.app-header h1 {
    font-size: 2.5rem;
    margin-bottom: 0.5rem;
    text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.2);
}

.app-header p {
    font-size: 1.1rem;
    opacity: 0.9;
}

/* ===== Search Bar ===== */
.search-bar {
    display: flex;
    gap: 1rem;
    margin-bottom: 2rem;
    max-width: 600px;
    margin-left: auto;
    margin-right: auto;
}

.search-input-wrapper {
    flex: 1;
    position: relative;
}

.search-input {
    width: 100%;
    padding: 1rem 3rem 1rem 1rem;
    font-size: 1rem;
    border: none;
    border-radius: 8px;
    box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}

.search-input:focus {
    outline: 2px solid #667eea;
    outline-offset: 2px;
}

.search-spinner {
    position: absolute;
    right: 1rem;
    top: 50%;
    transform: translateY(-50%);
    animation: spin 1s linear infinite;
}

@keyframes spin {
    from { transform: translateY(-50%) rotate(0deg); }
    to { transform: translateY(-50%) rotate(360deg); }
}

.search-button {
    padding: 1rem 2rem;
    background: white;
    border: none;
    border-radius: 8px;
    font-size: 1rem;
    font-weight: 600;
    cursor: pointer;
    box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
    transition: all 0.2s;
}

.search-button:hover:not(:disabled) {
    transform: translateY(-2px);
    box-shadow: 0 6px 8px rgba(0, 0, 0, 0.15);
}

.search-button:disabled {
    opacity: 0.5;
    cursor: not-allowed;
}

/* ===== Weather Card ===== */
.weather-card {
    background: white;
    border-radius: 16px;
    padding: 2rem;
    box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
    margin-bottom: 2rem;
}

.weather-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 1rem;
}

.weather-header h2 {
    font-size: 1.8rem;
    color: #333;
}

.favorite-button {
    background: none;
    border: none;
    font-size: 2rem;
    cursor: pointer;
    transition: transform 0.2s;
}

.favorite-button:hover {
    transform: scale(1.2);
}

.weather-time {
    color: #666;
    margin-bottom: 2rem;
}

.weather-main {
    display: grid;
    grid-template-columns: auto 1fr;
    gap: 2rem;
    align-items: center;
    margin-bottom: 2rem;
    padding: 2rem;
    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
    border-radius: 12px;
    color: white;
}

.weather-icon {
    font-size: 5rem;
}

.weather-temp {
    display: flex;
    flex-direction: column;
}

.temp-value {
    font-size: 4rem;
    font-weight: 300;
}

.feels-like {
    font-size: 1rem;
    opacity: 0.9;
}

.weather-description {
    grid-column: 1 / -1;
    font-size: 1.3rem;
    text-transform: capitalize;
    text-align: center;
}

.weather-details {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
    gap: 1rem;
    margin-bottom: 1rem;
}

.detail-item {
    display: flex;
    flex-direction: column;
    padding: 1rem;
    background: #f8f9fa;
    border-radius: 8px;
}

.detail-label {
    font-size: 0.9rem;
    color: #666;
    margin-bottom: 0.5rem;
}

.detail-value {
    font-size: 1.2rem;
    font-weight: 600;
    color: #333;
}

.weather-minmax {
    display: flex;
    justify-content: space-around;
    padding: 1rem;
    background: #f8f9fa;
    border-radius: 8px;
    font-weight: 600;
}

/* ===== Forecast List ===== */
.forecast-list {
    background: white;
    border-radius: 16px;
    padding: 2rem;
    box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
    margin-bottom: 2rem;
}

.forecast-title {
    font-size: 1.5rem;
    margin-bottom: 1.5rem;
    color: #333;
}

.forecast-grid {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
    gap: 1rem;
}

/* ===== Forecast Card ===== */
.forecast-card {
    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
    color: white;
    padding: 1.5rem;
    border-radius: 12px;
    text-align: center;
    transition: transform 0.2s;
}

.forecast-card:hover {
    transform: translateY(-4px);
}

.forecast-date {
    font-weight: 600;
    margin-bottom: 1rem;
    font-size: 0.9rem;
}

.forecast-icon {
    font-size: 3rem;
    margin-bottom: 1rem;
}

.forecast-temps {
    display: flex;
    justify-content: center;
    gap: 1rem;
    margin-bottom: 0.5rem;
    font-size: 1.3rem;
    font-weight: 600;
}

.temp-high {
    color: #fff;
}

.temp-low {
    opacity: 0.8;
}

.forecast-description {
    text-transform: capitalize;
    margin-bottom: 1rem;
    font-size: 0.9rem;
}

.forecast-details {
    display: flex;
    justify-content: space-around;
    font-size: 0.8rem;
    opacity: 0.9;
}

/* ===== Favorites List ===== */
.favorites-list {
    background: white;
    border-radius: 16px;
    padding: 2rem;
    box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
    margin-bottom: 2rem;
}

.favorites-list.empty {
    text-align: center;
    padding: 3rem 2rem;
    color: #666;
}

.favorites-hint {
    font-size: 0.9rem;
    margin-top: 0.5rem;
}

.favorites-title {
    font-size: 1.5rem;
    margin-bottom: 1.5rem;
    color: #333;
}

.favorites-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
    gap: 1rem;
}

.favorite-item {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    padding: 0.75rem 1rem;
    background: #f8f9fa;
    border-radius: 8px;
    transition: all 0.2s;
}

.favorite-item.active {
    background: #667eea;
    color: white;
}

.favorite-item .favorite-button {
    flex: 1;
    background: none;
    border: none;
    text-align: left;
    font-size: 1rem;
    cursor: pointer;
    color: inherit;
}

.remove-favorite {
    background: rgba(255, 0, 0, 0.1);
    border: none;
    width: 24px;
    height: 24px;
    border-radius: 50%;
    cursor: pointer;
    font-size: 0.8rem;
    color: #dc3545;
    transition: all 0.2s;
}

.remove-favorite:hover {
    background: #dc3545;
    color: white;
}

/* ===== Loading Spinner ===== */
.loading-spinner {
    text-align: center;
    padding: 4rem 2rem;
    background: white;
    border-radius: 16px;
    box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
}

.spinner-icon {
    font-size: 4rem;
    margin-bottom: 1rem;
    animation: spin 1s linear infinite;
}

.loading-message {
    font-size: 1.2rem;
    color: #666;
}

/* ===== Error Message ===== */
.error-message {
    text-align: center;
    padding: 3rem 2rem;
    background: white;
    border-radius: 16px;
    box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
}

.error-icon {
    font-size: 4rem;
    margin-bottom: 1rem;
}

.error-message h3 {
    color: #dc3545;
    margin-bottom: 1rem;
}

.error-message p {
    color: #666;
    margin-bottom: 1.5rem;
}

.retry-button {
    padding: 0.75rem 2rem;
    background: #667eea;
    color: white;
    border: none;
    border-radius: 8px;
    font-size: 1rem;
    cursor: pointer;
    transition: all 0.2s;
}

.retry-button:hover {
    background: #5568d3;
    transform: translateY(-2px);
}

/* ===== Responsive Design ===== */
@media (max-width: 768px) {
    body {
        padding: 1rem;
    }

    .app-header h1 {
        font-size: 2rem;
    }

    .search-bar {
        flex-direction: column;
    }

    .weather-main {
        grid-template-columns: 1fr;
        text-align: center;
    }

    .weather-icon {
        font-size: 4rem;
    }

    .temp-value {
        font-size: 3rem;
    }

    .weather-details {
        grid-template-columns: repeat(2, 1fr);
    }

    .forecast-grid {
        grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
    }

    .favorites-grid {
        grid-template-columns: 1fr;
    }
}

βœ… Component Checklist

You should now have created:

  • βœ… SearchBar.tsx - City search with debouncing
  • βœ… WeatherCard.tsx - Current weather display
  • βœ… ForecastCard.tsx - Single day forecast
  • βœ… ForecastList.tsx - 5-day forecast container
  • βœ… FavoritesList.tsx - Favorite cities management
  • βœ… LoadingSpinner.tsx - Loading state
  • βœ… ErrorMessage.tsx - Error handling
  • βœ… App.css - Complete styling

πŸ’‘ Design Principles Applied

  • Component Composition - Small, focused components
  • Props Interface - Clear TypeScript interfaces
  • Conditional Rendering - Show/hide based on state
  • Event Handling - User interactions
  • Accessibility - ARIA labels and semantic HTML
  • Responsive Design - Mobile-first approach

πŸ—οΈ Building the Main App Component

Now it's time to bring everything together! We'll create the main App component that orchestrates all our custom hooks and components, managing the application state and coordinating data flow between all parts of our weather dashboard.

App Component Overview

The App component will be the brain of our application. It will:

πŸ’‘ App Component Responsibilities

  • πŸ”„ Manage overall application state
  • 🌐 Coordinate API calls using custom hooks
  • 🎯 Handle user interactions (search, favorites, geolocation)
  • πŸ“Š Pass data to child components via props
  • ⚠️ Manage global loading and error states
  • πŸ’Ύ Sync favorites with localStorage

Creating App.tsx

Let's build the main App component that ties everything together:

// src/App.tsx
import { useState, useCallback, useEffect } from 'react';
import SearchBar from './components/SearchBar';
import WeatherCard from './components/WeatherCard';
import ForecastList from './components/ForecastList';
import FavoritesList from './components/FavoritesList';
import LoadingSpinner from './components/LoadingSpinner';
import ErrorMessage from './components/ErrorMessage';
import { useWeather } from './hooks/useWeather';
import { useFavorites } from './hooks/useFavorites';
import { useGeolocation } from './hooks/useGeolocation';
import './App.css';

function App() {
    const [selectedCity, setSelectedCity] = useState('London');

    // The single source of truth for weather is the discriminated-union `state`.
    const { state, fetchWeather, fetchWeatherByCoords } = useWeather();
    const { favorites, addFavorite, removeFavorite, isFavorite } = useFavorites();
    const {
        coordinates,
        loading: geoLoading,
        error: geoError,
        getCurrentPosition
    } = useGeolocation();

    // Fetch whenever the selected city changes (also runs once on mount).
    useEffect(() => {
        if (selectedCity) fetchWeather(selectedCity);
    }, [selectedCity, fetchWeather]);

    // When geolocation returns coordinates, fetch weather for that point.
    useEffect(() => {
        if (coordinates) {
            fetchWeatherByCoords(coordinates.latitude, coordinates.longitude);
        }
    }, [coordinates, fetchWeatherByCoords]);

    const handleSearch = useCallback((city: string) => {
        setSelectedCity(city);
    }, []);

    const handleToggleFavorite = useCallback((city: string) => {
        if (isFavorite(city)) {
            removeFavorite(city);
        } else {
            addFavorite(city);
        }
    }, [isFavorite, addFavorite, removeFavorite]);

    const handleRetry = useCallback(() => {
        if (selectedCity) fetchWeather(selectedCity);
    }, [selectedCity, fetchWeather]);

    return (
        <div className="app-container">
            <header className="app-header">
                <h1>🌀️ Weather Dashboard</h1>
                <p>Get real-time weather information for cities worldwide</p>
            </header>

            <SearchBar onSearch={handleSearch} isLoading={state.status === 'loading'} />

            {/* Geolocation button */}
            <div style={{ textAlign: 'center', marginBottom: '2rem' }}>
                <button
                    onClick={getCurrentPosition}
                    disabled={geoLoading}
                    className="location-button"
                    style={{
                        padding: '0.75rem 1.5rem',
                        background: 'white',
                        border: 'none',
                        borderRadius: '8px',
                        fontSize: '1rem',
                        cursor: 'pointer',
                        boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
                        transition: 'all 0.2s'
                    }}
                >
                    πŸ“ {geoLoading ? 'Getting location...' : 'Use My Location'}
                </button>
                {geoError && (
                    <p style={{ color: '#dc3545', marginTop: '0.5rem' }}>{geoError}</p>
                )}
            </div>

            {/* Render exactly one branch of the state machine */}
            {state.status === 'loading' && (
                <LoadingSpinner message="Fetching weather data..." />
            )}

            {state.status === 'error' && (
                <ErrorMessage message={state.error} onRetry={handleRetry} />
            )}

            {state.status === 'success' && (
                <>
                    {/* Inside this branch TypeScript KNOWS state.weather exists */}
                    <WeatherCard
                        weather={state.weather}
                        onAddToFavorites={handleToggleFavorite}
                        isFavorite={isFavorite(state.weather.name)}
                    />

                    <ForecastList forecast={state.forecast} />

                    <FavoritesList
                        favorites={favorites}
                        onSelectCity={handleSearch}
                        onRemoveFavorite={removeFavorite}
                        currentCity={state.weather.name}
                    />
                </>
            )}
        </div>
    );
}

export default App;

βœ… App Component Features

  • State Management - Tracks selected city
  • Hook Integration - Uses all custom hooks
  • Auto-fetch - Loads weather when city changes
  • Conditional Rendering - Shows loading/error/content states
  • Event Handlers - Memoized with useCallback
  • Geolocation Support - Get weather for current location

🎯 Understanding the Data Flow

Here's how data flows through our application:

sequenceDiagram participant User participant App participant Hooks participant API participant Components User->>App: Enter city name App->>Hooks: Call fetchWeather(city) Hooks->>API: HTTP Request API-->>Hooks: Weather data Hooks-->>App: Update state App->>Components: Pass data via props Components-->>User: Display weather

πŸ”§ Setting Up the Entry Point

Now let's set up the main entry point for our React application:

Update src/main.tsx

// src/main.tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import './index.css';

const rootElement = document.getElementById('root');
if (!rootElement) throw new Error('Root element #root not found in index.html');

createRoot(rootElement).render(
    <StrictMode>
        <App />
    </StrictMode>
);

Create src/index.css

/* src/index.css */
* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
    margin: 0;
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 
                 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 
                 'Helvetica Neue', sans-serif;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
}

code {
    font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
                 monospace;
}

#root {
    min-height: 100vh;
}

πŸ“ Environment Variables Setup

Create a .env file in your project root to store your API key securely:

# .env
VITE_WEATHER_API_KEY=your_api_key_here

πŸ’‘ StrictMode runs your effects twice (on purpose)

In development, React 19's <StrictMode> intentionally mounts, unmounts, and remounts each component to surface missing cleanup. That means your useEffect fires twice and fires two fetches. This is exactly why useWeather aborts the previous request and cleans up on unmount β€” with that in place, the double-invoke is harmless and you'll see only the correct final result.

⚠️ Important: API Key Security

  • Never commit your .env file to version control
  • Add .env to your .gitignore file
  • Use environment variables for sensitive data
  • For production, use backend proxy to hide API keys

Update .gitignore

# .gitignore
# dependencies
node_modules
/.pnp
.pnp.js

# testing
/coverage

# production
/build
/dist

# environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local

# misc
.DS_Store
*.log

# IDE
.vscode
.idea

πŸš€ Running Your Application

Now that everything is set up, let's run the application:

# Install dependencies (if not already done)
npm install

# Start the development server
npm run dev

# Your app should open at http://localhost:5173

βœ… Testing Your App

Follow these steps to verify everything works correctly:

βœ… Testing Checklist

  1. Initial Load
    • App loads with default city (London)
    • Current weather displays correctly
    • 5-day forecast shows up
  2. Search Functionality
    • Type a city name (e.g., "Tokyo")
    • Weather updates after 500ms delay
    • Loading spinner shows during fetch
  3. Favorites
    • Click star to add city to favorites
    • Favorites persist after page refresh
    • Click favorite to load that city
    • Click X to remove from favorites
  4. Geolocation
    • Click "Use My Location" button
    • Browser asks for location permission
    • Weather loads for current location
  5. Error Handling
    • Search for invalid city (e.g., "asdfgh")
    • Error message displays
    • Click "Try Again" to retry
  6. Responsive Design
    • Resize browser window
    • Layout adapts to different screen sizes
    • Test on mobile device

πŸ› Common Issues and Solutions

Issue #1: "API key not valid"

Solution:

  • Verify your API key is correct in .env file
  • Check that the variable name is exactly VITE_WEATHER_API_KEY (Vite only exposes vars prefixed with VITE_)
  • Restart the development server after changing .env
  • Wait a few minutes for new API keys to activate

Issue #2: "City not found"

Solution:

  • Check spelling of city name
  • Try adding country code (e.g., "Paris,FR")
  • Use major cities that are definitely in the database

Issue #3: Geolocation not working

Solution:

  • Ensure you're using HTTPS or localhost
  • Check browser location permissions
  • Some browsers block location on insecure connections

Issue #4: Favorites not persisting

Solution:

  • Check browser's localStorage is enabled
  • Check browser console for errors
  • Clear localStorage and try again
  • Test in different browser

🎨 Optional: Adding Polish

Here are some nice-to-have improvements you can add:

1. Auto-refresh Weather Data

// In App.tsx, add this useEffect
useEffect(() => {
    // Refresh weather every 10 minutes
    const interval = setInterval(() => {
        if (selectedCity) {
            fetchWeather(selectedCity);
        }
    }, 10 * 60 * 1000); // 10 minutes

    return () => clearInterval(interval);
}, [selectedCity, fetchWeather]);

2. Temperature Unit Toggle

// Add state for temperature unit
const [unit, setUnit] = useState<'metric' | 'imperial'>('metric');

// Add toggle button
<button onClick={() => setUnit(unit === 'metric' ? 'imperial' : 'metric')}>
    {unit === 'metric' ? 'Β°C' : 'Β°F'}
</button>

// To make this real, thread `unit` into the service's URL builder
// (units: unit) and re-fetch when it changes:
useEffect(() => {
    if (selectedCity) fetchWeather(selectedCity);
}, [selectedCity, unit, fetchWeather]);

3. Weather Alerts

// Inside the state.status === 'success' branch, `state.weather` is available:
{state.weather.main.temp > 35 && (
    <div className="alert alert-danger">
        πŸ”₯ Extreme Heat Warning!
    </div>
)}

{state.weather.main.temp < 0 && (
    <div className="alert alert-info">
        ❄️ Freezing Temperature Alert!
    </div>
)}

4. Loading Skeleton

// Create a skeleton component for better loading UX
const WeatherSkeleton = () => (
    <div className="weather-card skeleton">
        <div className="skeleton-header"></div>
        <div className="skeleton-main"></div>
        <div className="skeleton-details"></div>
    </div>
);

// Use it instead of LoadingSpinner
{loading && <WeatherSkeleton />}

βœ… Section 8 Complete!

You've now built the complete main App component! Your weather dashboard should be fully functional with:

  • βœ… Full application orchestration
  • βœ… All components integrated
  • βœ… Custom hooks working together
  • βœ… Search, favorites, and geolocation
  • βœ… Error handling and loading states
  • βœ… Responsive design

πŸ§ͺ Testing & Polish

Before we deploy, let's make sure everything works perfectly and add some final polish to our application. Testing ensures reliability, and polish makes the user experience delightful.

Manual Testing Guide

Follow this comprehensive testing checklist to verify all functionality:

🎯 Functional Testing

Search Feature
Test Case Expected Result Pass
Search for valid city (e.g., "Paris") Weather data loads correctly ☐
Search for invalid city Error message displays ☐
Type slowly (debouncing) Search waits 500ms before fetching ☐
Submit with Enter key Search triggers immediately ☐
Favorites Feature
Test Case Expected Result Pass
Click star to add favorite City appears in favorites list ☐
Refresh page Favorites persist ☐
Click favorite city Weather loads for that city ☐
Click X to remove City removed from favorites ☐
Geolocation Feature
Test Case Expected Result Pass
Click "Use My Location" Browser asks for permission ☐
Allow location access Weather loads for current location ☐
Deny location access Error message displays ☐

🎨 UI/UX Testing

Visual Testing Checklist

  • ☐ All weather icons display correctly
  • ☐ Temperature values are properly formatted
  • ☐ Colors and gradients render smoothly
  • ☐ Loading spinner animates properly
  • ☐ Hover effects work on interactive elements
  • ☐ No layout shifts or flickering
  • ☐ Text is readable on all backgrounds
  • ☐ Cards have proper spacing and alignment

πŸ“± Responsive Design Testing

Device Testing

Device/Size Checks Pass
Mobile (320-480px) Single column layout, stacked elements ☐
Tablet (768-1024px) 2-3 column grids, proper spacing ☐
Desktop (1024px+) Full grid layout, max width applied ☐

β™Ώ Accessibility Testing

Accessibility Checklist

  • ☐ All interactive elements are keyboard accessible (Tab navigation)
  • ☐ ARIA labels present on icon buttons
  • ☐ Loading states announced to screen readers (aria-live)
  • ☐ Error messages have proper role="alert"
  • ☐ Color contrast meets WCAG AA standards (4.5:1)
  • ☐ Focus indicators visible on all interactive elements
  • ☐ Form inputs have associated labels

⚑ Performance Testing

Performance Checks

  • ☐ Initial page load under 2 seconds
  • ☐ API calls are debounced (no excessive requests)
  • ☐ No memory leaks (check DevTools Memory tab)
  • ☐ Images and assets optimized
  • ☐ Console has no errors or warnings
  • ☐ Cache working properly (repeated city searches faster)

πŸ’‘ Performance Tips

  • Check Network Tab - Verify API calls aren't duplicated
  • Use React DevTools - Identify unnecessary re-renders
  • Test on Slow 3G - See how app performs on slow connections
  • Monitor localStorage - Ensure it's not growing too large

πŸ” Browser Compatibility

Test Across Browsers

  • ☐ Chrome (latest)
  • ☐ Firefox (latest)
  • ☐ Safari (latest)
  • ☐ Edge (latest)
  • ☐ Mobile browsers (iOS Safari, Chrome Mobile)

βœ… Testing Complete!

Once you've completed all these tests and everything passes, your weather dashboard is ready for deployment! πŸŽ‰

πŸš€ Deployment

Now that your weather dashboard is tested and polished, it's time to share it with the world! We'll deploy our application to a production environment where anyone can access it. We'll cover multiple deployment options, from the easiest to more advanced setups.

Deployment Options Overview

πŸ’‘ Popular Hosting Platforms

Platform Difficulty Free Tier Best For
Vercel ⭐ Easy βœ… Yes React apps, fast deployment
Netlify ⭐ Easy βœ… Yes Static sites, continuous deployment
GitHub Pages ⭐⭐ Medium βœ… Yes Public repos, GitHub integration
AWS Amplify ⭐⭐⭐ Advanced βœ… Limited Scalable apps, AWS ecosystem

🎯 Deployment with Vercel (Recommended)

Vercel is the easiest and fastest way to deploy React applications. It's created by the makers of Next.js and offers automatic deployments from Git repositories.

Step 1: Prepare Your Repository

# Initialize git repository (if not already done)
git init

# Add all files
git add .

# Commit changes
git commit -m "Initial commit: Weather Dashboard"

# Create GitHub repository and push
# (Create repo on GitHub first, then:)
git remote add origin https://github.com/yourusername/weather-dashboard.git
git branch -M main
git push -u origin main

Step 2: Configure Environment Variables

Before deploying, make sure your build works with environment variables:

// We already centralised this in src/config/env.ts, which throws on a missing key.
// The same value is read via Vite's typed env:
const API_KEY = import.meta.env.VITE_WEATHER_API_KEY;

if (!API_KEY) {
    console.error('API key is missing! Please set VITE_WEATHER_API_KEY');
}

Step 3: Deploy to Vercel

βœ… Vercel Deployment Steps

  1. Sign up - Visit vercel.com and sign up with GitHub
  2. Import Project - Click "New Project" and select your repository
  3. Configure Build Settings:
    • Framework Preset: Vite
    • Build Command: npm run build
    • Output Directory: dist
  4. Add Environment Variables:
    • Key: VITE_WEATHER_API_KEY
    • Value: Your API key
  5. Deploy - Click "Deploy" and wait ~1 minute
  6. Done! - Your app is live at your-app.vercel.app
graph LR A[Push to GitHub] --> B[Vercel Detects Change] B --> C[Build Application] C --> D[Run Tests] D --> E[Deploy to CDN] E --> F[Live at URL] style A fill:#667eea,stroke:#333,stroke-width:2px,color:#fff style F fill:#48bb78,stroke:#333,stroke-width:2px,color:#fff

Step 4: Custom Domain (Optional)

# In Vercel dashboard:
# 1. Go to Project Settings β†’ Domains
# 2. Add your custom domain (e.g., weather.yourdomain.com)
# 3. Follow DNS configuration instructions
# 4. Wait for SSL certificate to be issued (automatic)

🌐 Alternative: Netlify Deployment

Netlify is another excellent option with similar ease of use:

Netlify Deployment Steps

  1. Create netlify.toml configuration file:
    # netlify.toml
    [build]
      command = "npm run build"
      publish = "dist"
    
    [[redirects]]
      from = "/*"
      to = "/index.html"
      status = 200
  2. Sign up at netlify.com
  3. Connect GitHub repository
  4. Add environment variables in Site Settings
  5. Deploy automatically on push

πŸ“¦ GitHub Pages Deployment

For free hosting directly from your GitHub repository:

Install gh-pages Package

npm install --save-dev gh-pages

Update package.json

{
  "name": "weather-dashboard",
  "homepage": "https://yourusername.github.io/weather-dashboard",
  "scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
    "preview": "vite preview",
    "predeploy": "npm run build",
    "deploy": "gh-pages -d dist"
  }
}

Update vite.config.ts

// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  base: '/weather-dashboard/' // Your repo name
})

Deploy to GitHub Pages

# Deploy to GitHub Pages
npm run deploy

# Your app will be live at:
# https://yourusername.github.io/weather-dashboard

⚠️ GitHub Pages Limitations

  • Environment variables must be handled differently (hardcode or use backend)
  • No server-side functionality
  • Only works with public repositories (free tier)

πŸ” Production Environment Variables

For production deployments, handle environment variables securely:

Best Practices for API Keys

  • Never commit .env files - Always in .gitignore
  • Use platform env vars - Set in Vercel/Netlify dashboard
  • Consider a backend proxy - Hide API keys completely:
    // Instead of calling API directly from frontend:
    // ❌ fetch(`https://api.openweathermap.org/...?appid=${API_KEY}`)
    
    // βœ… Call your backend which handles the API key:
    // fetch(`/api/weather?city=${city}`)
  • Rate limiting - Implement on backend to prevent abuse
  • Rotate keys - Change API keys periodically

🎨 Pre-Deployment Checklist

βœ… Before You Deploy

  • ☐ All tests pass locally
  • ☐ Build succeeds without errors (npm run build)
  • ☐ Preview build works correctly (npm run preview)
  • ☐ Environment variables configured
  • ☐ .gitignore includes .env and node_modules
  • ☐ README.md with project description
  • ☐ License file added (MIT recommended)
  • ☐ All console.logs removed or made conditional
  • ☐ Error boundaries implemented
  • ☐ Analytics added (optional)

πŸ“Š Post-Deployment Monitoring

After deployment, monitor your application's health:

Things to Monitor

  • Error Tracking - Use Sentry or similar service
  • Performance - Use Lighthouse or WebPageTest
  • User Analytics - Google Analytics or Plausible
  • API Usage - Monitor OpenWeatherMap dashboard for quota
  • Uptime - Use UptimeRobot or StatusCake

Adding Error Tracking with Sentry

# Install Sentry
npm install @sentry/react
// src/main.tsx
import * as Sentry from "@sentry/react";

Sentry.init({
    dsn: "your-sentry-dsn",
    environment: import.meta.env.MODE,
    tracesSampleRate: 1.0,
});

// Wrap App in Sentry error boundary
root.render(
    <React.StrictMode>
        <Sentry.ErrorBoundary fallback={<ErrorFallback />}>
            <App />
        </Sentry.ErrorBoundary>
    </React.StrictMode>
);

πŸ”„ Continuous Deployment

Set up automatic deployments for a smooth workflow:

graph TD A[Write Code] --> B[Commit to Git] B --> C[Push to GitHub] C --> D["Vercel/Netlify Detects Push"] D --> E[Automated Build] E --> F[Run Tests] F -->|Pass| G[Deploy to Production] F -->|Fail| H[Notify Developer] G --> I[Live Update] style A fill:#667eea,stroke:#333,stroke-width:2px,color:#fff style G fill:#48bb78,stroke:#333,stroke-width:2px,color:#fff style H fill:#dc3545,stroke:#333,stroke-width:2px,color:#fff

πŸ’‘ CI/CD Benefits

  • Automatic Deployments - Push code, it goes live automatically
  • Preview Deployments - Each PR gets its own preview URL
  • Rollback Support - Easy to revert to previous versions
  • No Downtime - Zero-downtime deployments

βœ… Deployment Complete!

Congratulations! Your weather dashboard is now live and accessible to users worldwide. 🌍

What's Next?

  • Share your deployment URL in the course forum
  • Add the project to your portfolio
  • Share on social media with #ReactWeatherDashboard
  • Gather user feedback and iterate

🎁 Bonus Features

Ready to take your weather dashboard to the next level? Here are some exciting features you can add to make your project stand out even more! These are optional challenges that will help you practice advanced React and TypeScript concepts.

πŸŒ“ Feature 1: Dark Mode

Add a beautiful dark mode toggle that persists across sessions.

Create useTheme Hook

// src/hooks/useTheme.ts
import { useState, useEffect } from 'react';

type Theme = 'light' | 'dark';

export const useTheme = () => {
    const [theme, setTheme] = useState<Theme>(() => {
        const saved = localStorage.getItem('theme');
        return (saved as Theme) || 'light';
    });

    useEffect(() => {
        localStorage.setItem('theme', theme);
        document.documentElement.setAttribute('data-theme', theme);
    }, [theme]);

    const toggleTheme = () => {
        setTheme(prev => prev === 'light' ? 'dark' : 'light');
    };

    return { theme, toggleTheme };
};

Add Dark Mode Styles to App.css

/* Dark mode variables */
[data-theme="dark"] {
    --bg-gradient-start: #1a1a2e;
    --bg-gradient-end: #16213e;
    --card-bg: #0f3460;
    --text-primary: #eaeaea;
    --text-secondary: #c5c5c5;
}

[data-theme="light"] {
    --bg-gradient-start: #667eea;
    --bg-gradient-end: #764ba2;
    --card-bg: #ffffff;
    --text-primary: #333333;
    --text-secondary: #666666;
}

body {
    background: linear-gradient(135deg, 
                var(--bg-gradient-start) 0%, 
                var(--bg-gradient-end) 100%);
}

.weather-card {
    background: var(--card-bg);
    color: var(--text-primary);
}

Add Theme Toggle Button

// In App.tsx
const { theme, toggleTheme } = useTheme();

// Add to header
<button 
    onClick={toggleTheme}
    className="theme-toggle"
    aria-label="Toggle theme"
>
    {theme === 'light' ? 'πŸŒ™' : 'β˜€οΈ'}
</button>

πŸ“Š Feature 2: Weather Charts

Visualize temperature trends with interactive charts using Recharts.

Install Recharts

npm install recharts

Create TemperatureChart Component

// src/components/TemperatureChart.tsx
import {
    LineChart,
    Line,
    XAxis,
    YAxis,
    CartesianGrid,
    Tooltip,
    ResponsiveContainer
} from 'recharts';

interface ChartData {
    time: string;
    temp: number;
}

interface TemperatureChartProps {
    data: ChartData[];
}

function TemperatureChart({ data }: TemperatureChartProps) {
    return (
        <div className="temperature-chart">
            <h3>πŸ“ˆ Temperature Trend</h3>
            <ResponsiveContainer width="100%" height={300}>
                <LineChart data={data}>
                    <CartesianGrid strokeDasharray="3 3" />
                    <XAxis dataKey="time" />
                    <YAxis />
                    <Tooltip />
                    <Line 
                        type="monotone" 
                        dataKey="temp" 
                        stroke="#667eea" 
                        strokeWidth={2}
                    />
                </LineChart>
            </ResponsiveContainer>
        </div>
    );
};

export default TemperatureChart;

🌑️ Feature 3: Unit Toggle (Celsius ↔ Fahrenheit)

Let users switch between temperature units.

// src/hooks/useTemperatureUnit.ts
import { useState } from 'react';

type Unit = 'metric' | 'imperial';

export const useTemperatureUnit = () => {
    const [unit, setUnit] = useState<Unit>('metric');

    const toggleUnit = () => {
        setUnit(prev => prev === 'metric' ? 'imperial' : 'metric');
    };

    const convertTemp = (celsius: number): number => {
        return unit === 'metric' 
            ? celsius 
            : (celsius * 9/5) + 32;
    };

    const getUnitSymbol = (): string => {
        return unit === 'metric' ? 'Β°C' : 'Β°F';
    };

    return { unit, toggleUnit, convertTemp, getUnitSymbol };
};

πŸ—ΊοΈ Feature 4: Map Integration

Show the searched city on an interactive map using Leaflet.

Install Leaflet

npm install leaflet react-leaflet
npm install --save-dev @types/leaflet

Create WeatherMap Component

// src/components/WeatherMap.tsx
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
import 'leaflet/dist/leaflet.css';

interface WeatherMapProps {
    lat: number;
    lon: number;
    cityName: string;
}

function WeatherMap({ lat, lon, cityName }: WeatherMapProps) {
    return (
        <div className="weather-map">
            <h3>πŸ—ΊοΈ Location</h3>
            <MapContainer
                center={[lat, lon]}
                zoom={10}
                style={{ height: '300px', width: '100%', borderRadius: '12px' }}
            >
                <TileLayer
                    url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
                    attribution='© OpenStreetMap contributors'
                />
                <Marker position={[lat, lon]}>
                    <Popup>{cityName}</Popup>
                </Marker>
            </MapContainer>
        </div>
    );
};

export default WeatherMap;

πŸ”” Feature 5: Weather Alerts & Notifications

Add browser notifications for severe weather conditions.

// src/hooks/useNotifications.ts
import { useEffect } from 'react';
import type { WeatherData } from '@/types/weather';

export const useNotifications = () => {
    useEffect(() => {
        // Request notification permission
        if ('Notification' in window && Notification.permission === 'default') {
            Notification.requestPermission();
        }
    }, []);

    const sendNotification = (title: string, body: string) => {
        if ('Notification' in window && Notification.permission === 'granted') {
            new Notification(title, {
                body,
                icon: '/weather-icon.png',
                badge: '/badge-icon.png'
            });
        }
    };

    const checkWeatherAlerts = (weather: WeatherData) => {
        // Extreme heat
        if (weather.main.temp > 35) {
            sendNotification(
                'πŸ”₯ Extreme Heat Warning!',
                `Temperature in ${weather.name} is ${weather.main.temp}Β°C`
            );
        }

        // Freezing temperatures
        if (weather.main.temp < 0) {
            sendNotification(
                '❄️ Freezing Alert!',
                `Temperature in ${weather.name} is below freezing`
            );
        }

        // High wind speeds
        if (weather.wind.speed > 15) {
            sendNotification(
                'πŸ’¨ High Wind Warning!',
                `Wind speed in ${weather.name} is ${weather.wind.speed} m/s`
            );
        }
    };

    return { sendNotification, checkWeatherAlerts };
};

🌐 Feature 6: Multi-Language Support

Add internationalization (i18n) support.

Install i18next

npm install react-i18next i18next

Configure i18n

// src/i18n.ts
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';

const resources = {
    en: {
        translation: {
            "search": "Search for a city",
            "current_weather": "Current Weather",
            "forecast": "5-Day Forecast",
            "favorites": "Favorite Cities"
        }
    },
    es: {
        translation: {
            "search": "Buscar una ciudad",
            "current_weather": "Clima Actual",
            "forecast": "PronΓ³stico de 5 DΓ­as",
            "favorites": "Ciudades Favoritas"
        }
    },
    fr: {
        translation: {
            "search": "Rechercher une ville",
            "current_weather": "MΓ©tΓ©o Actuelle",
            "forecast": "PrΓ©visions sur 5 Jours",
            "favorites": "Villes Favorites"
        }
    }
};

i18n
    .use(initReactI18next)
    .init({
        resources,
        lng: 'en',
        fallbackLng: 'en',
        interpolation: {
            escapeValue: false
        }
    });

export default i18n;

⚑ Feature 7: Progressive Web App (PWA)

Make your app installable and work offline.

Install Vite PWA Plugin

npm install vite-plugin-pwa -D

Update vite.config.ts

// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { VitePWA } from 'vite-plugin-pwa';

export default defineConfig({
    plugins: [
        react(),
        VitePWA({
            registerType: 'autoUpdate',
            includeAssets: ['favicon.ico', 'robots.txt'],
            manifest: {
                name: 'Weather Dashboard',
                short_name: 'Weather',
                description: 'Real-time weather information worldwide',
                theme_color: '#667eea',
                icons: [
                    {
                        src: 'icon-192.png',
                        sizes: '192x192',
                        type: 'image/png'
                    },
                    {
                        src: 'icon-512.png',
                        sizes: '512x512',
                        type: 'image/png'
                    }
                ]
            }
        })
    ]
});

🎨 Feature 8: Animated Weather Icons

Replace emoji with beautiful animated weather icons.

# Use animated weather icons library
npm install react-animated-weather
// src/components/AnimatedWeatherIcon.tsx
import ReactAnimatedWeather from 'react-animated-weather';

interface AnimatedWeatherIconProps {
    condition: string;
}

function AnimatedWeatherIcon({ condition }: AnimatedWeatherIconProps) {
    const getIcon = (condition: string) => {
        const lower = condition.toLowerCase();
        if (lower.includes('clear')) return 'CLEAR_DAY';
        if (lower.includes('cloud')) return 'CLOUDY';
        if (lower.includes('rain')) return 'RAIN';
        if (lower.includes('snow')) return 'SNOW';
        if (lower.includes('thunder')) return 'SLEET';
        return 'PARTLY_CLOUDY_DAY';
    };

    return (
        <ReactAnimatedWeather
            icon={getIcon(condition)}
            color="#667eea"
            size={80}
            animate={true}
        />
    );
};

export default AnimatedWeatherIcon;

βœ… Bonus Features Summary

You now have ideas for 8 powerful features to enhance your weather dashboard:

  1. πŸŒ“ Dark Mode with theme persistence
  2. πŸ“Š Interactive temperature charts
  3. 🌑️ Unit conversion (Β°C ↔ Β°F)
  4. πŸ—ΊοΈ Interactive map with Leaflet
  5. πŸ”” Weather alerts and notifications
  6. 🌐 Multi-language support (i18n)
  7. ⚑ Progressive Web App capabilities
  8. 🎨 Animated weather icons

Challenge: Pick 2-3 features and implement them to make your project truly unique! πŸ’ͺ

πŸ† Project Showcase Ideas

πŸ’‘ How to Present Your Project

  • Portfolio Website - Add to your developer portfolio
  • GitHub README - Write comprehensive documentation
  • Demo Video - Record a walkthrough (Loom or YouTube)
  • Blog Post - Write about your development journey
  • Social Media - Share screenshots and features
  • Dev.to Article - Share lessons learned

πŸ“š Further Learning Resources

Continue Your Journey

πŸŽ‰ Congratulations!

You've completed the Module 4 Weather Dashboard project! This was a comprehensive application that brought together everything you learned in this module and beyond. Let's recap what you've accomplished:

πŸ† What You Built

  • βœ… Complete React + TypeScript Application - Full-stack frontend app
  • βœ… API Integration - Real-time data from OpenWeatherMap
  • βœ… Custom Hooks Library - Reusable logic (useWeather, useFavorites, useDebounce, useGeolocation)
  • βœ… Component Architecture - 7+ well-structured components
  • βœ… State Management - Complex state with multiple hooks
  • βœ… Error Handling - Graceful error states and retry logic
  • βœ… Loading States - Proper UX feedback
  • βœ… Local Storage - Data persistence
  • βœ… Geolocation - Browser API integration
  • βœ… Responsive Design - Mobile-first approach
  • βœ… Accessibility - ARIA labels and semantic HTML
  • βœ… Production Deployment - Live on the web!

πŸ’ͺ Skills You Practiced

  • useEffect - Side effects, cleanup, dependencies
  • Data Fetching - Async/await, error handling
  • Custom Hooks - Extracting and reusing logic
  • API Integration - Working with REST APIs
  • TypeScript - Type safety throughout
  • Component Composition - Building complex UIs
  • State Management - Multiple state sources
  • Performance Optimization - Debouncing, memoization
  • Deployment - Taking apps to production

🎯 Next Steps

  1. Implement Bonus Features - Add dark mode, charts, or maps
  2. Share Your Project - Show it off to friends and on social media
  3. Get Feedback - Ask other developers to review your code
  4. Refactor & Optimize - Clean up code, improve performance
  5. Move to Module 5 - Continue learning advanced concepts
  6. Build More Projects - Apply what you learned to new ideas

🌟 Amazing Work! 🌟

You've built a production-ready weather application from scratch. This is a major milestone in your React and TypeScript journey!

Keep building, keep learning, and keep pushing yourself to create amazing things!

πŸš€ Ready for Module 5? Let's go! πŸš€