Bài 28.5: Bài tập thực hành Next.js-TypeScript

Chào mừng bạn đến với bài viết tiếp theo trong chuỗi series Lập trình Web Front-end của chúng ta! Sau khi đã tìm hiểu về các công nghệ cốt lõi như HTML, CSS, JavaScript, TypeScript và đặc biệt là React cùng Next.js, đã đến lúc chúng ta thực sự bắt tay vào thực hành.

Việc kết hợp Next.js - framework React mạnh mẽ và linh hoạt cho sản xuất - với TypeScript - siêu tập hợp của JavaScript mang lại khả năng gõ tĩnh an toàn - là một công thức cực kỳ hiệu quả để xây dựng các ứng dụng web chất lượng cao. Sự kết hợp này giúp chúng ta bắt lỗi sớm ngay trong quá trình phát triển, cải thiện khả năng đọc hiểu và bảo trì code, đồng thời nâng cao năng suất làm việc nhóm.

Bài viết này không tập trung vào lý thuyết mà đi thẳng vào các bài tập thực hành cụ thể, giúp bạn củng cố kiến thức và làm quen với việc viết code Next.js sử dụng TypeScript một cách hiệu quả. Chúng ta sẽ cùng nhau giải quyết một số vấn đề phổ biến trong phát triển web, áp dụng cả Next.js và TypeScript.

Hãy cùng bắt đầu nào!

Bài tập 1: Định nghĩa Props cho Component bằng TypeScript

Trong React (và Next.js), component là khối xây dựng cơ bản. Component thường nhận dữ liệu thông qua props. Khi sử dụng TypeScript, việc định nghĩa rõ ràng kiểu dữ liệu cho propscực kỳ quan trọng để đảm bảo tính đúng đắn và nhận được sự hỗ trợ từ trình soạn thảo code (autocomplete, kiểm tra lỗi).

Mục tiêu: Tạo một component đơn giản (UserCard) hiển thị thông tin cơ bản của người dùng, sử dụng interface của TypeScript để định nghĩa cấu trúc của props.

// components/UserCard.tsx

import React from 'react';

// 1. Định nghĩa cấu trúc của props bằng Interface
interface UserProps {
  name: string;
  age: number;
  email: string;
  isActive: boolean;
}

// 2. Sử dụng Interface với React.FC (Functional Component)
const UserCard: React.FC<UserProps> = ({ name, age, email, isActive }) => {
  return (
    <div style={{ border: '1px solid #ccc', padding: '15px', margin: '10px', borderRadius: '8px' }}>
      <h3>{name}</h3>
      <p>Tuổi: {age}</p>
      <p>Email: {email}</p>
      <p>Trạng thái: **{isActive ? 'Hoạt động' : 'Không hoạt động'}**</p>
    </div>
  );
};

export default UserCard;

Cách sử dụng component:

// pages/index.tsx hoặc app/page.tsx (ví dụ trong môi trường Next.js)

import UserCard from '../components/UserCard';

const HomePage: React.FC = () => {
  const userData = {
    name: 'Nguyen Van A',
    age: 30,
    email: 'a.nguyen@example.com',
    isActive: true,
  };

  // TypeScript sẽ kiểm tra xem userData có khớp với UserProps không
  return (
    <div>
      <h1>Danh sách người dùng</h1>
      <UserCard
        name={userData.name}
        age={userData.age}
        email={userData.email}
        isActive={userData.isActive}
      />

      {/* Thử truyền sai kiểu dữ liệu hoặc thiếu prop -> TypeScript sẽ báo lỗi ngay! */}
      {/* <UserCard name="Tran Thi B" age="25" email="b.tran@example.com" isActive={false} /> */}
      {/* <UserCard name="Le Van C" age={20} email="c.le@example.com" /> */}
    </div>
  );
};

export default HomePage;
Giải thích:
  • Chúng ta định nghĩa một TypeScript interface tên là UserProps. Interface này mô tả chính xác các thuộc tính (name, age, email, isActive) và kiểu dữ liệu tương ứng mà component UserCard mong đợi nhận được qua props.
  • Khi định nghĩa component functional component, chúng ta sử dụng React.FC<UserProps>. Điều này cho TypeScript biết rằng component UserCard này là một React Functional Component và nó sẽ nhận props có cấu trúc được định nghĩa bởi UserProps.
  • Kết quả là, khi sử dụng <UserCard />, TypeScript sẽ tự động kiểm tra xem các props bạn truyền vào có đúng tên thuộc tính, đúng kiểu dữ liệu và có đủ các thuộc tính bắt buộc hay không. Nếu sai, bạn sẽ nhận được cảnh báo hoặc lỗi ngay lập tức trong trình soạn thảo code hoặc khi biên dịch, giúp bạn ngăn chặn các lỗi phổ biến liên quan đến việc truyền sai dữ liệu giữa các component.

