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

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 props
là cự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à componentUserCard
mong đợi nhận được quaprops
. - 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 componentUserCard
này là một React Functional Component và nó sẽ nhậnprops
có cấu trúc được định nghĩa bởiUserProps
. - Kết quả là, khi sử dụng
<UserCard />
, TypeScript sẽ tự động kiểm tra xem cácprops
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ểuuseState<Post[]>
. Điều này báo cho TypeScript biết rằngposts
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ànhPost[]
(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úcPost
. 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úcPost
, giúp bạn tránh các lỗiundefined
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ặcyup
kết hợp với TypeScript. - Trong phần render (
posts.map(...)
), TypeScript biết rằng mỗipost
trong mảngposts
là một đối tượngPost
, vì vậy bạn sẽ nhận được autocomplete và kiểm tra lỗi khi truy cậppost.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 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:
useRouter
vàquery
: HookuseRouter
từ Next.js cung cấp đối tượngrouter
, trong đó có thuộc tínhquery
.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ị trongquery
có thể làstring
,string[]
(nếu có nhiều tham số cùng tên) hoặcundefined
(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 đợiid
là một chuỗi số (ví dụ:/posts/1
), chúng ta cần thực hiện kiểm traif (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ếtpost
có thể chứa một đối tượngPostDetail
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. Khipost
chắc chắn không phảinull
(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à interfaceSSRDataProps
cho cấu trúc của toàn bộ objectprops
mà component trang (SSRDataPage
) sẽ nhận được.SSRDataProps
chỉ có một thuộc tính làusers
, kiểuUser[]
. - 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ậnprops
theo cấu trúcSSRDataProps
. - Hàm
getServerSideProps
được định nghĩa với kiểuGetServerSideProps<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ínhprops
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ànhUser[]
(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ủaSSRDataProps
hay không. Nếu bạn quên thuộc tínhusers
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ằngusers
mà component nhận được là một mảngUser[]
, cho phép bạn truy cập thuộc tính củauser
(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ồminitialState
kiểuboolean
(có thể là optional) vàlabel
kiểustring
(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ểuboolean
. - Hàm cập nhật state
setIsOn
chỉ được gọi với một giá trị kiểuboolean
hoặc một hàm trả vềboolean
.
- Giá trị ban đầu truyền vào
- 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.js và TypeScript 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