Vite + React + Typescript: Ứng dụng thời tiết

Đây là hướng dẫn xây dựng một ứng dụng đơn giản – Hiển thị thông tin thời tiết theo tên thành phố. Ứng dụng sẽ được xây dựng bằng Vite, React và Typescript.

Ứng dụng thời tiết

Trong bài viết này tập trung giới thiệu việc sử dụng Typescript để tạo ra một ứng dụng React, có nhiều phần sẽ không được sâu. Chính vì vậy bạn cũng phải có một chút hiểu biết nhất định về React hay Typescript.

Tạo một dự án với Vite

Hãy chắc chắn rằng bạn đã cài đặt NodeJS >= 18, tiếp đó bạn có thể tạo một dự án Vite bằng câu lệnh:

npm create vite@latest

Câu lệnh trên sẽ hỏi bạn về tên của dự án, hãy điền tên dự án của bạn (ví dụ: `vite-ts-weather-app`).

Tiếp theo hãy chọn React khi lựa chọn framework.

Cuối cùng, hãy chọn Typescript cho ngôn ngữ của dự án.

Chạy ứng dụng

Chúng ta đã hoàn thành việc khởi tạo dự án. Bây giờ bạn có thể di chuyển vào thư mục dự án và sử dụng những câu lệnh sau để chạy ứng dụng ở chế độ dev:

cd vite-ts-weather-app
npm install
npm run dev

Để xác nhận ứng dụng đang được chạy, cửa sổ terminal của bạn sẽ có kết quả tương tự như sau:

terminal

Hãy nhấn phím “o” để mở ứng dụng bằng trình duyệt mặc định của bạn.

Xây dựng ứng dụng thời tiết

Ứng dụng sẽ hiển thị thông tin thời tiết theo thành phố mà người nhập vào ô tìm kiếm. Chính vì vậy chúng ta sẽ phải gọi một API để lấy thông tin thời tiết, để giảm thiểu số lần gọi API chúng ta cũng cần sửa đổi logic khi gọi API.

Cài đặt Test Package

Hy vọng chúng ta sẽ quen với việc viết test cho mọi dòng code mà chúng ta viết.

Để viết test cho cho ứng dụng này, chúng ta sẽ dụng jest làm test framework, và một vài gói hỗ trợ từ @testing-library

npm install -D jest ts-jest jest-environment-jsdom @testing-library/react @testing-library/user-event

Tạo file cấu hình cho tiến trình jest – jest.config.js

/** @type {import('ts-jest').JestConfigWithTsJest} **/
export default {
  testEnvironment: 'jsdom',
  transform: {
    '^.+.tsx?$': ['ts-jest', { tsconfig: 'tsconfig.app.json' }],
  },
};

Chúng ta sẽ chạy test bằng lệnh npx jest (Có thể chúng ta sẽ gặp lỗi không có file test nào được tìm thấy, hãy tạm thời bỏ qua).

useFetch hook

Đây là một custom hook có thể thấy ở đa số dự án React, hook này hỗ trợ gọi một api với các state của data, loading, error.

Đây là spec của useFetch hook – `src/hooks/useFetch.test.ts`

import { renderHook, act, waitFor } from '@testing-library/react';
import useFetch from './useFetch';

describe('useFetch', () => {
  beforeEach(() => {
    global.fetch = jest.fn();
  });

  afterEach(() => {
    jest.clearAllMocks();
  });

  it('fetches data successfully', async () => {
    const mockData = { message: 'Success' };
    (fetch as jest.Mock).mockResolvedValueOnce({
      status: 200,
      json: jest.fn().mockResolvedValueOnce(mockData),
    });

    const { result } = renderHook(() => useFetch<typeof mockData>());

    act(() => {
      result.current.call('https://api.example.com/data');
    });

    await waitFor(() => {
      expect(result.current.loading).toBe(false);
      expect(result.current.data).toEqual(mockData);
      expect(result.current.error).toBeNull();
    });
  });

  it('handles fetch errors', async () => {
    (fetch as jest.Mock).mockRejectedValueOnce(new Error('Network Error'));

    const { result } = renderHook(() => useFetch());

    act(() => {
      result.current.call('https://api.example.com/data');
    });

    await waitFor(() => {
      expect(result.current.loading).toBe(false);
      expect(result.current.data).toBeNull();
      expect(result.current.error).toEqual(new Error('Network Error'));
    });
  });

  it('handles non-200 status codes', async () => {
    (fetch as jest.Mock).mockResolvedValueOnce({
      status: 404,
      json: jest.fn(),
    });

    const { result } = renderHook(() => useFetch());

    act(() => {
      result.current.call('https://api.example.com/data');
    });

    await waitFor(() => {
      expect(result.current.loading).toBe(false);
      expect(result.current.data).toBeNull();
      expect(result.current.error).toEqual(new Error('Failed to fetch!'));
    });
  });
});