Bài tập 2: Fetching Data và Typing API Response

Một tác vụ rất phổ biến trong ứng dụng web là lấy dữ liệu từ một API bên ngoài. Khi làm việc này trong Next.js với TypeScript, chúng ta cần đảm bảo rằng cấu trúc dữ liệu nhận được từ API phù hợp với những gì chúng ta mong đợi và sử dụng trong code.

Mục tiêu: Fetch một danh sách các bài viết từ một API giả lập (như JSONPlaceholder) và hiển thị chúng, sử dụng interface để định nghĩa cấu trúc dữ liệu nhận được và typing cho state quản lý dữ liệu.

// pages/posts.tsx (ví dụ sử dụng Client-Side Data Fetching với useEffect)

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

// 1. Định nghĩa cấu trúc dữ liệu của một bài viết từ API
interface Post {
  userId: number;
  id: number;
  title: string;
  body: string;
}

const PostsPage: React.FC = () => {
  // 2. Định nghĩa state để lưu trữ danh sách bài viết, gõ kiểu là mảng Post
  const [posts, setPosts] = useState<Post[]>([]);
  const [loading, setLoading] = useState<boolean>(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const fetchPosts = async () => {
      try {
        const response = await fetch('https://jsonplaceholder.typicode.com/posts');
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }
        // 3. Ép kiểu dữ liệu nhận được về mảng Post
        const data: Post[] = await response.json();
        setPosts(data);
      } catch (err: any) { // Sử dụng 'any' cho lỗi hoặc kiểm tra kiểu cụ thể
        setError(err.message);
      } finally {
        setLoading(false);
      }
    };

    fetchPosts();
  }, []); // Mảng dependencies rỗng để chỉ chạy một lần sau khi component mount

  if (loading) {
    return <div>Đang tải bài viết...</div>;
  }

  if (error) {
    return <div>Lỗi khi tải bài viết: {error}</div>;
  }

  return (
    <div>
      <h1>Danh sách bài viết</h1>
      <ul>
        {/* TypeScript biết rằng mỗi 'post' trong mảng 'posts' có cấu trúc của Interface Post */}
        {posts.map((post) => (
          <li key={post.id}>
            <h2>{post.title}</h2>
            <p>{post.body}</p>
          </li>
        ))}
      </ul>
    </div>
  );
};

export default PostsPage;
Giải thích:
  • Chúng ta tạo một TypeScript interface Post để mô tả cấu trúc dữ anticipated của một đối tượng bài viết từ API. Điều này giúp chúng ta biết chắc chắn những thuộc tính nào có sẵn (userId, id, title, body) và kiểu dữ liệu của chúng.
  • State posts được khai báo với kiểu useState<Post[]>. Điều này báo cho TypeScript biết rằng posts sẽ là một mảng chứa các đối tượng có cấu trúc như Post.
  • Khi nhận dữ liệu từ response.json(), chúng ta ép kiểu nó thành Post[] (const data: Post[] = ...). Việc này dựa trên giả định rằng API sẽ trả về dữ liệu đúng như cấu trúc Post. Nếu API trả về sai, TypeScript không tự động kiểm tra điều này tại runtime, nhưng nó đảm bảo rằng trong code của bạn, bạn đang xử lý dữ liệu như thể nó có cấu trúc Post, giúp bạn tránh các lỗi undefined khi truy cập thuộc tính (ví dụ: post.titlle thay vì post.title). Để kiểm tra cấu trúc dữ liệu nhận được một cách an toàn hơn tại runtime, bạn có thể sử dụng các thư viện validation như zod hoặc yup kết hợp với TypeScript.
  • Trong phần render (posts.map(...)), TypeScript biết rằng mỗi post trong mảng posts là một đối tượng Post, vì vậy bạn sẽ nhận được autocomplete và kiểm tra lỗi khi truy cập post.id, post.title, post.body.

Bài tập 3: Dynamic Routes và Typing Params