Dựa vào spec này chúng ta sẽ có implement của hook như sau: src/hooks/useHook.ts

import { useCallback, useState } from 'react';

export default function useFetch<T>() {
  const [data, setData] = useState<T | null>(null);
  const [error, setError] = useState<Error | null>(null);
  const [loading, setLoading] = useState<boolean>(false);
  const call = useCallback(async (url: string) => {
    try {
      setLoading(true);
      const response = await fetch(url);

      if (response.status !== 200) {
        setError(new Error('Failed to fetch!'));
        return;
      }

      setData(await response.json());
    } catch (error) {
      setError(error as Error);
    } finally {
      setLoading(false);
    }
  }, []);

  return { data, error, loading, call };
}

chúng ta có một generic hook, T là kiểu trả về của API.

Bạn sẽ phải chạy lại test để chắc code chúng ta viết đã thoả mãn yêu của của file test.

npx jest

DebounceInput

Đây là một React component, có chức năng tương tự input component. Điểm khác là sự kiện onChange sẽ được gọi sau một thời gian delay nhất định, các sự kiện trong khoảng thời gian chờ đợi cũng sẽ bị huỷ bỏ, nói cách khác chỉ có kết quả cuối cùng được tạo ra.

Đây là spec của component – src/components/DebounceInput.test.ts

import { render, fireEvent } from '@testing-library/react';
import { screen } from '@testing-library/react';
import DebounceInput from './DebounceInput';

describe('DebounceInput', () => {
  const waitTime = 300;
  let handleChange: jest.Mock;
  let input: HTMLInputElement;

  beforeEach(() => {
    jest.useFakeTimers();
    handleChange = jest.fn();

    render(<DebounceInput wait={waitTime} onChange={handleChange} />);
    input = screen.getByRole('textbox');
  });

  it('calls onChange after the specified wait time', () => {
    // Simulate changing the input value
    fireEvent.change(input, {
      target: { value: 'Hello' },
    });

    // Fast forward the timers
    jest.advanceTimersByTime(waitTime);

    // Check that onChange was called with the correct value
    expect(handleChange).toHaveBeenCalledTimes(1);
    expect(handleChange).toHaveBeenCalledWith('Hello');
  });

  it('clears previous timeout on new input', () => {
    // Simulate first change
    fireEvent.change(input, {
      target: { value: 'Hello' },
    });
    jest.advanceTimersByTime(waitTime - 100); // Advance time partially

    // Simulate second change before the first change has completed
    fireEvent.change(input, {
      target: { value: 'World' },
    });

    // Fast forward the timers to finish the debounce
    jest.advanceTimersByTime(waitTime); // Complete the remaining time

    // Check that onChange was called with the second value
    expect(handleChange).toHaveBeenCalledTimes(1);
    expect(handleChange).toHaveBeenCalledWith('World');
  });
});

và đây là nội dung của DebounceInput component – src/components/DebounceInput.tsx

import { useEffect, useState, InputHTMLAttributes } from 'react';

export interface DebounceInputProps
  extends Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange'> {
  onChange: (value: string) => void;
  wait: number;
}

export default function DebounceInput({
  onChange,
  wait,
  ...inputProps
}: DebounceInputProps) {
  const [value, setValue] = useState('');

  useEffect(() => {
    const timeoutId = setTimeout(() => {
      onChange(value);
    }, wait);

    return () => clearTimeout(timeoutId);
  }, [value, onChange, wait]);

  return <input {...inputProps} onChange={(e) => setValue(e.target.value)} />;
}

Kết quả khi chạy test:

jest

App.tsx

import { useEffect, useState } from 'react';
import './App.css';
import useFetch from './hooks/useFetch';
import WeatherData from './models/WeatherData';
import DebounceInput from './components/DebounceInput';