Next.js có tính năng định tuyến động (Dynamic Routes), cho phép tạo các trang dựa trên tham số trong URL (ví dụ: /posts/1, /users/abc). Khi sử dụng TypeScript, việc truy cập và sử dụng các tham số này một cách an toàn là cần thiết.

Mục tiêu: Tạo một trang chi tiết bài viết động (pages/posts/[id].tsx) hiển thị thông tin chi tiết của một bài viết dựa trên id trong URL, sử dụng TypeScript để xử lý tham số định tuyến. Chúng ta sẽ sử dụng useRouter cho ví dụ đơn giản này.

// pages/posts/[id].tsx

import React, { useEffect, useState } from 'react';
import { useRouter } from 'next/router';

// Định nghĩa cấu trúc dữ liệu của bài viết chi tiết (có thể giống interface Post ở bài tập 2)
interface PostDetail {
  userId: number;
  id: number;
  title: string;
  body: string;
}

const PostDetailPage: React.FC = () => {
  const router = useRouter();
  // Lấy tham số 'id' từ URL. Lưu ý: query params luôn là string hoặc string[] hoặc undefined
  const { id } = router.query;

  const [post, setPost] = useState<PostDetail | null>(null); // State có thể là PostDetail hoặc null
  const [loading, setLoading] = useState<boolean>(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    // Kiểm tra xem id đã có và là string (tránh trường hợp router chưa sẵn sàng hoặc id là mảng)
    if (typeof id === 'string') {
      const fetchPost = async () => {
        try {
          setLoading(true);
          setError(null); // Reset error
          const response = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`);
          if (!response.ok) {
            throw new Error(`HTTP error! status: ${response.status}`);
          }
          const data: PostDetail = await response.json(); // Ép kiểu dữ liệu nhận được
          setPost(data);
        } catch (err: any) {
          setError(err.message);
          setPost(null); // Clear previous data on error
        } finally {
          setLoading(false);
        }
      };

      fetchPost();
    }
  }, [id]); // useEffect chạy lại khi 'id' thay đổi

  if (loading) {
    return <div>Đang tải chi tiết bài viết...</div>;
  }

  if (error) {
    return <div>Lỗi khi tải chi tiết bài viết: {error}</div>;
  }

  // Kiểm tra nếu post là null (trường hợp lỗi hoặc id không hợp lệ sau khi tải)
  if (!post) {
     return <div>Không tìm thấy bài viết hoặc  lỗi xảy ra.</div>;
  }


  // TypeScript biết 'post' ở đây có cấu trúc PostDetail
  return (
    <div>
      <h1>{post.title}</h1>
      <p>{post.body}</p>
      <p><em>Người đăng ID: {post.userId}</em></p>
      <button onClick={() => router.back()}>Quay lại</button>
    </div>
  );
};

export default PostDetailPage;
Giải thích:
  • useRouterquery: Hook useRouter từ Next.js cung cấp đối tượng router, trong đó có thuộc tính query. query chứa các tham số từ URL. Tuy nhiên, TypeScript báo cho chúng ta biết rằng các giá trị trong query có thể là string, string[] (nếu có nhiều tham số cùng tên) hoặc undefined (khi trang được render lần đầu trên client trước khi router sẵn sàng).
  • Kiểm tra kiểu dữ liệu của id: Vì chúng ta mong đợi id là một chuỗi số (ví dụ: /posts/1), chúng ta cần thực hiện kiểm tra if (typeof id === 'string') trước khi sử dụng nó để fetch data. Điều này là quan trọng để đảm bảo an toàn kiểu dữ liệu và tránh lỗi runtime.
  • Typing cho State: State post được định nghĩa là useState<PostDetail | null>(null). Điều này cho biết post có thể chứa một đối tượng PostDetail hoặc là null (trước khi tải, khi tải lỗi, hoặc nếu bài viết không tồn tại).
  • Conditional Rendering: Chúng ta sử dụng kiểm tra if (loading), if (error), và if (!post) để hiển thị trạng thái tải, lỗi, hoặc trường hợp không tìm thấy dữ liệu một cách an toàn. Khi post chắc chắn không phải null (sau các kiểm tra), TypeScript cho phép chúng ta truy cập an toàn các thuộc tính của nó như post.title, post.body.

Bài tập 4: Server-Side Rendering (SSR) với Typed Data

Một trong những tính năng mạnh mẽ của Next.js là Server-Side Rendering (SSR) thông qua hàm getServerSideProps. Khi sử dụng SSR với TypeScript, chúng ta cần đảm bảo dữ liệu được fetch trên server và được truyền xuống component dưới dạng props có kiểu dữ liệu rõ ràng.

Mục tiêu: Tạo một trang sử dụng SSR (pages/ssr-data.tsx) để fetch dữ liệu một danh sách người dùng trên server và hiển thị chúng, sử dụng interface để typing dữ liệu fetch và typing cho props của trang.

// pages/ssr-data.tsx

import { GetServerSideProps, NextPage } from 'next';
import React from 'react';

// 1. Định nghĩa cấu trúc dữ liệu của một người dùng
interface User {
  id: number;
  name: string;
  username: string;
  email: string;
  // ... các thuộc tính khác nếu có
}

// 2. Định nghĩa cấu trúc của Props mà trang sẽ nhận được từ getServerSideProps
interface SSRDataProps {
  users: User[]; // Mảng các đối tượng User
}

// 3. Định nghĩa component trang với kiểu dữ liệu của Props
const SSRDataPage: NextPage<SSRDataProps> = ({ users }) => {
  return (
    <div>
      <h1>Danh sách người dùng (SSR)</h1>
      <ul>
        {/* TypeScript biết rằng 'users' là mảng User[] */}
        {users.map((user) => (
          <li key={user.id}>
            **{user.name}** ({user.username}) - {user.email}
          </li>
        ))}
      </ul>
    </div>
  );
};

export default SSRDataPage;

// 4. Implement getServerSideProps với kiểu dữ liệu rõ ràng
export const getServerSideProps: GetServerSideProps<SSRDataProps> = async (context) => {
  try {
    const res = await fetch('https://jsonplaceholder.typicode.com/users');
    if (!res.ok) {
      throw new Error(`HTTP error! status: ${res.status}`);
    }
    // 5. Ép kiểu dữ liệu nhận được thành User[]
    const users: User[] = await res.json();

    // 6. Trả về props đã được typing
    return {
      props: {
        users, // users ở đây phải có kiểu User[] theo định nghĩa SSRDataProps
      },
    };
  } catch (error) {
    console.error("Error fetching users in getServerSideProps:", error);
    // Xử lý lỗi: có thể trả về mảng rỗng, hoặc chuyển hướng đến trang lỗi
    return {
      props: {
        users: [], // Trả về mảng rỗng nếu có lỗi
      },
    };
    // Hoặc nếu bạn muốn hiển thị trang lỗi hoặc redirect:
    // return {
    //    redirect: {
    //      destination: '/error',
    //      permanent: false,
    //    },
    // };
    // return {
    //    notFound: true // Trả về trang 404
    // };
  }
};
Giải thích:
  • Chúng ta định nghĩa interface User cho cấu trúc dữ liệu của mỗi người dùng và interface SSRDataProps cho cấu trúc của toàn bộ object props mà component trang (SSRDataPage) sẽ nhận được. SSRDataProps chỉ có một thuộc tính là users, kiểu User[].
  • Component trang được khai báo là const SSRDataPage: NextPage<SSRDataProps>. NextPage là một kiểu tiện ích từ Next.js, kết hợp với generic <SSRDataProps> để báo cho TypeScript biết component này là một trang Next.js và sẽ nhận props theo cấu trúc SSRDataProps.
  • Hàm getServerSideProps được định nghĩa với kiểu GetServerSideProps<SSRDataProps>. Điều này là rất quan trọng. Nó báo cho TypeScript biết rằng hàm này là getServerSideProps của Next.js và nó phải trả về một object có thuộc tính props với cấu trúc là SSRDataProps.
  • Bên trong getServerSideProps, sau khi fetch dữ liệu, chúng ta ép kiểu dữ liệu nhận được (await res.json()) thành User[] (const users: User[] = ...).
  • Object trả về từ getServerSideProps có dạng { props: { users } }. TypeScript sẽ kiểm tra tại thời điểm biên dịch xem object { users } có khớp với cấu trúc của SSRDataProps hay không. Nếu bạn quên thuộc tính users hoặc truyền sai kiểu dữ liệu, TypeScript sẽ báo lỗi ngay tại đây.
  • Trong component SSRDataPage, nhờ typing <SSRDataProps>, TypeScript biết chắc chắn rằng users mà component nhận được là một mảng User[], cho phép bạn truy cập thuộc tính của user (như user.name, user.email) một cách an toàn và có autocomplete.

Bài tập 5: State Management Cơ bản với TypeScript

Ngay cả với state cục bộ đơn giản sử dụng useState trong React, việc sử dụng TypeScript giúp code rõ ràng hơn và an toàn hơn, đặc biệt khi state là một object hoặc một mảng.

Mục tiêu: Tạo một component đơn giản quản lý state của một form nhỏ hoặc một trạng thái bật/tắt, sử dụng typing cho useState.

// components/ToggleSwitch.tsx

import React, { useState } from 'react';

interface ToggleProps {
  initialState?: boolean; // state ban đầu có thể có hoặc không
  label?: string;
}

const ToggleSwitch: React.FC<ToggleProps> = ({ initialState = false, label }) => {
  // 1. Định nghĩa state với kiểu dữ liệu rõ ràng (boolean)
  const [isOn, setIsOn] = useState<boolean>(initialState);

  const handleToggle = () => {
    // TypeScript biết setIsOn chỉ nhận giá trị boolean hoặc hàm cập nhật trả về boolean
    setIsOn(!isOn);
  };

  return (
    <div style={{ margin: '10px' }}>
      {label && <span style={{ marginRight: '10px' }}>{label}:</span>}
      <button onClick={handleToggle} style={{ padding: '8px', cursor: 'pointer' }}>
        {/* TypeScript biết isOn là boolean */}
        **{isOn ? 'BẬT' : 'TẮT'}**
      </button>
      <span style={{ marginLeft: '10px', fontWeight: 'bold', color: isOn ? 'green' : 'red' }}>
        Trạng thái: {isOn ? 'Hoạt động' : 'Dừng'}
      </span>
    </div>
  );
};

export default ToggleSwitch;

Cách sử dụng component:

// pages/settings.tsx (ví dụ)

import ToggleSwitch from '../components/ToggleSwitch';

const SettingsPage: React.FC = () => {
  return (
    <div>
      <h1>Cài đặt</h1>
      <ToggleSwitch label="Bật tính năng X" initialState={true} />
      <ToggleSwitch label="Chế độ tối" initialState={false} />
      <ToggleSwitch label="Thông báo email" /> {/* Sử dụng giá trị mặc định false */}
    </div>
  );
};

export default SettingsPage;
Giải thích:
  • Chúng ta định nghĩa interface ToggleProps cho props, bao gồm initialState kiểu boolean (có thể là optional) và label kiểu string (optional).
  • State isOn được khai báo là useState<boolean>(initialState). Bằng cách chỉ định <boolean>, chúng ta báo cho TypeScript biết rằng state này luôn luôn là một giá trị boolean.
  • Nhờ đó, TypeScript kiểm tra rằng:
    • Giá trị ban đầu truyền vào useState phải có thể gán cho kiểu boolean.
    • Hàm cập nhật state setIsOn chỉ được gọi với một giá trị kiểu boolean hoặc một hàm trả về boolean.
  • Trong phần JSX, TypeScript biết isOn là boolean, giúp bạn sử dụng nó trong các biểu thức điều kiện một cách an toàn.

Kết thúc các bài tập

Thông qua các bài tập vừa rồi, chúng ta đã cùng nhau thực hành cách kết hợp Next.jsTypeScript trong một số tình huống phổ biến: định nghĩa props cho component, fetching data từ API (cả client-side và server-side), xử lý tham số dynamic route, và quản lý state cục bộ.

Mỗi bài tập đều nhấn mạnh vào việc sử dụng interface hoặc type để định nghĩa cấu trúc dữ liệu và sử dụng generic (<...>) với các hook và hàm của React/Next.js (React.FC, useState, GetServerSideProps) để báo cho TypeScript biết về kiểu dữ liệu mà chúng ta đang làm việc.

Thực hành là chìa khóa để làm chủ bất kỳ công nghệ nào. Hãy thử mở rộng các bài tập này, thêm vào các tính năng khác, hoặc áp dụng typing vào các phần khác trong dự án Next.js của bạn. Bạn sẽ nhanh chóng nhận thấy lợi ích to lớn mà TypeScript mang lại: code ít lỗi hơn, dễ đọc hơn, và quy trình phát triển mượt mà hơn.

Chúc bạn học tốt và tiếp tục khám phá sâu hơn về sự kết hợp mạnh mẽ này!

Comments

There are no comments at the moment.