function App() {
  const { data: weather, call } = useFetch<WeatherData>();
  const [city, setCity] = useState<string>('Tokyo');

  useEffect(() => {
    if (city) {
      call(
        `https://api.openweathermap.org/data/2.5/weather?units=metric&appid=9505fd1df737e20152fbd78cdb289b6a&q=${city}`
      );
    }
  }, [city]);

  return (
    <>
      <main>
        <form>
          <DebounceInput
            type="text"
            autoComplete="off"
            wait={500}
            onChange={(v) => setCity(v)}
          />
          <button>
            <i className="fa-solid fa-magnifying-glass"></i>
          </button>
        </form>
        {weather ? (
          <section className="result">
            <figure className="name">
              <figcaption>{weather.name}</figcaption>
              <img
                src={`https://flagsapi.com/${weather.sys.country}/shiny/32.png`}
              />
            </figure>

            <figure className="temperature">
              <img
                src={`https://openweathermap.org/img/wn/${weather.weather[0].icon}@4x.png`}
              />
              <figcaption>
                <span>{Math.floor(weather.main.temp)}</span>
                <sup>o</sup>
              </figcaption>
            </figure>
            <p className="description">{weather.weather[0].description}</p>
            <ul>
              <li>
                <span>Clouds</span>
                <i className="fa-solid fa-cloud"></i>
                <span id="clouds">{weather.clouds.all}</span>%
              </li>
              <li>
                <span>Humidity</span>
                <i className="fa-solid fa-droplet"></i>
                <span id="humidity">{weather.main.humidity}</span>%
              </li>
              <li>
                <span>Pressure</span>
                <i className="fa-solid fa-gauge"></i>
                <span id="pressure">{weather.main.pressure}</span>hPa
              </li>
            </ul>
          </section>
        ) : (
          <>TODO</>
        )}
      </main>
    </>
  );
}

export default App;

Và file css – `App.css`

@import url('https://use.fontawesome.com/releases/v6.5.2/css/all.css');

#root {
  margin: 0;
  padding: 0;
  display: grid;
  height: 100vh;
  width: 100vw;
  overflow: hidden;
  font-size: 2rem;
}

* {
  padding: 0;
  margin: 0;
}

main {
  width: 100%;
  height: 100%;
  background-color: #f7f7f7;
  padding: 30px;
  box-sizing: border-box;
  font-family: sans-serif;
}

form {
  border: 1px solid #5552;
  display: flex;
  border-radius: 30px;
  justify-content: space-between;
}

input,
button {
  border: none;
  background-color: transparent;
  outline: none;
  padding: 10px;
  box-sizing: border-box;
}

i {
  opacity: 0.7;
}

.result {
  padding-top: 20px;
  text-align: center;
}

.result .name {
  font-weight: bold;
  font-size: large;
  display: flex;
  justify-content: center;
  align-items: center;
  gap: 10px;
}

.temperature img {
  width: 150px;
  filter: drop-shadow(0 10px 50px #555);
}

.temperature figcaption {
  font-size: 3em;
}

.description {
  padding: 10px 0 30px;
}

.description::first-letter {
  text-transform: capitalize;
}

ul {
  list-style: none;
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 10px;
}

li {
  background-color: #f78a55;
  color: #fff;
  border-radius: 10px;
  padding: 20px 10px;
  background-image: linear-gradient(to bottom, transparent 50%, #0003 50%);
  font-weight: bold;
  font-size: small;
}

li i {
  font-size: 2em;
  margin: 20px 0;
  display: block !important;
}

li:nth-child(2) {
  background-color: #b56291;
}

li:nth-child(3) {
  background-color: #48567b;
}

File src/models/WeatherData.ts

export default interface WeatherData {
  coord: Coord;
  weather: Weather[];
  base: string;
  main: Main;
  visibility: number;
  wind: Wind;
  clouds: Clouds;
  dt: number;
  sys: Sys;
  timezone: number;
  id: number;
  name: string;
  cod: number;
}

interface Coord {
  lon: number;
  lat: number;
}

interface Weather {
  id: number;
  main: string;
  description: string;
  icon: string;
}

interface Main {
  temp: number;
  feels_like: number;
  temp_min: number;
  temp_max: number;
  pressure: number;
  humidity: number;
  sea_level: number;
  grnd_level: number;
}

interface Wind {
  speed: number;
  deg: number;
}

interface Clouds {
  all: number;
}

interface Sys {
  type: number;
  id: number;
  country: string;
  sunrise: number;
  sunset: number;
}

Kết luận

Với việc sử dụng Vite, với một vài bước đơn giản, chúng ta đã hoàn thành một ứng dụng React. Ứng dụng chưa có nhiều chức năng, nhưng hy vọng nó có thể giúp bạn hình dung được việc phát triển một ứng dụng React bằng Typescript như thế nào.

Source code và Demo: https://stackblitz.com/edit/stackblitz-starters-s8gcrt

Tham khảo