# Bạn muốn học gì? Source: https://www.youhoc.com/index Nền tảng cho mọi ngôn ngữ Front-end Framework
# Arrow Function trong JavaScript Source: https://www.youhoc.com/javascript/arrow-function Khi bắt đầu làm việc với các stack như React, Node.js hay bất kỳ framework JavaScript nào, bạn sẽ thấy cú pháp `=>` xuất hiện khắp nơi - trong callback của `fetch`, trong các method như `.map()`, `.filter()`, hay trong các component của React. Nếu chưa quen với Arrow Function, code của người khác sẽ trông khá lạ lẫm. Bài viết này là một "refresher" nhanh về Arrow Function, giúp bạn đọc và viết được cú pháp này trước khi bước vào học bất kỳ JavaScript stack nào. *** ## Arrow Function là gì? Arrow Function là cú pháp rút gọn để khai báo function, được giới thiệu từ ES6 (2015). Thay vì viết `function() { ... }`, bạn dùng `() => { ... }`. Ví dụ đơn giản nhất: ```js theme={null} const numbers = [1, 2, 3, 4, 5]; const doubled = numbers.map((number) => number * 2); // [2, 4, 6, 8, 10] ``` Arrow Function được dùng nhiều nhất trong các callback như `.map()`, `.filter()`, `.then()`, vì nó giúp code ngắn gọn và dễ đọc hơn. *** ## Cách khai báo ### Function bình thường (function expression) ```js theme={null} const add = function(a, b) { return a + b; }; ``` ### Arrow Function ```js theme={null} const add = (a, b) => { return a + b; }; ``` ### Arrow Function rút gọn - nếu chỉ có 1 dòng `return` Khi thân hàm chỉ có một biểu thức duy nhất để trả về, bạn có thể bỏ luôn `{}` và `return`: ```js theme={null} const add = (a, b) => a + b; ``` Nếu chỉ có 1 tham số, bạn còn có thể bỏ cả dấu ngoặc `()`, siêu gọn: ```js theme={null} const double = n => n * 2; ``` *** ## Dùng Arrow Function như callback Đây là nơi Arrow Function tỏa sáng nhất. So sánh 3 cách viết callback cho `.then()`: ```js theme={null} // Function bình thường .then(function(res) { return res.json(); }) // Arrow Function .then((res) => { return res.json(); }) // Arrow Function rút gọn - cách viết phổ biến nhất .then(res => res.json()) ``` Cả 3 đều làm cùng một việc. Trong thực tế, bạn sẽ thường thấy cách rút gọn nhất. *** ## Function lồng Function Một pattern hay gặp là truyền Arrow Function trực tiếp làm argument khi gọi hàm, thay vì phải khai báo riêng: ```js theme={null} function greet(greetFunc, name) { return greetFunc(name); } // Viết dài - khai báo riêng console.log( greet(function(name) { return 'Hi ' + name; }, 'Tony') ) // Viết ngắn - truyền Arrow Fn trực tiếp console.log(greet((name) => 'Hi ' + name, 'Tony')) ``` *** ## Lưu ý quan trọng ### 1. Không có hoisting - phải khai báo trước khi dùng Function bình thường được "hoisted" lên đầu file, nên bạn có thể gọi nó trước khi khai báo. Arrow Function thì không. ```js theme={null} regular(); // chạy OK arrow(); // Lỗi: Cannot access 'arrow' before initialization function regular() { console.log('Regular'); } const arrow = () => console.log('Arrow'); ``` ### 2. Không có `this` Đây là điểm khác biệt lớn nhất. Function bình thường tạo ra `this` riêng của nó, trỏ đến object đang gọi hàm. Arrow Function không tạo `this`, nên nó dùng `this` từ scope bên ngoài. ```js theme={null} const person = { name: 'Brad', sayHelloRegular: function () { console.log('Regular:', this.name); // "Brad" }, sayHelloArrow: () => { console.log('Arrow:', this.name); // undefined }, }; person.sayHelloRegular(); // Regular: Brad person.sayHelloArrow(); // Arrow: undefined ``` `sayHelloArrow` không có `this` của riêng nó, nên `this` ở đây trỏ ra scope bên ngoài object - là `{}` (trong Node.js ES Modules) hoặc `window` (trong browser). Cả hai đều không có thuộc tính `name`, nên kết quả là `undefined`. **Khi nào nên dùng function bình thường?** Khi bạn cần truy cập `this`, ví dụ trong object methods hay class methods. Với các callback thuần túy không liên quan đến `this`, Arrow Function là lựa chọn tốt hơn. *** ## Tóm tắt | | Function bình thường | Arrow Function | | :------- | :------------------------------- | :--------------------------------- | | Cú pháp | `function(a, b) { return ... }` | `(a, b) => ...` | | Hoisting | Có | Không | | `this` | Có (trỏ đến object gọi hàm) | Không (dùng `this` từ scope ngoài) | | Dùng khi | Object/class methods, cần `this` | Callback, xử lý dữ liệu | Arrow Function không phải là "phiên bản mới hơn" của function bình thường, mà là một công cụ khác phù hợp với những tình huống khác nhau. Trong thực tế, bạn sẽ dùng Arrow Function cho hầu hết các callback (`.map`, `.filter`, `.then`...), và dùng function bình thường khi cần `this`. Khi đã quen với Arrow Function, bạn sẽ sẵn sàng hơn để đọc code trong React, Node.js, hay bất kỳ JavaScript stack nào khác. # Tổng quan Source: https://www.youhoc.com/javascript/index Đang cập nhật # 10 câu hỏi phỏng vấn Next.js thường gặp Source: https://www.youhoc.com/next-js/10-interview-questions Tổng hợp 10 câu hỏi phỏng vấn Next.js phổ biến từ junior đến mid-level, kèm gợi ý trả lời ngắn gọn, dễ nhớ và bám sát thực tế production. Bạn vừa hoàn thành một project Next.js, tự tin apply vào vị trí Frontend Developer. Đến vòng technical, nhà tuyển dụng hỏi: *"Sự khác nhau giữa Server Component và Client Component là gì? Khi nào bạn dùng cái nào?"* — bạn ấp úng vì lâu nay chỉ copy `'use client'` lên đầu file cho... chắc ăn. Next.js là framework rất "hot" trong các buổi phỏng vấn Frontend hiện nay, nhưng câu hỏi thường xoay quanh **một số ít chủ đề cốt lõi**. Nắm chắc chúng, bạn sẽ tự tin trả lời được 80% các vòng phỏng vấn. Dưới đây là 10 câu hỏi phổ biến nhất, kèm gợi ý cách trả lời ngắn gọn — vừa đủ để gây ấn tượng, không lan man. ## 1. Next.js là gì? Khác gì so với React? Đây gần như là câu "khởi động" trong mọi buổi phỏng vấn. **Gợi ý trả lời:** * **React** là một thư viện UI, chỉ lo phần render component phía client. * **Next.js** là một framework xây trên React, bổ sung những thứ React thuần không có sẵn: file-based routing, Server-Side Rendering (SSR), Static Site Generation (SSG), tối ưu hình ảnh, API Routes, và gần đây nhất là **React Server Components**. Nói ngắn gọn: *React lo phần UI, Next.js lo phần "còn lại" của một ứng dụng web hoàn chỉnh — routing, rendering, fetching, deployment.* ## 2. Phân biệt CSR, SSR, SSG và ISR Câu này kiểm tra bạn có thực sự hiểu **các chiến lược render** hay không. | Chiến lược | Khi nào render? | Use case | | :---------------------------------------- | :--------------------------------- | :------------------------------------------------- | | **CSR** (Client-Side Rendering) | Trong browser, sau khi JS tải xong | Dashboard nội bộ, không cần SEO | | **SSR** (Server-Side Rendering) | Trên server, mỗi request | Trang có data thay đổi liên tục, cần SEO | | **SSG** (Static Site Generation) | Lúc build | Blog, landing page, docs | | **ISR** (Incremental Static Regeneration) | Lúc build + revalidate định kỳ | E-commerce, news — cập nhật không quá thường xuyên | **Mẹo trả lời:** nhấn mạnh rằng trong App Router (Next.js 13+), bạn không còn dùng `getServerSideProps` hay `getStaticProps` nữa — mà điều khiển bằng `fetch` options (`cache`, `next.revalidate`) và **Server Components**. ## 3. Server Components và Client Components khác nhau ở đâu? Đây là câu **dễ bị hỏi nhất** với Next.js 13+. **Gợi ý trả lời:** * **Server Component** (mặc định): chạy trên server, không gửi JS xuống client → bundle nhỏ hơn, fetch data trực tiếp được, không dùng được hook (`useState`, `useEffect`). * **Client Component**: phải khai báo `'use client'` ở đầu file. Chạy trên browser, dùng được hook, event handler, browser API. **Nguyên tắc vàng:** đặt `'use client'` **càng sâu trong cây component càng tốt**. Chỉ "client hoá" những phần thực sự cần tương tác. ```jsx theme={null} // ❌ Sai: client hoá cả Header chỉ vì cần usePathname trong NavLink 'use client'; export default function Header() { /* ... */ } // ✅ Đúng: tách NavLink ra component riêng // header.js (Server Component) import NavLink from './nav-link'; // nav-link.js (Client Component) 'use client'; import { usePathname } from 'next/navigation'; ``` ## 4. File-based routing hoạt động thế nào? Khác gì Pages Router? **Gợi ý trả lời:** Next.js dùng **cấu trúc thư mục làm routing**. Trong App Router: * Mỗi thư mục = một segment URL. * File `page.js` (hoặc `page.tsx`) định nghĩa nội dung trang. * File `layout.js` định nghĩa khung bao quanh, hỗ trợ **nested layouts**. * Các "reserved files" khác: `loading.js`, `error.js`, `not-found.js`, `route.js`. Khác biệt chính so với **Pages Router** (cũ): | Pages Router (`/pages`) | App Router (`/app`) | | :------------------------------------- | :----------------------------- | | `getServerSideProps`, `getStaticProps` | `fetch` trong Server Component | | `_app.js`, `_document.js` | `layout.js` | | Mọi component đều là client | Mặc định Server Component | | `next/router` | `next/navigation` | ## 5. Dynamic Routes là gì? Làm sao truyền params vào trang? **Gợi ý trả lời:** Đặt tên thư mục trong dấu ngoặc vuông để tạo **dynamic segment**: ```text theme={null} /app/blog/[slug]/page.js → /blog/hello-world /app/shop/[...all]/page.js → catch-all: /shop/a/b/c /app/shop/[[...all]]/page.js → optional catch-all ``` Trong component, `params` được truyền vào như một prop: ```jsx theme={null} export default async function BlogDetail({ params }) { const { slug } = await params; // Next.js 15+: params là Promise const post = await getPost(slug); return
{post.title}
; } ``` **Bonus:** đề cập `generateStaticParams()` để pre-render các slug lúc build (SSG cho dynamic routes). ## 6. Khi nào dùng ``, khi nào dùng `useRouter`? **Gợi ý trả lời:** * ``: dùng cho navigation **khai báo** (declarative) — khi user click vào một link. Hỗ trợ **prefetch** tự động và chuyển sang CSR khi đang ở trong app. * `useRouter()` (từ `next/navigation`): dùng cho navigation **lập trình** (programmatic) — sau khi submit form, sau khi đăng nhập thành công, v.v. ```jsx theme={null} 'use client'; import { useRouter } from 'next/navigation'; const router = useRouter(); router.push('/dashboard'); router.replace('/login'); router.refresh(); // refetch Server Component ``` **Lưu ý:** `useRouter` chỉ dùng được trong Client Component. Ở Server Component, dùng `redirect()` từ `next/navigation`. ## 7. Data fetching trong App Router hoạt động ra sao? **Gợi ý trả lời:** Trong App Router, bạn fetch data trực tiếp trong **Server Component** bằng `async/await`: ```jsx theme={null} export default async function PostsPage() { const res = await fetch('https://api.example.com/posts', { next: { revalidate: 60 }, // ISR: cache 60 giây }); const posts = await res.json(); return ; } ``` Có 3 chế độ chính, điều khiển qua option của `fetch`: * `cache: 'force-cache'` (mặc định) → tương đương **SSG** * `cache: 'no-store'` → tương đương **SSR** * `next: { revalidate: N }` → tương đương **ISR** Kết hợp với `loading.js` để hiển thị skeleton trong khi fetch, và `error.js` để xử lý lỗi. ## 8. API Routes / Route Handlers dùng để làm gì? **Gợi ý trả lời:** Next.js cho phép viết **backend ngay trong project** thông qua `route.js` (App Router) hoặc `/pages/api/*` (Pages Router). ```jsx theme={null} // app/api/posts/route.js export async function GET() { const posts = await db.post.findMany(); return Response.json(posts); } export async function POST(request) { const body = await request.json(); const post = await db.post.create({ data: body }); return Response.json(post, { status: 201 }); } ``` **Use case:** * Webhook handler * Proxy che giấu API key * BFF (Backend for Frontend) đơn giản * Xử lý form submit, upload file **Lưu ý:** với mutation đơn giản, **Server Actions** (Next.js 14+) thường gọn hơn Route Handlers. ## 9. Tối ưu performance trong Next.js — bạn làm những gì? Câu này test **kinh nghiệm thực chiến**. Liệt kê được nhiều ý là điểm cộng lớn: * **`` từ `next/image`** — lazy loading, sinh `srcset`, dùng AVIF/WebP tự động. * **`` với prefetch** — tải sẵn route khi link xuất hiện trong viewport. * **`next/font`** — tự host font, tránh layout shift. * **Server Components** — giảm JS bundle gửi xuống client. * **Streaming** với `loading.js` và `` — hiển thị UI từng phần thay vì chờ toàn bộ data. * **Dynamic import** (`next/dynamic`) — code-splitting cho component nặng (chart, editor). * **Tránh `'use client'` ở root** — chỉ client hoá những gì thực sự cần. * **Caching đúng cách** với `fetch` options và `revalidateTag` / `revalidatePath`. ## 10. Bạn deploy Next.js ở đâu? Vì sao? Câu hỏi mang tính **trải nghiệm**, không có đáp án đúng/sai. **Gợi ý trả lời:** * **Vercel**: đơn giản nhất, hỗ trợ đầy đủ mọi tính năng Next.js (ISR, Image Optimization, Edge Functions). Nhược: giá cao khi scale. * **Self-host (Docker + Node.js)**: linh hoạt, kiểm soát chi phí, nhưng phải tự lo Image Optimization, caching, CDN. * **Cloudflare Pages / Netlify / AWS Amplify**: tốt cho dự án nhỏ, nhưng một vài tính năng (ISR, middleware) có thể giới hạn. Nếu có kinh nghiệm thực tế, hãy kể câu chuyện cụ thể: *"Mình từng deploy một blog SSG trên Vercel free tier, sau đó migrate sang VPS dùng Docker để giảm chi phí khi traffic tăng..."* — luôn ăn điểm. ## Một vài golden rules khi trả lời phỏng vấn Next.js * **Đừng trả lời chung chung.** "Next.js có SSR" là chưa đủ — hãy nói **khi nào** dùng SSR và **vì sao**. * **Luôn so sánh với Pages Router.** Người phỏng vấn thường muốn biết bạn có hiểu sự thay đổi từ Next.js 12 → 13+ không. * **Nhắc đến caching.** App Router có hệ thống cache 4 tầng (Request Memoization, Data Cache, Full Route Cache, Router Cache) — nắm sơ là điểm cộng lớn. * **Đừng "phun" buzzword.** Nếu nói "React Server Components" thì phải giải thích được nó là gì, khác Client Component ra sao. * **Sẵn sàng cho câu hỏi sâu hơn.** Sau câu "SSR là gì?" thường sẽ là "Streaming SSR khác gì SSR truyền thống?". ## Tổng kết 10 câu hỏi trên gần như bao trùm các chủ đề bạn sẽ gặp trong phỏng vấn Next.js cho vị trí Junior đến Mid-level: 1. Next.js vs React 2. CSR / SSR / SSG / ISR 3. Server vs Client Components 4. File-based routing & App vs Pages Router 5. Dynamic Routes 6. `` vs `useRouter` 7. Data fetching trong App Router 8. API Routes / Route Handlers 9. Tối ưu performance 10. Deployment Nếu muốn đi xa hơn, hãy tìm hiểu thêm các chủ đề nâng cao thường xuất hiện ở vòng senior: * **Server Actions** và `useFormState`, `useFormStatus` * **Middleware** và **Edge Runtime** * **Caching strategies**: `revalidateTag`, `revalidatePath`, `unstable_cache` * **Parallel Routes** và **Intercepting Routes** * **Internationalization (i18n)** trong App Router Chúc bạn pass phỏng vấn dễ dàng! # 9 điều cơ bản đầu tiên khi đến với Next.js Source: https://www.youhoc.com/next-js/9-starter-notes Next.js không thay thế React — nó là một framework xây dựng trên nền React, bổ sung những thứ mà React “vanilla” không có sẵn: Server-Side Rendering (SSR), file-based routing, tối ưu hình ảnh, và nhiều hơn nữa. Bạn đã quen với React — tạo component, quản lý state, render UI. Nhưng khi ứng dụng cần SEO tốt hơn, tốc độ tải trang nhanh hơn, hay routing không cần cài thêm thư viện… bạn bắt đầu nghe đến **Next.js**. Next.js không thay thế React — nó là một framework *xây dựng trên nền React*, bổ sung những thứ mà React “vanilla” không có sẵn: Server-Side Rendering (SSR), file-based routing, tối ưu hình ảnh, và nhiều hơn nữa. Bài viết này tóm tắt 11 khái niệm cốt lõi bạn sẽ gặp ngay trong bất cứ buổi học đầu tiên nào về Next.js. ## 1. Làm quen với “file-based routing”: tạo route bằng folder Với React Router, bạn phải khai báo route bằng code. Next.js làm điều đó theo cách khác: **cấu trúc thư mục chính là routing**. ```text theme={null} /app ├── page.js → example.com/ ├── /about │ └── page.js → example.com/about └── /blog ├── page.js → example.com/blog └── /post-1 └── page.js → example.com/blog/post-1 ``` Mỗi thư mục là một segment của URL. Muốn có route mới? Tạo thư mục mới và đặt `page.js` bên trong. ## 2. Hai “reserved files” quan trọng nhất: `page.js` và `layout.js` Next.js có một hệ thống **reserved filenames** — những tên file đặc biệt mà framework nhận ra và xử lý theo cách riêng. ```text theme={null} /app ├── layout.js ← app shell (master layout, bao toàn bộ app) ├── page.js └── /about ├── layout.js ← nested layout, chỉ áp dụng cho /about └── page.js ``` Hai file cơ bản nhất: * `page.js` — định nghĩa nội dung của một trang, đây là file bắt buộc để một route được render. * `layout.js` — định nghĩa “khung” bao quanh trang, ví dụ header, footer, sidebar. Layout lồng nhau (nested layout) cũng được hỗ trợ — `layout.js` trong thư mục con chỉ áp dụng cho trang đó và các trang con của nó. Trong `layout.js` thường có metadata, thẻ `` và ``, và `{children}` — placeholder để render các trang con. ## 3. Các reserved filenames khác cần biết Next.js còn nhiều tên file đặc biệt khác: | File | Tác dụng | | :------------- | :--------------------------------------------- | | `not-found.js` | Hiển thị khi route không tồn tại (lỗi 404) | | `error.js` | Fallback khi có lỗi xảy ra | | `loading.js` | Hiển thị trong khi trang đang fetch data | | `route.js` | Tạo API endpoint (trả về JSON, không phải JSX) | | `icon.png` | Tự động dùng làm favicon | Chi tiết đầy đủ tại [tài liệu chính thức của Next.js](https://nextjs.org/docs/app/api-reference/file-conventions). ## 4. Component `` — navigate thông minh hơn Thay vì dùng thẻ `` thông thường, Next.js cung cấp component ``: ```jsx theme={null} import Link from 'next/link'; About ``` Điểm hay của việc sử dụng Link là khi truy cập trực tiếp vào URL, Next.js sẽ **SSR** (render phía server). Khi bạn đang đứng trong ứng dụng và click link, nó chuyển sang **CSR** — chỉ load phần component của route mới, không reload toàn trang. Tốt nhất của cả hai thế giới. ## 5. Dynamic Routes — route với tham số động Để xử lý các URL dạng `/blog/post-1`, `/blog/post-2`… bạn có thể dùng **dynamic segment** bằng cách đặt tên thư mục trong dấu ngoặc vuông: ```text theme={null} /app └── /blog ├── page.js └── /[slug] └── page.js → example.com/blog/:slug ``` Trong component, `params` được truyền vào như một prop mặc định: ```jsx theme={null} export default function BlogDetail({ params }) { return

{params.slug}

; } ``` ## 6. Tổ chức file — `/components` nên đặt ngoài `/app` Thư mục `/app` chỉ nên chứa các routing files. Custom components nên đặt **bên ngoài** thư mục `/app`: ```text theme={null} /app ├── layout.js └── page.js /components ← đặt ở ngoài /app └── header.js ``` Lý do: thư mục trong `/app` mà không có `page.js` sẽ không tạo ra route — nhưng để tránh nhầm lẫn, tốt hơn là tách biệt hoàn toàn. Để import cho gọn, dùng **path alias** `@` bằng cách cấu hình `jsconfig.json`: ```json theme={null} { "compilerOptions": { "paths": { "@/*": ["./*"] } } } ``` Sau đó import như sau: `import Header from '@/components/header'`. ## 7. CSS Modules — tránh xung đột class name Bên cạnh `global.css` như mọi app, Next.js hỗ trợ sẵn CSS Modules. Mỗi component có file CSS riêng: ```text theme={null} /components ├── header.js └── header.module.css ``` ```jsx theme={null} import classes from '@/components/header.module.css'; Home ``` Class name sẽ được tự động scope theo component, tránh hoàn toàn việc “trùng tên class” giữa các component khác nhau. ## 8. Component `` — tối ưu hình ảnh tự động Đừng dùng thẻ `` thông thường trong Next.js. Hãy dùng `` từ `next/image`: ```jsx theme={null} import Image from 'next/image'; import logo from '@/assets/logo.png'; ``` Next.js tự động xử lý: lazy loading, tối ưu chất lượng và kích thước, sinh `srcset` cho responsive… Thêm prop `priority` cho những ảnh quan trọng (hiển thị ngay khi load trang, không lazy load). > **Lưu ý quan trọng:** Chỉ những ảnh được **import tĩnh** (xử lý lúc build time) mới tận dụng được đầy đủ tính năng này.\ > Ảnh lấy từ database lúc runtime sẽ không có sẵn metadata `width`, `height` — bạn cần truyền thủ công hoặc dùng prop `fill`. ## 9. Server Components và Client Components Đây là khái niệm **khác biệt lớn nhất** so với React thuần. * Với `create-react-app`, tất cả component đều là **Client Components** — chạy trên browser. * Với Next.js, tất cả component **mặc định là React Server Components (RSC)** — render phía server. Bằng chứng đơn giản: thử `console.log` trong một component Next.js — bạn sẽ thấy log xuất hiện ở terminal của dev server, **không phải** trong DevTools của browser. ### Sử dụng `'use client'` để khai báo Client Component Do đó, khi bạn cần dùng các React hook như `useState`, `useEffect`, `usePathname`… bạn **bắt buộc** phải khai báo component đó là Client Component: ```jsx theme={null} 'use client'; import { useState, useEffect } from 'react'; import Image from 'next/image'; import burgerImg from '@/assets/burger.jpg'; import curryImg from '@/assets/curry.jpg'; import dumplingsImg from '@/assets/dumplings.jpg'; const images = [ { image: burgerImg, alt: 'A delicious, juicy burger' }, { image: curryImg, alt: 'A delicious, spicy curry' }, { image: dumplingsImg, alt: 'Steamed dumplings' }, ]; export default function ImageSlideshow() { const [currentImageIndex, setCurrentImageIndex] = useState(0); useEffect(() => { const interval = setInterval(() => { setCurrentImageIndex((prevIndex) => prevIndex < images.length - 1 ? prevIndex + 1 : 0 ); }, 5000); return () => clearInterval(interval); }, []); return (
{images.map((image, index) => ( {image.alt} ))}
); } ``` Nếu quên khai báo `'use client'`, Next.js sẽ báo lỗi ngay: ```text theme={null} You're importing a component that needs useState. This only works in a Client Component... ``` ### Quy tắc: Đặt `'use client'` đúng chỗ, càng sâu càng tốt **Một sai lầm phổ biến: đặt** `'use client'` **ở component cha, kéo theo toàn bộ cây component bên dưới trở thành Client Component — mất đi lợi ích của SSR.** **Best practice:** tách logic cần client-side ra thành component con riêng, đặt `'use client'` ở đó, và dùng nó trong Server Component: ```jsx theme={null} // nav-link.js — client component nhỏ gọn 'use client'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; export default function NavLink({ href, children }) { const path = usePathname(); return ( {children} ); } ``` ```jsx theme={null} // header.js — vẫn là Server Component // nếu đặt 'use client' ở header để sử dụng được 'usePathname', sẽ biến header thành client-component import NavLink from './nav-link'; export default function Header() { return ( ); } ``` Nguyên tắc: **chỉ “client hoá” những gì thực sự cần tương tác phía client**. ## Tổng kết Sau buổi đầu tiên với Next.js, bạn đã nắm được những nền tảng quan trọng: * **Routing** dựa trên cấu trúc thư mục, với các reserved files như `page.js`, `layout.js`, `error.js`… * `` để navigate thông minh giữa SSR và CSR * **Dynamic routes** với `[slug]` * **Tổ chức dự án** với `/components` bên ngoài `/app`, dùng `@` alias * **CSS Modules** để scope style theo component * `` để tối ưu hình ảnh tự động * **Server vs Client Components** — mặc định là Server, dùng `'use client'` khi cần hook, và đặt càng sâu càng tốt Chủ đề tiếp theo: [**Data Fetching**](https://coda.io/d/_dINStbw4UsM/_sudiga0V). # Tự làm Image Slideshow trong React, không cần SwipeJS Source: https://www.youhoc.com/next-js/build-simple-slideshow Trong bài này, chúng ta sẽ tự xây từ con số 0 — bắt đầu từ bản đơn giản nhất, rồi lần lượt thêm nút điều hướng, numbered buttons, và cuối cùng là progress bar kiểu Stories cho ngầu. Khi cần một slideshow, phản xạ đầu tiên của nhiều người là mở npm lên và cài SwiperJS. Hoàn toàn hợp lý — Swiper mạnh, có sẵn mọi thứ. Nhưng nếu bạn chỉ cần một slideshow đơn giản, không có touch gesture, không có lazy load phức tạp, thì một vài chục dòng React là đủ. Trong bài này, chúng ta sẽ tự xây từ con số 0 — bắt đầu từ bản đơn giản nhất, rồi lần lượt thêm nút điều hướng, numbered buttons, và cuối cùng là progress bar kiểu Stories cho ngầu. ## Bước 1 — Slideshow cơ bản (chỉ CSS transition) Ý tưởng cốt lõi rất đơn giản: render tất cả ảnh ra cùng lúc, nhưng chỉ một ảnh có `opacity: 1`, các ảnh còn lại `opacity: 0`. Mỗi 5 giây, chuyển class `active` sang ảnh tiếp theo. ```tsx components/ImageSlideshow.tsx theme={null} 'use client' import { useState } from 'react' import Image from 'next/image' import burgerImg from '@/assets/burger.jpg' import curryImg from '@/assets/curry.jpg' import pizzaImg from '@/assets/pizza.jpg' import classes from './ImageSlideshow.module.css' const images = [ { src: burgerImg, alt: 'A juicy burger' }, { src: curryImg, alt: 'A spicy curry' }, { src: pizzaImg, alt: 'A fresh pizza' }, ] export default function ImageSlideshow() { const [currentIndex, setCurrentIndex] = useState(0) return (
{images.map((image, index) => ( {image.alt} ))}
) } ``` ```css ImageSlideshow.module.css theme={null} .slideshow { position: relative; width: 100%; height: 400px; border-radius: 8px; overflow: hidden; } .slideshow img { width: 100%; height: 100%; object-fit: cover; position: absolute; /* xếp chồng tất cả ảnh lên nhau */ top: 0; left: 0; opacity: 0; transform: scale(1.05); transition: opacity 0.5s ease, transform 0.5s ease; } .slideshow .active { opacity: 1; transform: scale(1); z-index: 1; } ``` Hiện tại `currentIndex` không bao giờ thay đổi — slideshow đang đứng yên. Thêm `useEffect` để tự động chuyển ảnh: ```tsx theme={null} import { useState, useEffect } from 'react' // bên trong component: useEffect(() => { const interval = setInterval(() => { setCurrentIndex(prev => (prev + 1) % images.length) }, 5000) return () => clearInterval(interval) // dọn dẹp khi unmount }, []) ``` `% images.length` là trick để tự động quay vòng — khi index đến cuối mảng sẽ về lại 0. Giả sử có 3 ảnh, `images.length = 3`, index hợp lệ là `0, 1, 2`: ``` index 0 → (0 + 1) % 3 = 1 ✓ index 1 → (1 + 1) % 3 = 2 ✓ index 2 → (2 + 1) % 3 = 3 % 3 = 0 ← quay về đầu ``` `%` (modulo) trả về **phần dư** của phép chia. `3 % 3 = 0` vì 3 chia 3 dư 0. `4 % 3 = 1` vì 4 chia 3 dư 1 — nên nó luôn giữ kết quả trong khoảng `0` đến `length - 1`. Tương tự cho nút Prev, thêm `images.length` trước để tránh số âm: ``` index 0 → (0 - 1 + 3) % 3 = 2 % 3 = 2 ← quay về cuối index 1 → (1 - 1 + 3) % 3 = 3 % 3 = 0 ✓ index 2 → (2 - 1 + 3) % 3 = 4 % 3 = 1 ✓ ``` Nếu không cộng `+ 3`, `(0 - 1) % 3 = -1` — index âm, không dùng được. ## Bước 2 — Thêm nút Prev / Next Thêm hai hàm điều hướng và render hai nút: ```tsx theme={null} export default function ImageSlideshow() { const [currentIndex, setCurrentIndex] = useState(0) useEffect(() => { const interval = setInterval(() => { setCurrentIndex(prev => (prev + 1) % images.length) }, 5000) return () => clearInterval(interval) }, []) function goToPrev() { setCurrentIndex(prev => (prev - 1 + images.length) % images.length) } function goToNext() { setCurrentIndex(prev => (prev + 1) % images.length) } return (
{images.map((image, index) => ( {image.alt} ))}
) } ``` ```css theme={null} .prev, .next { position: absolute; top: 50%; transform: translateY(-50%); z-index: 2; background: rgba(0, 0, 0, 0.4); color: white; border: none; padding: 0.5rem 1rem; font-size: 1.5rem; cursor: pointer; border-radius: 4px; transition: background 0.2s; } .prev:hover, .next:hover { background: rgba(0, 0, 0, 0.7); } .prev { left: 0.75rem; } .next { right: 0.75rem; } ``` Lưu ý công thức `(prev - 1 + images.length) % images.length` cho nút Prev — cộng thêm `images.length` trước khi mod để tránh kết quả âm khi `prev = 0`. ## Bước 3 — Thêm Numbered Slide Buttons (Dots) Thêm một row dots phía dưới, mỗi dot tương ứng một slide: ```tsx theme={null} return (
{/* ... ảnh và nút prev/next ... */}
{images.map((_, index) => (
) ``` ```css theme={null} .dots { position: absolute; bottom: 1rem; left: 50%; transform: translateX(-50%); z-index: 2; display: flex; gap: 0.5rem; } .dot { width: 10px; height: 10px; border-radius: 50%; border: none; background: rgba(255, 255, 255, 0.5); cursor: pointer; padding: 0; transition: background 0.2s, transform 0.2s; } .dot:hover { background: rgba(255, 255, 255, 0.8); } .dotActive { background: white; transform: scale(1.25); } ``` Khi click vào một dot, `setCurrentIndex(index)` đưa trực tiếp đến slide đó — không cần next/prev liên tục. ## Bước 4 — Auto-play với Progress Bar kiểu Stories Đây là phần thú vị nhất. Thay vì một thanh progress bar “đếm ngược” chung cho toàn bộ slideshow, chúng ta làm theo kiểu Instagram Stories: **mỗi slide có một thanh riêng**, thanh của slide đang hiển thị sẽ fill từ 0% đến 100% trong 5 giây. Trick ở đây là dùng **CSS** `@keyframes` **animation** kết hợp với `key` prop của React. Khi `key` thay đổi, React unmount và remount element — điều này buộc animation CSS restart từ đầu. ```tsx theme={null} export default function ImageSlideshow() { const [currentIndex, setCurrentIndex] = useState(0) useEffect(() => { const interval = setInterval(() => { setCurrentIndex(prev => (prev + 1) % images.length) }, 5000) return () => clearInterval(interval) }, []) function goToPrev() { setCurrentIndex(prev => (prev - 1 + images.length) % images.length) } function goToNext() { setCurrentIndex(prev => (prev + 1) % images.length) } return (
{/* Progress bars */}
{images.map((_, index) => (
{index === currentIndex && ( // key={currentIndex} buộc React remount element này // mỗi khi slide đổi → animation restart từ đầu
)} {index < currentIndex && ( // slide đã xem: fill 100% tĩnh
)}
))}
{/* Ảnh */} {images.map((image, index) => ( {image.alt} ))}
) } ``` ```css theme={null} /* Progress bar Stories */ .progressBars { position: absolute; top: 0.75rem; left: 0.75rem; right: 0.75rem; z-index: 2; display: flex; gap: 4px; } .progressTrack { flex: 1; height: 3px; background: rgba(255, 255, 255, 0.35); border-radius: 2px; overflow: hidden; } .progressFill { height: 100%; background: white; border-radius: 2px; width: 0%; animation: fillProgress 5s linear forwards; } .progressFillComplete { height: 100%; background: white; border-radius: 2px; width: 100%; } @keyframes fillProgress { from { width: 0%; } to { width: 100%; } } ``` Animation `fillProgress` chạy trong `5s` — khớp với interval của `setInterval`. Mỗi lần slide đổi, `key={currentIndex}` thay đổi và React remount `
`, animation bắt đầu lại từ 0%. Các slide đã xem (`index < currentIndex`) hiển thị thanh trắng đầy tĩnh. Các slide chưa đến thì chỉ có track xám. ## Lưu ý thực tế **Đừng để interval và animation lệch nhau.** Nếu `setInterval` là 5000ms mà animation là 4s, thanh sẽ đầy trước khi slide chuyển — trông sẽ lạ. Luôn giữ hai giá trị này đồng bộ. Cách tốt hơn là define một constant: ```tsx theme={null} const SLIDE_DURATION = 5000 // ms // dùng cho cả interval... setInterval(() => { ... }, SLIDE_DURATION) // ...và CSS (inline style hoặc CSS variable)
``` **Pause khi hover.** Nếu muốn dừng slideshow khi người dùng hover vào, dùng `onMouseEnter` / `onMouseLeave` để clear và set lại interval. Cách gọn nhất là dùng `useRef` để giữ reference của interval thay vì `useEffect` thuần. **Swipe trên mobile.** Code trên không có touch gesture. Nếu cần, đây là lúc cân nhắc SwiperJS hoặc `react-swipeable` — đừng tự handle `touchstart`/`touchmove` bằng tay nếu không có nhu cầu học sâu về nó. ## Tóm tắt | Version | Tính năng thêm | Kỹ thuật chính | | -------------- | -------------------------------- | ----------------------------------------- | | Cơ bản | Tự chạy | `setInterval` + CSS `opacity` | | + Prev/Next | Điều hướng thủ công | Modulo `%` để wrap index | | + Dots | Click thẳng vào slide | Map index → button | | + Progress bar | Feedback trực quan Stories-style | CSS `@keyframes` + React `key` để restart | Từ một slideshow tự chạy đến Stories-style chỉ cần khoảng 80 dòng code và không có dependency nào ngoài React. Khi nào cần thêm touch, infinite loop mượt hơn, hay lazy load — lúc đó mới cần đến Swiper. # Tự xây dựng Image Picker có preview trong React Source: https://www.youhoc.com/next-js/build-your-own-image-picker Bài này dùng Next.js với Typescript làm ví dụ, nhưng toàn bộ logic có thể dùng trong bất kỳ React app nào. Hầu hết các form upload ảnh bạn thấy đều trông như thế này: một ô `` xấu xí, không có preview, user không biết mình vừa chọn ảnh gì. Bài này sẽ xây từng bước một custom `ImagePicker` component có giao diện riêng và preview ảnh trước khi submit — chỉ với `useRef`, `useState`, và `useEffect`. Không cần thư viện ngoài. ## Bước 1 — Bắt đầu với input cơ bản Tạo component `ImagePicker` nhận vào `title` (label hiển thị) và `name` (dùng cho `id` và `name` của input): ```tsx theme={null} // components/image-picker.tsx export default function ImagePicker({ title, name, }: { title: string; name: string; }) { return (
); } ``` Dùng trong form: ```tsx theme={null} ``` Đây là điểm xuất phát. Bây giờ bắt đầu nâng cấp dần. ## Bước 2 — Ẩn input, thêm button tùy chỉnh File input mặc định của trình duyệt rất khó style. Cách phổ biến là **ẩn nó đi**, rồi dùng một button tùy chỉnh để trigger click vào input ẩn đó thông qua `useRef`. Vì dùng `useRef` và các DOM event, component này phải là **Client Component**: ```tsx theme={null} 'use client'; import { useRef } from "react"; export default function ImagePicker( { title, name }: { title: string; name: string } ) { const imageInputRef = useRef(null); const handlePickClick = () => { imageInputRef.current?.click(); }; return (
{/* Input ẩn — vẫn hoạt động bình thường khi submit form */} {/* Button tùy chỉnh thay thế input */}
); } ``` `useRef` ở đây không để lưu state — nó lưu **tham chiếu trực tiếp đến DOM node** của input ẩn. Khi button được click, ta gọi `.click()` trên input đó, trình duyệt sẽ mở hộp thoại chọn file như bình thường. > Dùng `type="button"` cho button để tránh nó vô tình submit form khi click. ## Bước 3 — Preview ảnh đã chọn Khi người dùng chọn file, sự kiện `onChange` của input được kích hoạt. Từ đó lấy file và tạo URL tạm thời để hiển thị preview bằng `URL.createObjectURL()`: ```tsx theme={null} // sử dụng useState để lưu previewUrl const [previewUrl, setPreviewUrl] = useState(null); const handleImageChange = (event: React.ChangeEvent) => { // kiểm tra file input const files = event.target.files; if (!files || files.length === 0) return; // chỉ xử lý file đầu tiên const file = files[0]; setPreviewUrl(URL.createObjectURL(file)); }; ``` `URL.createObjectURL(file)` tạo ra một URL dạng `blob:http://...` trỏ thẳng đến file trong bộ nhớ — không cần upload lên server, hiển thị ngay lập tức. Thêm preview vào JSX: ```tsx theme={null} import Image from "next/image"; {previewUrl && (
Preview
)} ``` Và bind handler vào input: ```tsx theme={null} ``` ## Bước 4 — Dọn dẹp bộ nhớ với `useEffect` `URL.createObjectURL()` cấp phát bộ nhớ cho mỗi URL tạo ra. Nếu không giải phóng, những URL cũ bị ghi đè (khi chọn ảnh mới) hoặc component bị unmount sẽ vẫn chiếm bộ nhớ — gọi là **memory leak**. Dùng `useEffect` để cleanup: ```tsx theme={null} useEffect(() => { return () => { if (previewUrl) { URL.revokeObjectURL(previewUrl); } }; }, [previewUrl]); ``` Hàm trả về trong `useEffect` là **cleanup function** — React tự động gọi nó trước mỗi lần effect chạy lại (tức là mỗi khi `previewUrl` thay đổi), và khi component unmount. Như vậy URL cũ luôn được giải phóng đúng lúc. ### Ghi chú dành cho những người mới về `useEffect` `return` **một function trong** `useEffect` **là cú pháp đặc biệt của hook này** — không phải return giá trị thông thường. Hàm được truyền vào `return` bên trong `useEffect` sẽ **chỉ được gọi vào đúng 2 thời điểm**: 1. Trước khi effect chạy lại (tức là `previewUrl` vừa thay đổi, chuẩn bị chạy effect mới) 2. Khi component unmount ```tsx theme={null} useEffect(() => { // Effect chạy sau mỗi lần previewUrl thay đổi return () => { // Cleanup chạy TRƯỚC lần effect tiếp theo, hoặc khi unmount if (previewUrl) { URL.revokeObjectURL(previewUrl); } }; }, [previewUrl]); ``` Trình tự thực tế khi người dùng chọn ảnh lần 2: ```text theme={null} [chọn ảnh lần 1] → effect chạy (previewUrl = "blob://...aaa") [chọn ảnh lần 2] → cleanup chạy (revoke "blob://...aaa") → effect chạy (previewUrl = "blob://...bbb") [unmount] → cleanup chạy (revoke "blob://...bbb") ``` URL cũ luôn được dọn trước khi URL mới được tạo — đúng thứ tự, không leak. Nếu muốn viết tường minh hơn thay vì dùng arrow function, cách đúng là: ```tsx theme={null} useEffect(() => { function cleanup() { if (previewUrl) { URL.revokeObjectURL(previewUrl); } } return cleanup; // return tham chiếu đến function, không gọi nó }, [previewUrl]); ``` `return cleanup` khác với `return cleanup()` — cái trước trả về function để React gọi sau, cái sau gọi ngay và trả về `undefined`. ## Bước 5 — Nút xóa ảnh đã chọn ```tsx theme={null} const handleReset = () => { setPreviewUrl(null); if (imageInputRef.current) { imageInputRef.current.value = ""; // reset giá trị input file } }; // Hiển thị nút chỉ khi đã có ảnh {previewUrl && ( )} ``` Cần reset cả `imageInputRef.current.value` — nếu không, input vẫn giữ file cũ trong nội bộ dù preview đã bị xóa, dẫn đến submit sẽ vẫn gửi file đó. ## Full component ```tsx theme={null} 'use client'; import { useRef, useState, useEffect } from "react"; import Image from "next/image"; export default function ImagePicker({ title, name, }: { title: string; name: string; }) { const imageInputRef = useRef(null); const [previewUrl, setPreviewUrl] = useState(null); const handlePickClick = () => { imageInputRef.current?.click(); }; const handleImageChange = (event: React.ChangeEvent) => { const files = event.target.files; if (!files || files.length === 0) return; setPreviewUrl(URL.createObjectURL(files[0])); }; const handleReset = () => { setPreviewUrl(null); if (imageInputRef.current) imageInputRef.current.value = ""; }; useEffect(() => { return () => { if (previewUrl) URL.revokeObjectURL(previewUrl); }; }, [previewUrl]); return (
{previewUrl && (
Preview
)}
{previewUrl && ( )}
); } ``` ## Tóm tắt Bài này là ví dụ thực tế cho thấy ba hook phối hợp với nhau: * `useRef` — tham chiếu đến DOM node để trigger click programmatically * `useState` — lưu URL preview, khi thay đổi sẽ re-render hiển thị ảnh mới * `useEffect` — cleanup `blob URL` để tránh memory leak Đây cũng là một trong những trường hợp điển hình **phải dùng Client Component** trong Next.js — vì cần tương tác với DOM và Browser API (`URL.createObjectURL`). Vậy là xong — bạn đã có một image picker xịn, có preview, tự tay xây từ đầu. Biết làm từ gốc rễ như vậy, sau này dù dùng component có sẵn từ thư viện nào đó, bạn cũng hiểu bên dưới nó đang làm gì. Thử thách tiếp theo: nâng cấp component này để **preview nhiều ảnh cùng lúc** — lúc đó `previewUrl` sẽ là một mảng, và cleanup cũng cần xử lý tất cả các URL trong mảng đó. # Next.js Caching: Hướng dẫn thực chiến cho production Source: https://www.youhoc.com/next-js/cache-layers Bạn deploy xong, mở trang lên thấy dữ liệu vẫn cũ. Bạn đã gọi `revalidatePath`, đã `router.refresh()`, thậm chí clear hết cookie mà vẫn thế. Hoặc ngược lại: trang load chậm vì data cứ fetch đi fetch lại mỗi lần ai vào. Cả hai vấn đề đều xuất phát từ một chỗ: **không hiểu rõ Next.js đang cache ở đâu, và theo quy tắc nào.** Để giải quyết vấn đề này, bạn cần hiểu 4 lớp Cache của Next.js. ## 4 lớp cache của Next.js Next.js không chỉ có một “cache” — nó có bốn lớp, mỗi lớp hoạt động độc lập: | Lớp | Nằm ở đâu | Tồn tại bao lâu | | :------------------ | :------------ | :--------------------- | | Request Memoization | Server (RAM) | 1 request | | Data Cache | Server (disk) | Cho đến khi revalidate | | Full Route Cache | Server (disk) | Cho đến khi revalidate | | Router Cache | Client (RAM) | Phiên duyệt web | ### 1. Request Memoization Đây là tính năng của React, không phải riêng Next.js. Trong một lần render, nếu nhiều component cùng gọi `fetch` với URL và options giống nhau, React chỉ thực sự gọi request đó **một lần duy nhất** — các lần sau trả về kết quả đã lưu trong RAM. ```tsx theme={null} // Component A const data = await fetch('https://api.example.com/posts') // Component B — cùng URL, cùng options → không tốn thêm request const data = await fetch('https://api.example.com/posts') ``` Cache này tự xóa sau mỗi request. Bạn không cần quản lý gì. > **Lưu ý:** Request Memoization chỉ áp dụng cho GET, chỉ chạy trên server component, và không hoạt động trong route handler (`route.ts`). Nếu bạn dùng Drizzle / Prisma / DB trực tiếp thay vì `fetch`, hãy dùng hàm `cache` của React: ```tsx theme={null} import { cache } from 'react' export const getUser = cache(async (id: string) => { return db.query.users.findFirst({ where: eq(users.id, id) }) }) ``` Trường hợp này thường gặp nhất là khi bạn cần phải lấy thông tin của một trang 2 lần: một lần cho hàm `generateMetadata`, và một lần cho chính component của trang đó. Tôi có đề cập đến ở bài trước về [Metadata trong Next.js](/next-js/metadata). ### 2. Data Cache Đây là cache phía server, lưu kết quả `fetch` **giữa các request khác nhau** — kể cả người dùng khác nhau. Dữ liệu tồn tại trên disk cho đến khi bạn chủ động revalidate. Đây thường là nguyên nhân chính khiến “dữ liệu không chịu cập nhật dù backend đã thay đổi.” ### 3. Full Route Cache Next.js có thể render sẵn toàn bộ một trang (HTML + RSC Payload) lúc build, rồi serve thẳng file tĩnh đó cho tất cả request. Đây là cách các trang tĩnh của bạn load cực nhanh. ### 4. Router Cache (Client Cache) Khi bạn điều hướng giữa các trang trong Next.js, trình duyệt lưu RSC Payload của các trang đã ghé thăm trong RAM. Chuyển qua lại giữa các trang sẽ không cần gọi server. Đây là lý do Next.js cảm giác nhanh như SPA. ## Sự thay đổi qua các phiên bản Đây là điểm **dễ gây nhầm lẫn nhất** khi đọc tài liệu cũ hoặc tutorial từ thời Next.js 14. ### Next.js 14: Cache everything by default `fetch` mặc định dùng `force-cache` — tức là toàn bộ data đều được cache trừ khi bạn chủ động opt-out. Nghe có vẻ tốt cho performance, nhưng thực tế gây ra vô số bug khó debug. ```tsx theme={null} // Next 14: mặc định cache, data có thể stale const res = await fetch('https://api.example.com/posts') // Muốn không cache mới phải thêm const res = await fetch('https://api.example.com/posts', { cache: 'no-store' }) ``` ### Next.js 15: Dynamic by default Next.js 15 đảo ngược hoàn toàn: **không cache gì cả** trừ khi bạn opt-in. `fetch` mặc định là `no-store`. Router Cache cho page segment cũng bị tắt theo mặc định. ```tsx theme={null} // Next 15: mặc định không cache const res = await fetch('https://api.example.com/posts') // Muốn cache mới phải thêm const res = await fetch('https://api.example.com/posts', { cache: 'force-cache' }) ``` ### Next.js 16: Explicit caching với `use cache` Next.js 16 giới thiệu **Cache Components** — một mô hình hoàn toàn mới, tường minh hơn nhiều. Thay vì config qua option của `fetch`, bạn dùng directive `'use cache'` trực tiếp trong function hoặc component. Đây là thay đổi lớn nhất trong lịch sử caching của Next.js, và là hướng đi cho tương lai. ## Caching trong Next.js 16 với `use cache` ### Bật tính năng Thêm vào `next.config.ts`: ```typescript theme={null} import type { NextConfig } from 'next' const nextConfig: NextConfig = { cacheComponents: true, } export default nextConfig ``` ### Cache ở cấp độ data (function) Dùng khi bạn muốn cache kết quả của một hàm fetch hoặc DB query: ```tsx theme={null} import { cacheLife } from 'next/cache' export async function getPosts() { 'use cache' cacheLife('hours') return db.query('SELECT * FROM posts') } ``` Thay vì dùng số giây như trước (`revalidate: 3600`), bạn dùng **semantic profiles**: | Profile | Ý nghĩa thực tế | | :---------- | :----------------------- | | `'seconds'` | Revalidate sau vài giây | | `'minutes'` | Revalidate sau vài phút | | `'hours'` | Revalidate sau vài tiếng | | `'days'` | Revalidate sau vài ngày | | `'weeks'` | Revalidate sau vài tuần | ### Cache ở cấp độ component/page ```tsx theme={null} import { cacheLife } from 'next/cache' export default async function BlogPage() { 'use cache' cacheLife('days') const posts = await db.query('SELECT * FROM posts') return (
    {posts.map(post => (
  • {post.title}
  • ))}
) } ``` Directive `'use cache'` có thể đặt ở đầu file (cache toàn bộ exports), hoặc ngay trong function/component cụ thể. ### Cache với tag để revalidate theo nhu cầu ```tsx theme={null} import { cacheLife, cacheTag } from 'next/cache' export async function getPosts() { 'use cache' cacheLife('hours') cacheTag('posts') // gán tag cho cache này return db.query('SELECT * FROM posts') } ``` Khi có bài viết mới được tạo, gọi `revalidateTag` trong Server Action: ```tsx theme={null} 'use server' import { revalidateTag } from 'next/cache' export async function createPost(data: FormData) { await db.insert(posts).values({ ... }) revalidateTag('posts') // xóa toàn bộ cache có tag 'posts' } ``` ## Còn `fetch` options thì sao? Nếu bạn **không dùng** `cacheComponents: true`, cách cũ vẫn hoạt động và vẫn là mặc định: ```tsx theme={null} // Không cache (mặc định Next 15+) const res = await fetch(url, { cache: 'no-store' }) // Cache có revalidate theo thời gian const res = await fetch(url, { next: { revalidate: 60 } }) // Cache có tag const res = await fetch(url, { next: { tags: ['posts'] } }) ``` Cách này vẫn hợp lệ, nhưng giới hạn ở `fetch` — không dùng được cho DB query trực tiếp. Đó là lý do `'use cache'` ra đời để giải quyết triệt để. ## Best practices cho production **Trang hoàn toàn tĩnh** (giới thiệu, landing page): Dùng `'use cache'` với `cacheLife('weeks')` hoặc `cacheLife('days')`. Chỉ rebuild khi có cập nhật nội dung. **Trang hỗn hợp** (blog, danh sách sản phẩm): Cache function lấy dữ liệu chính, phần động (số lượt xem, giỏ hàng) để uncached. Đây là sức mạnh của Partial Prerendering — bạn không cần chọn “tất cả static” hoặc “tất cả dynamic” nữa. ```tsx theme={null} export default async function ProductPage({ params }) { const product = await getCachedProduct(params.id) // có 'use cache' return (
Loading...
}> {/* uncached */}
) } ``` **Trang cá nhân hóa** (dashboard, profile): Không cache page-level. Chỉ cache những dữ liệu chung ít thay đổi như danh sách menu, cấu hình hệ thống. **Lưu ý quan trọng:** `'use cache'` không thể đọc `cookies()` hay `headers()` trực tiếp bên trong. Hãy đọc chúng bên ngoài, rồi truyền vào như argument: ```tsx theme={null} // ❌ Sai export async function getUserData() { 'use cache' const cookieStore = await cookies() // lỗi! } // ✅ Đúng const cookieStore = await cookies() const userId = cookieStore.get('userId')?.value const user = await getCachedUser(userId) // truyền vào như argument ``` **Test cache đúng cách:** Dev mode không cache dữ dội như production. Để kiểm tra thực tế, hãy chạy `npm run build` và `npm start`, sau đó dùng `console.log` trong server component để xem component có bị re-render hay không. ## Tóm tắt Next.js có bốn lớp cache: Request Memoization (per-request), Data Cache (persistent server), Full Route Cache (trang tĩnh), Router Cache (client). Ba phiên bản có triết lý khác nhau hoàn toàn: v14 cache mặc định, v15 dynamic mặc định, v16 explicit opt-in với `'use cache'`. Trong Next.js 16, cách làm chuẩn là: * Dùng `'use cache'` + `cacheLife(profile)` để khai báo tường minh thứ gì cần cache * Dùng `cacheTag` để gán nhãn, `revalidateTag` để xóa cache theo nhu cầu * Wrap phần dynamic trong `` để tận dụng Partial Prerendering # Làm quen với Fetch dữ liệu, Loading, Error & Not Found trong Next Source: https://www.youhoc.com/next-js/data-fetching Lấy dữ liệu từ server và xử lý các trạng thái đi kèm: loading, lỗi, và trang không tồn tại. Trong bài trước chúng ta đã làm quen với cấu trúc thư mục và App Router của Next.js. Hôm nay đi vào phần thực tế hơn: **lấy dữ liệu từ server**, và xử lý các trạng thái đi kèm — loading, lỗi, và trang không tồn tại. ## Fetch dữ liệu trong Server Component Next.js mặc định mọi component đều là **Server Component** — tức là chúng chạy trên server, không phải trên trình duyệt. Nhờ đó, bạn có thể **kết nối thẳng vào database** và query dữ liệu ngay bên trong component, không cần tạo API route trung gian. ```typescript src/db/queries.ts theme={null} import { getCloudflareContext } from "@opennextjs/cloudflare"; import { drizzle } from "drizzle-orm/d1"; import { sitemapEntries } from "@/db/schema"; // lấy env có typed từ cloudflare const { env } = await getCloudflareContext({ async: true }); export async function getSitemapEntries() { // khởi tạo kết nối đến D1 database const db = drizzle(env.DB); return await db.select().from(sitemapEntries).all(); } ``` Sau đó dùng ngay trong component, chỉ cần thêm `async`: ```tsx src/app/page.tsx theme={null} import { getSitemapEntries } from "@/db/queries"; // nhớ chuyển function thành async export default async function Home() { const entries = await getSitemapEntries(); return (
    {entries.map((entry) => (
  1. {entry.url}
  2. ))}
); } ``` > Lưu ý: Nếu bạn cần lấy dữ liệu từ **API bên ngoài** (third-party), vẫn có thể dùng `fetch()` trong Server Component — không cần `useEffect`.\ > Chỉ dùng `useEffect` khi component đó là **Client Component** (`'use client'`). ### Vậy khi nào thực sự **bắt buộc** phải dùng `useEffect` trong Client Component? **1. Cần tương tác với browser API**\ Những thứ chỉ tồn tại trong trình duyệt — `localStorage`, `sessionStorage`, `navigator`, `window`, `document`. Server không có những thứ này, nên không thể chạy ở Server Component. ```tsx theme={null} 'use client'; useEffect(() => { const theme = localStorage.getItem('theme'); setTheme(theme); }, []); ``` **2. Fetch dữ liệu phụ thuộc vào hành động của người dùng (sau khi trang đã load)**\ Server Component chỉ chạy một lần khi render. Nếu bạn cần re-fetch dựa trên input của người dùng — tìm kiếm live-search, infinite scroll, autocomplete — thì phải ở client. ```tsx theme={null} 'use client'; useEffect(() => { fetch(`/api/search?q=${query}`).then(...); }, [query]); // chạy lại mỗi khi query thay đổi ``` **3. Subscribe vào real-time data**\ WebSocket, SSE (Server-Sent Events), hoặc các subscription như Firebase/Supabase realtime — những thứ cần kết nối liên tục trong suốt vòng đời component. ```tsx theme={null} 'use client'; useEffect(() => { const channel = supabase.channel('messages').on(...).subscribe(); return () => channel.unsubscribe(); // cleanup khi unmount }, []); ``` **4. Tích hợp thư viện bên thứ ba cần DOM**\ Nhiều thư viện JS (chart, map, rich text editor…) cần DOM node thực sự mới khởi tạo được — không thể chạy trên server. ```tsx theme={null} 'use client'; useEffect(() => { const chart = new Chart(canvasRef.current, { ... }); return () => chart.destroy(); }, []); ``` **Tóm lại là…** | Tình huống | Dùng gì | | :---------------------------- | :-------------------------------------- | | Fetch data lúc load trang | Server Component (+ `Suspense` nếu cần) | | Fetch lại khi user thao tác | `useEffect` trong Client Component | | Cần `window`, `localStorage`… | `useEffect` trong Client Component | | Real-time / WebSocket | `useEffect` trong Client Component | | Thư viện cần DOM | `useEffect` trong Client Component | Ranh giới rõ ràng nhất là: **Server Component fetch là one-shot khi render — còn** `useEffect` **là để phản ứng với những thứ xảy ra** ***sau đó*** **trên trình duyệt.** ## Hiển thị loading với `loading.tsx` Next.js có một file reserved là `loading.tsx`. Đặt nó cùng thư mục với `page.tsx`, Next sẽ tự động hiển thị component này trong khi trang đang chờ dữ liệu. ```tsx src/app/loading.tsx theme={null} export default function Loading() { return (

Đang tải...

); } ``` Cách này đơn giản, nhưng có một hạn chế: nó **ẩn toàn bộ trang** trong lúc loading — kể cả những phần tĩnh không cần chờ dữ liệu. ### Dùng `` để loading đúng chỗ Giải pháp tốt hơn là tách phần cần dữ liệu thành một component riêng, rồi bọc nó bằng ``: ```tsx src/app/page.tsx theme={null} import { Suspense } from "react"; import { getSitemapEntries } from "@/db/queries"; // lấy dữ liệu trong 1 component mới async function SitemapEntries() { const entries = await getSitemapEntries(); return (
    {entries.map((entry) => (
  1. {entry.url}
  2. ))}
); } // Home không cần async nữa export default function Home() { return (

Sitemap

Đang tải danh sách...

}>
); } ``` Như vậy, tiêu đề `

` hiển thị ngay lập tức, chỉ có danh sách bên dưới mới chờ — đúng behavior mà người dùng mong đợi. ## Xử lý lỗi với `error.tsx` Tương tự, Next.js có file reserved `error.tsx` để bắt lỗi xảy ra trong quá trình render: ```tsx src/app/error.tsx theme={null} // error boundary hoạt động ở phía client 'use client'; // bắt buộc phải có export default function Error({ error, reset, }: { error: Error; reset: () => void; }) { return (

Có lỗi xảy ra!

{error.message}

); } ``` Có hai điểm quan trọng: * **Phải thêm** `'use client'` ở đầu file — đây là yêu cầu bắt buộc của Next.js vì error boundary hoạt động ở phía client. * Component nhận vào prop `error` (để hiển thị thông điệp lỗi) và `reset` (để thử render lại). ## Xử lý 404 với `not-found.tsx` Khi một trang hoặc resource không tồn tại, dùng file `not-found.tsx` để tạo template 404: ```tsx src/app/not-found.tsx theme={null} export default function NotFound() { return (

404 — Không tìm thấy trang

Trang bạn đang tìm không tồn tại.

); } ``` Sau đó, **dùng** `notFound()` **khi route hợp lệ nhưng data không tồn** tại — thường gặp nhất ở dynamic routes, import từ `next/navigation`: ```tsx theme={null} import { notFound } from 'next/navigation'; export default async function NewsDetail({ params }: { params: { slug: string } }) { const news = await getNewsBySlug(params.slug); if (!news) { notFound(); // tự động dùng not-found.tsx gần nhất } return
{news.title}
; } ``` File `not-found.tsx` có thể đặt ở nhiều cấp khác nhau: ```text theme={null} app/ ├── not-found.tsx ← catch-all cho toàn app ├── news/ │ ├── [slug]/ │ │ └── page.tsx │ └── not-found.tsx ← chỉ cho route /news/* └── page.tsx ``` Next.js sẽ dùng file `not-found.tsx` **gần nhất** với nơi gọi `notFound()`. ## Lưu ý thực tế (production) `loading.tsx` **vs** ``\ Dùng `loading.tsx` cho các trang có toàn bộ nội dung phụ thuộc vào data (trang dashboard, trang danh sách). Dùng `` khi trang có cả phần tĩnh lẫn phần động — đây là pattern được khuyến nghị nhiều hơn vì UX tốt hơn. `error.tsx` **nên có nút “Thử lại”**\ Prop `reset` cho phép re-render lại segment hiện tại mà không cần reload toàn trang. Đây là chi tiết nhỏ nhưng cải thiện UX đáng kể, đừng bỏ qua. **Không log thông tin nhạy cảm ra** `error.message`\ Ở production, `error.message` đôi khi có thể chứa thông tin nội bộ (tên bảng, connection string…). Nên log đầy đủ ở server (dùng `console.error` hoặc dịch vụ như Sentry), còn hiển thị ra UI chỉ nên là thông điệp chung chung. **Scope** `not-found.tsx` **cẩn thận**\ Đặt `not-found.tsx` ở `app/` sẽ override trang 404 mặc định của Next — hãy đảm bảo nó đủ thông tin, có link về trang chủ, và không quá “trống”. ## Tóm tắt | File | Mục đích | Lưu ý | | :-------------- | :-------------------------------------------- | :----------------------------------------------------- | | `loading.tsx` | Hiển thị skeleton/spinner khi trang đang load | Ẩn toàn bộ trang — cân nhắc dùng `` thay thế | | `` | Loading có chọn lọc theo từng component | Linh hoạt hơn `loading.tsx`, ưu tiên dùng | | `error.tsx` | Bắt lỗi runtime trong route segment | **Bắt buộc**`'use client'` | | `not-found.tsx` | Hiển thị trang 404 | Dùng cùng `notFound()` từ `next/navigation` | # Giới thiệu tổng quan về Next.js (5/2026) Source: https://www.youhoc.com/next-js/index Học bây giờ có muộn lắm không? Bạn vừa học xong React. Hoặc bạn đang làm việc với một framework khác và nghe mọi người nói nhiều về Next.js. Và câu hỏi đầu tiên xuất hiện trong đầu là: *“Mình bắt đầu bây giờ có muộn quá không?”* Câu trả lời ngắn: **Không. Thậm chí bạn đang đến đúng lúc.** Đây là lý do. ## Next.js vừa trải qua giai đoạn “loạn nhất” của nó Để hiểu tại sao bây giờ là thời điểm tốt, hãy nhìn lại timeline: **Tháng 10/2022 — Next.js 13:** App Router ra mắt, nhưng chỉ ở dạng **beta**. Chính tài liệu chính thức lúc đó ghi rõ: *“The app directory is currently in beta and we do not recommend using it in production yet.”* Turbopack cũng chỉ ở alpha. Cộng đồng hào hứng nhưng chưa ai dám dùng thật sự. **Tháng 5/2023 — Next.js 13.4:** App Router chính thức **stable**. Team Next.js tuyên bố: *“Starting today, we recommend building new applications with the App Router.”* Đây là lúc mọi người bắt đầu thực sự học và dùng App Router — nhưng cũng là lúc caching còn rất khó đoán, bug nhiều, và tài liệu vẫn đang được viết lại. **Tháng 10/2023 — Next.js 14:** Tập trung ổn định, cải thiện hiệu năng. App Router đã dùng được nhưng mô hình caching vẫn là nguồn gốc của vô số confusion — cache mặc định quá nhiều thứ, khó debug. **Tháng 10/2024 — Next.js 15:** Đảo ngược triết lý caching: không còn cache mặc định nữa. Turbopack stable cho môi trường dev. React 19 được hỗ trợ. Đây là bước ngoặt lớn — nhưng cũng là breaking change khiến nhiều dự án cũ bị ảnh hưởng. **Tháng 10/2025 — Next.js 16:** `'use cache'` directive ra mắt, thay thế hoàn toàn các API cũ như `unstable_cache`. Turbopack stable cho cả production build. Mô hình caching cuối cùng trở nên **tường minh và có thể dự đoán được**. Team Next.js mô tả đây là sự hoàn chỉnh của Partial Prerendering — thứ họ đã xây dựng từ 2023. Nhìn lại timeline đó, bạn sẽ thấy: những ai học Next.js từ cuối 2022 đến giữa 2024 đã phải học đi học lại nhiều lần. Những ai bắt đầu từ cuối 2025 trở đi — như bạn — được học đúng phiên bản mà mọi thứ đã có câu trả lời rõ ràng. ## Những câu hỏi thường gặp ### “Tôi cần giỏi React trước rồi mới học Next không?” Không cần giỏi hoàn toàn, nhưng cần hiểu cơ bản: component, props, state, hook (`useState`, `useEffect`). Next.js là framework xây trên React — bạn sẽ vẫn viết React mỗi ngày. Điều thú vị là bạn hoàn toàn có thể **thực hành React ngay trong Next.js**. Một app To-do, một Timer, một Crypto Dashboard — những thứ bạn từng làm với React thuần, giờ có thể làm trong Next.js và lưu data vào database luôn mà không cần backend riêng. Học React và học Next.js song song là hoàn toàn hợp lý. ### “App Router hay Pages Router?” Nếu bạn bắt đầu mới, không có dự án cũ nào: **App Router**, không cần bàn. Tuy nhiên nếu bạn **có technical debt** — tức là đang làm việc với một codebase dùng Pages Router, hoặc join vào một team đang dùng Pages Router — thì bạn vẫn cần học Pages Router để làm việc được. Trong trường hợp đó, học cả hai là hợp lý: hiểu Pages Router để làm việc ngắn hạn, song song tìm hiểu App Router để chuẩn bị cho migration sau này. Pages Router vẫn được Vercel cam kết hỗ trợ dài hạn, nhưng toàn bộ tính năng mới (Server Actions, `'use cache'`, Partial Prerendering) đều chỉ có trên App Router. Những kiến thức từ Pages Router vẫn dùng được trong App Router: React Context, API Routes (giờ gọi là Route Handlers), authentication patterns, environment variables. Còn `getServerSideProps`, `getStaticProps`, `_app.tsx` thì App Router đã có cách làm khác hoàn toàn. ### “Next.js dùng để làm gì, khác React thuần chỗ nào?” React là thư viện để xây UI. Next.js là framework — nó bổ sung những thứ React không có sẵn: routing theo file system, Server Components, data fetching tích hợp, caching, deploy-ready out of the box. Nói đơn giản: React giúp bạn xây giao diện, Next.js giúp bạn xây cả ứng dụng. ### “Server Component và Client Component khác nhau thế nào?” Đây là câu hỏi quan trọng nhất khi mới vào App Router. **Server Component** chạy trên server, không có state, không có event handler, nhưng có thể đọc database trực tiếp và không gửi JS xuống browser. Đây là mặc định trong App Router. **Client Component** có `'use client'` ở đầu file, chạy trên browser, dùng được `useState`, `useEffect`, event handler — nhưng không đọc được database trực tiếp. Nguyên tắc thực tế: **mặc định viết Server Component, chỉ thêm** `**'use client'**` **khi component cần tương tác hoặc state.** ```tsx theme={null} // Server Component — mặc định, không cần khai báo gì export default async function PostList() { const posts = await db.query('SELECT * FROM posts') // đọc DB trực tiếp return
    {posts.map(p =>
  • {p.title}
  • )}
} ``` ```tsx theme={null} 'use client' // Client Component — cần 'use client' vì dùng useState export default function Counter() { const [count, setCount] = useState(0) return } ``` ### “Tôi cần học TypeScript không?” Không bắt buộc để bắt đầu, nhưng Next.js được thiết kế với TypeScript là first-class. Hầu hết tài liệu, ví dụ, và dự án thực tế đều dùng TypeScript. Nếu bạn chưa biết, học song song — TypeScript sẽ giúp bạn catch lỗi sớm hơn và đọc code dễ hơn nhiều. ### “Tôi cần biết gì về backend trước khi học Next?” Không cần nhiều. Next.js tích hợp sẵn Route Handlers (tạo API endpoint), Server Actions (submit form trực tiếp từ server component), kết nối database. Bạn có thể xây một ứng dụng full-stack chỉ trong một project Next.js mà không cần Express hay backend riêng. Tuy nhiên, hiểu cơ bản về HTTP (GET/POST, request/response, status code) sẽ giúp bạn không bị bỡ ngỡ. ### “Deploy ở đâu?” **Vercel** là lựa chọn đơn giản nhất — cùng công ty làm Next.js, hỗ trợ mọi tính năng, free tier dùng được cho project cá nhân. Nhưng nếu bạn không muốn bị lệ thuộc vào một vendor, có một số lựa chọn tốt: **Cloudflare Workers** là lựa chọn đáng chú ý nhất hiện tại. Cloudflare đã đầu tư nghiêm túc vào việc hỗ trợ Next.js thông qua adapter **OpenNext** (`@opennextjs/cloudflare`). Next.js 15 và 16 đều được hỗ trợ chính thức. Điểm khác biệt quan trọng: OpenNext chạy app của bạn trên **Node.js runtime** (không phải Edge runtime bị giới hạn), nên hầu hết tính năng Next.js đều hoạt động bình thường. Để bắt đầu: ```bash theme={null} npm create cloudflare@latest -- my-app --framework=next --platform=workers ``` Ngoài ra còn có **Netlify**, **Railway**, hoặc tự host trên VPS với `npm run build` và `npm start` — đều hoạt động tốt cho App Router. > Một lưu ý thực tế: dù deploy ở đâu, hãy test kỹ trên môi trường đó trước khi lên production — một số tính năng caching mới của Next.js 16 có thể hoạt động khác nhau tùy platform. ## Lộ trình gợi ý nếu bạn bắt đầu hôm nay Không cần học theo thứ tự cứng nhắc, nhưng đây là hướng đi hợp lý: 1. **React cơ bản** (component, props, useState, useEffect) — nếu chưa vững 2. **App Router** (file-based routing, layout, page, loading, error) 3. **Server vs Client Component** — hiểu rõ cái này sẽ giúp phần còn lại dễ hơn nhiều 4. **Data fetching** (fetch trong Server Component, Route Handlers) 5. **Database** (Prisma hoặc Drizzle là lựa chọn phổ biến với Next.js) 6. **Authentication** (NextAuth.js / Auth.js là thư viện phổ biến nhất) 7. **Caching** (hiểu bốn lớp cache, `'use cache'` trong Next.js 16) 8. **Deploy** lên Vercel Xây project thực tế song song từ bước 2 — đừng chờ “học xong rồi mới làm.” ## Tóm lại Muộn hay không phụ thuộc vào bạn đang so sánh với ai và để làm gì. Nếu bạn muốn làm việc với React ecosystem, Next.js là kỹ năng thực tế và đang được tuyển dụng nhiều. Nếu bạn muốn xây sản phẩm cá nhân, Next.js cho phép bạn làm full-stack mà không cần team backend riêng. Và nếu bạn đang bắt đầu ngay lúc này — bạn đang học đúng phiên bản ổn định nhất từ trước đến nay, với cộng đồng lớn nhất, tài liệu tốt nhất, và mô hình caching cuối cùng đã có lý. Không muộn. Đúng lúc. # Thực hành Intercepting Routes trong Next.js qua ví dụ Photo Gallery Source: https://www.youhoc.com/next-js/intercepting-routes Đây là pattern shareable modal, và đây chính xác là thứ Intercepting Routes được sinh ra để giải quyết. Bạn đang xem một feed ảnh trên Instagram. * Click vào một ảnh — một modal xuất hiện, URL đổi thành `/photo/123`, bạn có thể copy link đó gửi cho bạn bè. * Người nhận mở link → thấy trang ảnh đầy đủ, không phải modal. * Nhấn Back → quay lại feed, không phải trang trắng. Đây là pattern **shareable modal**, và đây chính xác là thứ Intercepting Routes được sinh ra để giải quyết. Trước khi có tính năng này, bạn phải tự quản lý URL state, history API, scroll position bằng JavaScript thuần, rất dễ bug. Các bạn có thể xem demo tính năng này trong trang [Marketer Việt Nam](https://www.marketervietnam.vn/), nhấp vào một bài đăng bộ ảnh bất kỳ. ## Vấn đề Intercepting Routes giải quyết Với routing thông thường, bạn chỉ có hai lựa chọn: * **Modal không có URL** — không thể share link, F5 là mất, SEO không có * **Chuyển trang hoàn toàn** — mất context trang cũ, trải nghiệm bị ngắt quãng Intercepting Routes cho phép bạn có **cả hai cùng lúc**: | Tình huống | Hiển thị | | :--------------------------------- | :------------------------ | | Click từ feed → `/photo/123` | Modal overlay trên feed | | Paste URL `/photo/123` vào tab mới | Trang ảnh đầy đủ | | F5 khi đang mở modal | Trang ảnh đầy đủ | | Nhấn Back | Quay lại feed, modal đóng | ## Cú pháp Intercepting Routes Intercepting Routes dùng ký hiệu tương tự relative path, nhưng dựa trên **route segment** chứ không phải file system: | Ký hiệu | Ý nghĩa | | :--------------- | :--------------------------------- | | `(.)folder` | Intercept segment **cùng cấp** | | `(..)folder` | Intercept segment **một cấp trên** | | `(..)(..)folder` | Intercept segment **hai cấp trên** | | `(...)folder` | Intercept từ **root** `/app` | > **Lưu ý quan trọng:** `@slot` không tính là route segment vì nó không ảnh hưởng URL. Nên khi tính “cấp”, bạn chỉ đếm các thư mục thực sự tạo ra URL segment. ## Xây dựng Photo Gallery step-by-step ### Mục tiêu ```text theme={null} / → Trang feed ảnh /photo/[id] → Trang ảnh đầy đủ (khi truy cập trực tiếp) → Modal overlay trên feed (khi click từ feed) ``` ### Bước 1: Cấu trúc thư mục ```text theme={null} /app ├── @modal │ ├── (..)photo │ │ └── [id] │ │ └── page.tsx ← intercepted page (hiển thị modal) │ └── default.tsx ← trả về null khi không có modal ├── photo │ └── [id] │ └── page.tsx ← full page (khi hard navigate) ├── layout.tsx └── page.tsx ← feed ảnh ``` **Tại sao dùng** `(..)photo` **mà không phải** `(.)photo`**?** Slot `@modal` nằm trong `app/`, cùng cấp với `photo/`. Nhưng vì `@modal` không tạo URL segment, khi tính cấp để intercept `/photo/[id]`, ta đang đứng ở `app/` nhìn vào `app/photo/` — tức là **một cấp dưới** → dùng `(..)`. ### Bước 2: Layout nhận slot `@modal` ```tsx app/layout.tsx theme={null} export default function RootLayout({ children, modal, }: { children: React.ReactNode modal: React.ReactNode }) { return ( {children} {modal} {/* modal render ở đây, chồng lên feed */} ) } ``` ### Bước 3: `default.tsx` — không render gì khi không có modal ```tsx app/@modal/default.tsx theme={null} export default function ModalDefault() { return null } ``` Khi người dùng ở trang feed `/` mà chưa click ảnh nào, slot `@modal` cần render *gì đó* — `null` là câu trả lời đúng. ### Bước 4: Feed ảnh — trang chính ```tsx app/page.tsx theme={null} import Link from 'next/link' const photos = [ { id: '1', src: 'https://picsum.photos/seed/1/400/300', title: 'Ảnh 1' }, { id: '2', src: 'https://picsum.photos/seed/2/400/300', title: 'Ảnh 2' }, { id: '3', src: 'https://picsum.photos/seed/3/400/300', title: 'Ảnh 3' }, ] export default function FeedPage() { return (
{photos.map((photo) => ( {photo.title} ))}
) } ``` Không có gì đặc biệt ở đây — chỉ là `` bình thường trỏ đến `/photo/[id]`. ### Bước 5: Trang ảnh đầy đủ — cho hard navigation ```tsx app/photo/[id]/page.tsx theme={null} const photos = [ { id: '1', src: 'https://picsum.photos/seed/1/800/600', title: 'Ảnh 1' }, { id: '2', src: 'https://picsum.photos/seed/2/800/600', title: 'Ảnh 2' }, { id: '3', src: 'https://picsum.photos/seed/3/800/600', title: 'Ảnh 3' }, ] export default function PhotoPage({ params }: { params: { id: string } }) { const photo = photos.find((p) => p.id === params.id) if (!photo) notFound() return (

{photo.title}

{photo.title}
) } ``` ### Bước 6: Modal — intercepted page ```tsx app/@modal/(..)photo/[id]/page.tsx theme={null} 'use client' import { useRouter } from 'next/navigation' const photos = [ { id: '1', src: 'https://picsum.photos/seed/1/800/600', title: 'Ảnh 1' }, { id: '2', src: 'https://picsum.photos/seed/2/800/600', title: 'Ảnh 2' }, { id: '3', src: 'https://picsum.photos/seed/3/800/600', title: 'Ảnh 3' }, ] export default function PhotoModal({ params }: { params: { id: string } }) { const router = useRouter() const photo = photos.find((p) => p.id === params.id) if (!photo) return null return ( // Overlay backdrop
router.back()} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)', display: 'grid', placeItems: 'center', zIndex: 50, }} > {/* Modal content — click không đóng modal */}
e.stopPropagation()} style={{ background: 'white', padding: 24, borderRadius: 8 }}>

{photo.title}

{photo.title}
) } ``` `router.back()` đóng modal bằng cách quay lại trang trước trong history — đúng hành vi người dùng kỳ vọng. ## Luồng hoạt động — tổng kết ```text theme={null} Người dùng ở /feed, click ảnh id=1 ↓ Next.js soft navigate → /photo/1 ↓ Next.js phát hiện: đang trong feed, có intercepting route (..)photo/[id] ↓ @modal slot → render PhotoModal (URL hiển thị: /photo/1) children → vẫn render FeedPage bên dưới ↓ Người dùng copy URL /photo/1, mở tab mới (hard navigate) ↓ Không có context để intercept ↓ @modal slot → render default.tsx (null) children → render PhotoPage đầy đủ ``` ## Ứng dụng thực tế trong production Intercepting Routes + Parallel Routes không chỉ dùng cho gallery ảnh. Dưới đây là các pattern phổ biến trong production: ### 1. Login modal với fallback page Nút “Đăng nhập” ở navbar mở modal, nhưng `/login` vẫn là trang độc lập cho người dùng không có JavaScript hoặc truy cập trực tiếp. Pattern này giúp **progressive enhancement** — app hoạt động tốt kể cả khi JS chưa load xong. ```text theme={null} /app ├── @auth │ ├── (.)login │ │ └── page.tsx ← login modal │ └── default.tsx ← null ├── login │ └── page.tsx ← login page đầy đủ └── layout.tsx ``` ### 2. Quick view sản phẩm — E-commerce Người dùng hover/click vào card sản phẩm → modal hiển thị thông tin nhanh (ảnh, giá, nút thêm vào giỏ), URL đổi thành `/products/abc`. Người dùng muốn xem chi tiết hơn → click “Xem chi tiết” mở trang sản phẩm đầy đủ. URL `/products/abc` có thể share được, SEO đầy đủ. ### 3. Shopping cart side drawer Click icon giỏ hàng → drawer trượt ra từ phải, URL đổi thành `/cart`. Người dùng vẫn thấy trang đang xem bên dưới. Nhấn Back hoặc click ngoài → drawer đóng, quay lại trang cũ. ### 4. User profile popover Trong ứng dụng social, click vào tên người dùng → popover hiển thị thông tin tóm tắt và nút Follow, URL đổi thành `/users/[username]`. Người dùng click vào tên → trang profile đầy đủ. ### 5. Image/video lightbox trong CMS Trong trang quản lý media, click vào file → lightbox xem trước mở ra, URL dẫn đến file cụ thể — có thể share link để cộng tác viên xem đúng file đó. ## Bảng ví dụ tổng hợp | Pattern | Ý nghĩa | Ví dụ thực tế | Column 4 | | :--------------- | :----------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `@folder` | Named slot — không tạo URL segment, layout nhận làm prop | Sidebar điều hướng + main content render song song trong cùng layout | `
app/feed/
├── **@content/**
├── **@sidebar/**
└── layout.tsx
` | | `(.)folder` | Intercept route cùng cấp | Navbar có link `/login` — click mở modal, truy cập thẳng `/login` → trang đầy đủ | `
app/
├── **@auth/**
│ ├── **(.)login/**
│ │ └── page.tsx
│ └── default.tsx
├── login/
│ └── page.tsx
└── layout.tsx
` | | `(..)folder` | Intercept route một cấp trên (`@slot` không tính là segment) | Click ảnh trong `/feed` → intercept `/photo/[id]`, modal overlay, feed vẫn còn bên dưới | `
app/
├── **@modal/**
│ ├── **(..)photo/**
│ │ └── [id]/page.tsx
│ └── default.tsx
├── feed/page.tsx
└── photo/[id]/page.tsx
` | | `(..)(..)folder` | Intercept route hai cấp trên | Trong `/checkout/payment`, click “xem lại” → intercept `/shop/[id]`, quick-view ngay trong trang thanh toán | `
app/
├── checkout/
│ └── payment/
│ ├── **@modal/**
│ │ └── **(..)(..)shop/**
│ │ └── [id]/page.tsx
│ └── page.tsx
└── shop/[id]/page.tsx
` | | `(...)folder` | Intercept từ `app/` root, bất kể đang ở nested level nào | Click notification bell ở navbar → intercept `/notifications` từ mọi trang, hiện drawer overlay | `
app/
├── dashboard/
│ └── settings/
│ ├── **@modal/**
│ │ └── **(...)notifications/**
│ │ └── page.tsx
│ └── page.tsx
└── notifications/page.tsx
` | ## Nested Routes vs Intercepting Routes — Khi nào dùng cái nào? Đây là điểm dễ nhầm nhất. Cả hai đều cho shareable URL và SEO đầy đủ, nhưng hành vi khi hard navigate thì khác nhau hoàn toàn. [**Nested Routes**](https://coda.io/d/_dINStbw4UsM/_su0YD-jZ) — hard navigate và soft navigate cho kết quả **giống nhau**: Tôi đã có bài viết về [useState vs URL-driven UI](https://coda.io/d/_dINStbw4UsM/_su0YD-jZ), trong đó sử dụng Nested Routes để tạo Modal. ```text theme={null} app/ ├── list/ │ ├── new/ │ │ └── page.tsx → /list/new │ ├── layout.tsx → render + {children} │ └── page.tsx → /list ``` Dù người dùng click từ `/list` hay paste thẳng `/list/new` vào tab mới, họ đều thấy list + modal cùng lúc — vì `layout.tsx` luôn render. **Intercepting Routes** — hard navigate và soft navigate cho kết quả **khác nhau**: | | **Soft navigate (click )** | **Hard navigate (paste URL, F5)** | | :---------------------- | :--------------------------------- | :------------------------------------------- | | **Nested Routes** | Modal + context trang cha bên dưới | Modal + context trang cha bên dưới | | **Intercepting Routes** | Modal + context trang cha bên dưới | **Trang đầy đủ, không có context trang cha** | Câu hỏi thực tế để chọn đúng: *"Khi người dùng paste link vào tab mới, tôi muốn họ thấy gì?"* * **Thấy modal trong context của trang cha** (list vẫn hiển thị bên dưới) → Nested Routes là đủ, đơn giản hơn nhiều * **Thấy trang độc lập, không có context trang cha** (chỉ thấy mỗi trang ảnh, không có gallery) → cần Intercepting Routes Ví dụ Instagram: click ảnh từ profile → modal overlay trên profile. Nhưng paste link ảnh vào tab mới → trang ảnh đầy đủ, không thấy profile của ai cả. Đây là lý do Instagram cần Intercepting Routes, còn một trang `/list/new` đơn giản thì không cần. ## Best practices **Luôn có** `default.tsx` **trả về** `null` **trong slot modal.** Đây là file bắt buộc — không có nó, khi hard navigate vào bất kỳ trang nào không phải `/photo/[id]`, slot `@modal` sẽ không biết render gì và trả về 404. **Dùng** `router.back()` **để đóng modal, không dùng** `router.push('/')`**.** `router.back()` giữ đúng navigation history. `router.push('/')` sẽ đẩy một entry mới vào history, người dùng nhấn Back không quay lại feed mà đi đến trang trước đó trong lịch sử. **Tách** `` **wrapper và nội dung modal thành hai component riêng.** Wrapper (`Modal`) là Client Component để xử lý `router.back()` và animation. Còn nội dung bên trong (form, data) nên là Server Component. Pattern này giúp tận dụng được Server Component cho phần lớn UI. ```tsx app/@modal/(..)photo/[id]/page.tsx theme={null} import { Modal } from '@/components/modal' // Client Component import { PhotoDetail } from '@/components/photo' // Server Component export default function PhotoModal({ params }) { return ( ) } ``` **Nếu có nhiều modal, gom tất cả intercepting routes vào một slot duy nhất.** Nếu dùng nhiều slot parallel routes (`@modal1`, `@modal2`), cả hai đều render cùng lúc — hai modal chồng lên nhau. Thay vào đó, dùng một slot `@modal` và đặt tất cả intercepting routes bên trong: ```text theme={null} /@modal ├── (.)login │ └── page.tsx ├── (.)cart │ └── page.tsx └── default.tsx ``` **Intercepting Routes chỉ hoạt động với** `` **và** `router.push()`**.** Dùng `
` tag thông thường, `window.location`, hoặc mở link trong tab mới đều là hard navigation — intercepting route sẽ không được kích hoạt. **Kiểm tra ký hiệu** `(.)` **cẩn thận.** Đây là lỗi phổ biến nhất. Nhớ rằng `@slot` không tính là route segment khi đếm cấp. Nếu modal không xuất hiện khi click, rất có thể bạn đang dùng sai ký hiệu. ## Kết luận Intercepting Routes giải quyết bài toán **shareable modal** — kết hợp trải nghiệm modal mượt mà với URL thật sự có thể share và SEO-friendly: * **Soft navigate** (click ``) → intercept, hiển thị modal trên trang hiện tại * **Hard navigate** (paste URL, F5) → không intercept, hiển thị trang đầy đủ * `default.tsx` → bắt buộc có để slot không bị 404 * **Một slot** `@modal` → gom tất cả intercepting routes vào một chỗ Component thuần đỡ rắc rối hơn rất nhiều — và thực tế phần lớn modal trong production không cần Intercepting Routes. Nên dùng component thuần khi: * Modal không cần share link * Không cần SEO cho nội dung trong modal * F5 đóng modal là hành vi chấp nhận được (ví dụ confirm dialog, form popup đơn giản) Intercepting Routes chỉ thực sự cần thiết khi bạn cần **cả ba thứ cùng lúc**: * URL thay đổi khi mở modal → có thể share link * F5 không mất context → hard navigate ra trang đầy đủ thay vì đóng modal * Back button đóng modal thay vì ra trang trước Ví dụ điển hình là Instagram — click ảnh từ profile, URL đổi thành `/p/abc123`, bạn copy link gửi bạn bè, họ mở ra thấy trang ảnh đầy đủ chứ không phải feed của bạn. Nếu dùng component thuần thì URL không đổi, link không share được. Nếu sản phẩm của bạn không có yêu cầu đó thì component thuần là lựa chọn đúng — đơn giản hơn, ít bug hơn, dễ maintain hơn. # Tất tần tật về Metadata trong Next.js, có những điều tôi chắc bạn cũng không biết Source: https://www.youhoc.com/next-js/metadata Metadata là thứ bạn không thấy khi dùng app, nhưng Google, Facebook và mọi crawler khác lại nhìn vào đó đầu tiên. Bài viết này đi qua toàn bộ những gì Next.js cung cấp cho metadata, từ cơ bản đến những chi tiết ít ai chú ý. Bạn vừa deploy xong một blog cá nhân bằng Next.js. Giao diện đẹp, code sạch, mọi thứ chạy ngon. Nhưng khi share link lên Facebook, cái preview hiện ra chỉ là một ô trắng tinh, không ảnh, không tiêu đề, không mô tả. Và khi Google index trang danh sách phân trang của bạn, nó thấy `/blog?page=1`, `/blog?page=2`, `/blog?page=3` như ba trang hoàn toàn khác nhau với cùng nội dung — duplicate content điển hình. Metadata là thứ bạn không thấy khi dùng app, nhưng Google, Facebook và mọi crawler khác lại nhìn vào đó đầu tiên. Bài viết này đi qua toàn bộ những gì Next.js cung cấp cho metadata, từ cơ bản đến những chi tiết ít ai chú ý. *** ## Metadata mặc định: đặt một lần, áp cho toàn bộ app Điểm xuất phát là `app/layout.tsx`. Export một object `metadata` ở đây và nó sẽ được áp dụng cho tất cả các trang trong app, trừ khi một trang nào đó tự override lại. ```tsx app/layout.tsx theme={null} import type { Metadata } from "next"; export const metadata: Metadata = { title: "Marketer Vietnam", description: "Mạng xã hội dành cho marketer tại Việt Nam", }; ``` Đơn giản vậy thôi. Bây giờ mọi trang đều có title và description mặc định nếu chúng không tự khai báo. *** ## Metadata tĩnh — override cho từng trang cụ thể Mỗi `page.tsx` hoặc `layout.tsx` đều có thể export `metadata` riêng để ghi đè lên metadata của parent. ```tsx app/about/page.tsx theme={null} export const metadata = { title: "Về chúng tôi", description: "Tìm hiểu thêm về Marketer Vietnam.", }; ``` Cơ chế này hoạt động theo cây — Next.js sẽ gộp metadata từ root layout xuống đến trang hiện tại, nhưng với rule là **child ghi đè parent** theo từng field. *** ## Title Template: trick bạn cần biết Thay vì mỗi trang phải tự ghi tên thương hiệu `"Tiêu đề trang trong | Marketer Vietnam"`, bạn có thể khai báo một template ở root layout: ```tsx app/layout.tsx theme={null} export const metadata: Metadata = { title: { template: "%s | Marketer Vietnam", default: "Marketer Vietnam", }, }; ``` Khi đó, các trang con chỉ cần khai báo phần title của riêng mình: ```tsx app/about/page.tsx theme={null} export const metadata: Metadata = { title: "Về chúng tôi", }; // Output: Về chúng tôi | Marketer Vietnam ``` `%s` là placeholder — Next.js tự điền title của trang con vào đó. `default` là fallback dùng khi trang con không khai báo title (ví dụ trang chủ). Tuy nhiên, đôi khi bạn muốn một trang nào đó **bỏ qua template**, ví dụ landing page campaign với title riêng hoàn toàn. Dùng `absolute`: ```tsx theme={null} export const metadata: Metadata = { title: { absolute: "Mua ngay — Sale 50% hôm nay", }, }; // Output: Mua ngay — Sale 50% hôm nay ``` *** ## Metadata động: khi title phụ thuộc vào data Với các trang như chi tiết bài viết, title và description phải được lấy từ database tùy theo slug. Next.js có hàm `generateMetadata` cho việc này: ```tsx app/post/[slug]/page.tsx theme={null} import { cache } from "react"; import { getPostBySlug } from "@/db/queries"; // Cache để tránh gọi DB 2 lần trong cùng một request const getPost = cache(async (slug: string) => { return getPostBySlug(slug); }); // Hàm này chạy ở Server nên truy cập được database an toàn export async function generateMetadata({ params, }: { params: Promise<{ slug: string }>; }) { const { slug } = await params; const post = await getPost(slug); if (!post) return {}; return { title: post.title, description: post.excerpt, openGraph: { title: post.title, description: post.excerpt, }, }; } export default async function PostDetailPage({ params, }: { params: Promise<{ slug: string }>; }) { const { slug } = await params; const post = await getPost(slug); // ... } ``` **Tại sao phải** `cache()`**?** Vì cả `generateMetadata` và component page đều cần gọi `getPost` với cùng slug. Nếu không cache, Next.js sẽ gọi DB hai lần trong một request. `react/cache` đảm bảo lần gọi thứ hai trả về kết quả đã có trong bộ nhớ, không tốn thêm một query nào. **Một lưu ý khác**: bạn **không thể** vừa export `metadata` object vừa export `generateMetadata` trong cùng một file — chỉ được dùng một trong hai. `generateMetadata` chạy hoàn toàn trên server, giống như một Server Component — nó có thể `await`, truy cập database, đọc cookies, nhưng không bao giờ expose code hay data xuống client. *** ## OG Image: ảnh preview khi share link ### Ảnh tĩnh cho toàn site Đặt file `opengraph-image.jpg` (hoặc `.png`) vào thư mục `app/`. Next.js tự nhận và inject vào thẻ `` mà không cần config gì thêm. ### Ảnh theo từng route Đặt `opengraph-image.jpg` vào thư mục của route đó. Ví dụ ảnh riêng cho trang blog: ```text theme={null} app/ ├── opengraph-image.jpg ← fallback cho toàn site └── blog/ └── opengraph-image.jpg ← ghi đè cho /blog và các trang con ``` ### Ảnh dynamic sinh từ tiêu đề bài viết Đây là tính năng rất hay mà không phải ai cũng biết. Tạo file `opengraph-image.tsx` trong thư mục route động: ```tsx app/post/[slug]/opengraph-image.tsx theme={null} import { ImageResponse } from "next/og"; import { getPostBySlug } from "@/db/queries"; export const size = { width: 1200, height: 630 }; export const contentType = "image/png"; export default async function Image({ params, }: { params: { slug: string }; }) { const post = await getPostBySlug(params.slug); return new ImageResponse( (
{post?.title}
Marketer Vietnam
) ); } ``` Next.js tự render JSX này thành ảnh PNG và serve nó như một route động (`/post/[slug]/opengraph-image`). Mỗi bài viết có ảnh OG riêng, sinh tự động từ tiêu đề — không cần Figma, không cần upload tay. *** ## Canonical URL: quan trọng hơn bạn nghĩ Nếu bạn không khai báo canonical, **Next.js sẽ không tự inject thẻ** ``. Google sẽ tự suy luận, nhưng đó là rủi ro — nhất là khi bạn có phân trang hay query params. ```tsx theme={null} export const metadata = { metadataBase: new URL("https://www.marketervietnam.vn"), alternates: { canonical: "/blog", }, }; ``` Với các trang phân trang (`/blog?page=2`, `/blog?page=3`), canonical nên trỏ về `/blog` — trang "gốc" không có query string. Nếu không, Google có thể coi mỗi trang là một URL riêng biệt với nội dung trùng lặp. ```tsx theme={null} export async function generateMetadata({ params }) { const { slug } = await params; return { alternates: { canonical: `/post/${slug}`, }, }; } ``` `metadataBase` cần được khai báo ở root layout để Next.js biết domain của bạn, từ đó resolve các path tương đối thành URL đầy đủ trong các thẻ meta. *** ## Extend metadata từ parent — và tại sao bạn nên làm vậy Khi khai báo OG image trong `generateMetadata`, có một pattern thường bị bỏ qua: ```tsx theme={null} export async function generateMetadata({ params }, parent) { const post = await getPost(params.slug); const previousImages = (await parent).openGraph?.images || []; return { openGraph: { images: [post.coverImage, ...previousImages], }, }; } ``` Tại sao lại `...previousImages` thay vì ghi đè hoàn toàn? Vì parent layout có thể đã khai báo ảnh OG mặc định (logo site, ảnh fallback). Bằng cách spread `previousImages` vào sau, bạn đặt ảnh bài viết lên đầu (crawler lấy ảnh đầu tiên) nhưng vẫn giữ lại fallback nếu ảnh bài viết không tải được. *** ## Robots — kiểm soát crawler ```tsx theme={null} export const metadata: Metadata = { robots: { index: true, follow: true, googleBot: { index: true, follow: true, "max-image-preview": "large", "max-snippet": -1, }, }, }; ``` `max-image-preview: "large"` cho phép Google dùng ảnh full-size trong kết quả tìm kiếm. `max-snippet: -1` có nghĩa là không giới hạn độ dài đoạn trích. Hai field này ít người để ý nhưng ảnh hưởng trực tiếp đến cách bài viết hiển thị trên Google Search. *** ## Tóm tắt | Tình huống | Dùng gì | | :----------------------------- | :--------------------------------- | | Title mặc định cho toàn app | `metadata` trong `app/layout.tsx` | | Append tên site vào title | `title.template` | | Title không dùng template | `title.absolute` | | Title/description từ database | `generateMetadata` + `cache()` | | Ảnh OG mặc định | `opengraph-image.jpg` trong `app/` | | Ảnh OG sinh tự động từ data | `opengraph-image.tsx` | | Chống duplicate content | `alternates.canonical` | | Kiểm soát hiển thị trên Google | `robots.googleBot` | Metadata là một trong những thứ ít hào nhoáng nhất trong front-end, nhưng nó ảnh hưởng trực tiếp đến SEO và cách người dùng nhìn thấy app của bạn khi share link. Next.js đã làm cho việc này khá tiện lợi — quan trọng là biết từng công cụ dùng khi nào. Bước tiếp theo nếu bạn muốn đào sâu hơn: tìm hiểu về **Structured Data (JSON-LD)** để thêm rich snippets cho Google, hoặc **Sitemap** generation với `app/sitemap.ts` — cả hai đều được Next.js App Router hỗ trợ native. # Optimistic UI: Phản hồi trước, lưu sau, lỗi thì hoàn lại trạng thái cũ với trong React Source: https://www.youhoc.com/next-js/optimistic-ui-update Một tính năng đúng nghĩa là "nâng cao", khó làm, chỉ giải quyết được một chút trải nghiệm ở tầng giao diện người dùng, chỉ để cải thiện được mức độ tự nhiên khi tương tác. Bạn đã bao giờ click "Like" trên Facebook và thấy con số tăng lên ngay lập tức, dù bạn biết rằng request vẫn đang trên đường đến server? Đó chính là **Optimistic UI**, một kỹ thuật cải thiện trải nghiệm người dùng bằng cách cập nhật giao diện ngay lập tức, thay vì đợi server phản hồi xong mới render lại. Trong bài viết này, chúng ta sẽ xây dựng một nút React (cảm xúc) cho bài viết blog, người dùng chọn mood, UI cập nhật ngay, rồi mới sync với server ở phía sau. Giả định chúng ta có một nút "React" với 6 trạng thái như sau: ```tsx theme={null} // mảng moods chứa các reaction const moods = [ { name: '10 điểm', value: 'excited', icon: FireIcon, iconColor: 'text-white', bgColor: 'bg-red-500' }, { name: 'Mãi iu', value: 'loved', icon: HeartIcon, iconColor: 'text-white', bgColor: 'bg-pink-400' }, { name: 'Cũng cũng', value: 'happy', icon: FaceSmileIcon, iconColor: 'text-white', bgColor: 'bg-green-400' }, { name: 'Ê nha', value: 'sad', icon: FaceFrownIcon, iconColor: 'text-white', bgColor: 'bg-yellow-400' }, { name: 'Tui thích', value: 'like', icon: HandThumbUpIcon, iconColor: 'text-white', bgColor: 'bg-blue-500' }, { name: 'Không có gì', value: null, icon: XMarkIcon, iconColor: 'text-gray-400 dark:text-gray-500', bgColor: 'bg-transparent', }, ] type MoodOption = (typeof moods)[number] // Tìm mood object trong mảng moods theo value, // fallback về option "Không có gì" nếu không tìm thấy function getMoodOption(mood: MoodSelection): MoodOption { return moods.find((item) => item.value === mood) || moods[5] } ``` *** ## Vấn đề với cách làm thông thường Cách đơn giản nhất để implement tính năng này là: gọi Server Action, đợi response, rồi mới cập nhật UI. ```tsx highlight={34,36-37} theme={null} export default function ReactButton({ postId, // để gọi server action mood, // mood hiện tại của người dùng (null nếu chưa react) reactionCount,// tổng số reaction của bài viết }: { postId: string mood: MoodSelection reactionCount: number }) { // Mỗi thứ cần hiển thị = một useState riêng const [selected, setSelected] = useState(() => getMoodOption(mood)) const [count, setCount] = useState(reactionCount) const handleMoodChange = async (nextSelectedOption: MoodOption) => { // Lưu lại previous để có thể xử lý lỗi sau này const previousMood = selected.value // Gọi server, đợi response... const updatedMood = await reactMood(postId, nextSelectedOption.value) // Nhận response xong mới cập nhật UI if ('mood' in updatedMood) { setSelected(getMoodOption(updatedMood.mood)) if (previousMood === null && updatedMood.mood !== null) { setCount((current) => current + 1) } else if (previousMood !== null && updatedMood.mood === null) { setCount((current) => Math.max(0, current - 1)) } } } return (
{selected.name} {moods.map((mood) => ( {mood.name} ))} {count}
) } ``` Cách này hoạt động đúng, nhưng có một vấn đề rõ ràng: trong khoảng thời gian chờ server (có thể 200–500ms, thậm chí hơn), **UI không có phản ứng gì**. Người dùng click xong, nhìn vào màn hình, không biết có chuyện gì đang xảy ra. Trải nghiệm này đặc biệt tệ trên kết nối chậm. *** ## `useOptimistic` là gì? `useOptimistic` là một hook được React giới thiệu từ phiên bản 19, cho phép bạn **hiển thị một giá trị tạm thời ngay lập tức**, trong khi async operation vẫn đang chạy ở background. Khi operation hoàn thành (thành công hoặc thất bại), React tự động đồng bộ lại với giá trị thực. Hook này được cấu hình như sau: ```tsx theme={null} const [optimisticValue, addOptimistic] = useOptimistic( realValue, // Giá trị thực, được xác nhận bởi server (current, next) => computeNextState(current, next) // Hàm tính giá trị tạm thời ) ``` Điểm quan trọng cần nhớ: * `optimisticValue` là giá trị **bạn hiển thị lên UI** — có thể là giá trị tạm thời, hoặc giá trị thực nếu không có operation nào đang chạy. * `addOptimistic(next)` kích hoạt cập nhật UI ngay lập tức với giá trị tạm thời. * Nếu server **thất bại**, React tự động rollback `optimisticValue` về `realValue`. Bạn không cần tự xử lý rollback. * Nếu server **thành công**, bạn cần tự cập nhật `realValue` để "confirm" kết quả. `useOptimistic` **phải chạy bên trong** `useTransition` (hoặc một Server Action). Đây là yêu cầu bắt buộc — hook này được thiết kế để hoạt động trong React's transition system. *** ## Refactor từng bước ### Bước 1 — Tách "giá trị đã xác nhận" ra khỏi render state Trước đây, `useState` vừa là nơi lưu giá trị server trả về, vừa là nguồn dữ liệu để render. Bây giờ chúng ta cần tách chúng ra: ```tsx theme={null} // Giá trị đã được server xác nhận lần cuối const [savedReaction, setSavedReaction] = useState(() => ({ mood, reactionCount, })) // Lỗi từ server (nếu có) để hiển thị cho người dùng const [actionError, setActionError] = useState(null) ``` `savedReaction` đóng vai trò là **source of truth từ server**. Chúng ta chỉ cập nhật nó khi server xác nhận thành công. ### Bước 2 — Khai báo `useOptimistic` ```tsx theme={null} const [optimisticReaction, addOptimisticReaction] = useOptimistic( savedReaction, // reaction hiện tại (currentReaction, nextMood: MoodSelection) => ({ mood: nextMood, // reaction tạm thời (chính là reaction vừa click) reactionCount: getNextReactionCount( currentReaction.mood, // reaction hiện tại nextMood, // reaction vừa click currentReaction.reactionCount,// reaction count ), }), ) ``` Hàm thứ hai (`reducer`) nhận vào `currentReaction` (giá trị hiện tại) và `nextMood` (giá trị bạn muốn thay đổi thành), rồi tính toán và trả về state tạm thời. Ở đây, helper `getNextReactionCount` xử lý logic tăng/giảm count: ```tsx theme={null} function getNextReactionCount( currentMood: MoodSelection, nextMood: MoodSelection, currentCount: number, ) { // chỉ tăng count nếu từ null sang react, hoặc gỡ bỏ react thành null if (currentMood === null && nextMood !== null) return currentCount + 1 if (currentMood !== null && nextMood === null) return Math.max(0, currentCount - 1) return currentCount // Đổi mood sang mood khác: count không đổi } ``` ### Bước 3 — Sử dụng `addOptimisticReaction` với `useTransition` Sử dụng `[optimisticReaction, addOptimisticReaction]` được trả ra từ hook `useOptimistic` để cập nhật lại handler. Các hàm của `useOptimistic` yêu cầu chạy trong một transition. Thêm `useTransition` và bọc toàn bộ async logic: ```tsx highlight={1,10} theme={null} const [isPending, startTransition] = useTransition() const handleMoodChange = async (nextSelectedOption: MoodOption) => { const nextMood = nextSelectedOption.value as MoodSelection setActionError(null) startTransition(async () => { // 1. Cập nhật UI ngay lập tức addOptimisticReaction(nextMood) // 2. Gọi server ở background const updatedMood = await reactMood(postId, nextMood) if ('error' in updatedMood) { // Server thất bại → useOptimistic tự rollback về savedReaction // thông điệp báo lỗi sẽ hiển thị bên cạnh nút reaction setActionError(updatedMood.error) return } // 3. Server thành công → cập nhật savedReaction để "confirm" // "currentReaction" chỉ đơn giản là "prevState", không liên quan gì đến useOptimistic setSavedReaction((currentReaction) => ({ mood: updatedMood.mood, reactionCount: getNextReactionCount( currentReaction.mood, updatedMood.mood, currentReaction.reactionCount, ), })) }) } ``` Lưu ý rằng khi server thất bại, chúng ta **không cần làm gì với** `optimisticReaction`. Khi transition kết thúc mà không có lời gọi `setSavedReaction`, React tự động trả `optimisticReaction` về giá trị của `savedReaction`. Đó là toàn bộ cơ chế rollback. ### Bước 4 — Render từ `optimisticReaction` để cập nhật UI "lạc quan" Chỗ này bạn cần hiểu là`optimisticReaction` là giá trị trả về từ hàm reducer `(currentReaction, nextMood: MoodSelection)` bên trong `useOptimistic` hook, cụ thể hơn: * **Khi không có transition nào đang chạy:** `optimisticReaction === savedReaction`, nghĩa React trả thẳng giá trị gốc, hàm reducer không được gọi. * **Khi đang trong transition (sau khi** `addOptimisticReaction(nextMood) `**được gọi):** React gọi hàm reducer với `(savedReaction, nextMood)`, và kết quả trả về chính là `optimisticReaction` lúc đó. Nên có thể hiểu hàm reducer đó là: *"nếu tôi cần hiển thị UI tạm thời, hãy tính nó như thế này"*. Còn `optimisticReaction` là React tự quyết định trả về cái gì tùy theo trạng thái hiện tại — giá trị gốc hoặc giá trị tạm thời. Rồi, giờ xem lại phần render của cách làm thông thường — state đến thẳng từ `useState`, button không có trạng thái chờ: ```tsx highlight={5-6,18} theme={null} return (
{selected.name} {moods.map((mood) => ( {mood.name} ))} {count}
) } ``` Với optimistic UI, chỉ có 3 điểm khác biệt: nguồn dữ liệu render lấy từ `optimisticReaction` thay vì trực tiếp từ `useState`, button có thêm `disabled={isPending}` (do `useTransition` trả ra) để tránh double-click, và có thêm một chỗ hiển thị lỗi nếu server thất bại: ```tsx highlight={2-3,9-11,23,26} theme={null} // Lấy giá trị hiển thị từ optimisticReaction thay vì useState trực tiếp const selected = getMoodOption(optimisticReaction.mood) const count = optimisticReaction.reactionCount return (
{/* disabled khi đang chờ server, tránh click nhiều lần */} {selected.name} {moods.map((mood) => ( {mood.name} ))} {count} {/* Hiển thị lỗi nếu server thất bại (UI đã rollback tự động lúc này) */} {actionError ? {actionError} : null}
) } ``` *** ## Kết quả: timeline của một lần click | Thời điểm | Điều xảy ra | | :--------- | :------------------------------------------------------------------------ | | Click | `addOptimisticReaction(nextMood)` → UI cập nhật ngay | | \~0ms | `optimisticReaction` phản chiếu mood mới, count mới | | \~200ms | Server xử lý xong, trả về kết quả | | Thành công | `setSavedReaction(...)` → `optimisticReaction` đồng bộ với server | | Thất bại | Transition kết thúc → `optimisticReaction` tự rollback về `savedReaction` | *** ## Một vài lưu ý thực tế **Về** `useTransition `**bắt buộc:** Nếu bạn gọi `addOptimisticReaction` bên ngoài `startTransition`, React sẽ báo lỗi ở development mode. Đây là thiết kế có chủ ý — `useOptimistic` chỉ có nghĩa khi có một async transition đang diễn ra để nó "che" đi. **Về** `isPending`**:** `isPending` là `true` trong suốt thời gian transition, bao gồm cả sau khi optimistic update đã hiển thị. Dùng nó để disable input, hiện loading indicator nhỏ, hoặc giảm opacity — nhưng không nên dùng để ẩn nội dung đã optimistic, vì như vậy sẽ phá vỡ mục đích ban đầu. **Về rollback:** Rollback tự động hoạt động tốt cho các thao tác đơn giản. Nếu UI phức tạp hơn (ví dụ: optimistic insert một item vào list), bạn cần đảm bảo `savedReaction` (giá trị gốc) không bao gồm item chưa được xác nhận — nếu không, rollback sẽ không về đúng trạng thái cũ. Giả sử bạn có một comment list, và người dùng submit một comment mới. **Làm sai — thêm comment vào** `savedList `**trước:** ```tsx theme={null} const handleSubmit = async (text: string) => { const tempComment = { id: 'temp', text } // ❌ Thêm vào saved state trước khi server xác nhận setSavedList(prev => [...prev, tempComment]) addOptimistic(tempComment) const result = await submitComment(text) if ('error' in result) { setError(result.error) return // transition kết thúc, useOptimistic rollback về... savedList // nhưng savedList đã có tempComment rồi → không rollback được } } ``` **Làm đúng — chỉ cập nhật** `savedList `**khi server xác nhận:** ```tsx theme={null} const handleSubmit = async (text: string) => { const tempComment = { id: 'temp', text } // ✅ savedList không bị động, chỉ optimistic state thay đổi addOptimistic(tempComment) const result = await submitComment(text) if ('error' in result) { setError(result.error) return // transition kết thúc, useOptimistic rollback về savedList // savedList vẫn sạch → rollback đúng, tempComment biến mất } // Server thành công → mới cập nhật savedList setSavedList(prev => [...prev, result.comment]) } ``` Tóm lại: `savedList` là "bức ảnh đã được server xác nhận", `useOptimistic` chỉ layer lên trên nó. Nếu bạn làm bẩn `savedList` trước khi server confirm thì rollback không còn chỗ sạch để về nữa. Nhưng chắc bạn sẽ không "ngốc" vậy đâu nhỉ, ahihi... *** ## Tóm lại `useOptimistic` giải quyết một vấn đề UX rất cụ thể: khoảng trống giữa lúc người dùng tương tác và lúc server phản hồi. Bằng cách hiển thị giá trị tạm thời ngay lập tức và tự rollback khi có lỗi, hook này giúp UI cảm giác nhanh và responsive mà không cần bạn tự quản lý state rollback phức tạp. Pattern cốt lõi gồm 3 phần: `useState` giữ giá trị đã xác nhận, `useOptimistic` tạo giá trị hiển thị tạm thời, và `useTransition` bọc toàn bộ async flow lại. Nếu bạn muốn tìm hiểu thêm, một số chủ đề liên quan đáng đọc tiếp là `useActionState` (kết hợp form action với optimistic update), `revalidatePath` / `revalidateTag` trong Next.js (để đồng bộ cache sau khi mutation), và Suspense boundaries (để kiểm soát loading state ở cấp độ cao hơn). # Tại sao bạn nên bật "--react-compiler" trong dự án Next.js mới nhất? Source: https://www.youhoc.com/next-js/react-compiler-on-or-off Lợi ích rõ ràng nhất: Xóa sổ useMemo, useCallback, memo. Bạn đang xây dựng một component danh sách sản phẩm. Ban đầu nó đơn giản, nhưng dần dần bạn thêm filter, sort, pagination… và bắt đầu nhận ra UI bị giật khi user tương tác. Bạn mở DevTools, thấy cả cây component re-render mỗi khi state thay đổi dù phần lớn trong số đó không liên quan gì đến thay đổi đó. Giải pháp quen thuộc là bọc mọi thứ trong `useMemo`, `useCallback`, `memo`… Nhưng rồi bạn lại phải lo thêm: dependency array có đúng không? Có vô tình tạo closure stale không? Code ngày càng khó đọc hơn. Đó chính xác là vấn đề mà **React Compiler** sinh ra để giải quyết. Mình đã thử và xác nhận là KHÔNG CẦN `useMemo` cũng như `useCallback` nữa. ## React Compiler là gì? React Compiler là một **build-time tool** — nghĩa là nó chạy trong quá trình build, phân tích code của bạn và tự động thêm memoization vào những chỗ cần thiết. Bạn không cần sửa một dòng code nào. Tháng 10/2025, React Compiler chính thức ra mắt phiên bản **stable 1.0**, sau nhiều năm được phát triển nội bộ tại Meta (trước đây có tên là “React Forget”). Hiện tại nó hỗ trợ: * **React 17, 18, 19** (tối ưu nhất với React 19) * **Next.js 15.3.1+** — tích hợp sẵn, bật bằng một dòng config * **Next.js 16** — React Compiler Support được đánh dấu là **stable** * **React Native / Expo** * **Vite**, **Rsbuild**, và các bundler khác Câu lệnh để khởi tạo một dự án Next.js chuẩn có React Compiler: ```shellscript theme={null} npx create-next-app@latest marnext --app --src-dir --ts --tailwind --react-compiler # --app: sử dụng App Router # --src-dir: đặt /app trong thư mục /src # --ts (hoặc --typescript): ngôn ngữ chính là TS # --tailwind: có tailwind (optional) # --react-compiler: có react compiler ``` ## Lợi ích rõ ràng nhất: Xóa sổ `useMemo`, `useCallback`, `memo` Đây là ví dụ điển hình nhất. Trước khi có React Compiler, bạn phải viết: ```tsx theme={null} // ❌ Trước — manual memoization, dài dòng và dễ sai import { useMemo, useCallback, memo } from 'react' const ProductList = memo(function ProductList({ products, onSelect }) { const sorted = useMemo(() => { return [...products].sort((a, b) => a.price - b.price) }, [products]) const handleSelect = useCallback((id: string) => { onSelect(id) }, [onSelect]) return (
    {sorted.map(p => ( handleSelect(p.id)} /> ))}
) }) ``` Có một bug tinh vi ở đây: dù `handleSelect` được bọc trong `useCallback`, arrow function `() => handleSelect(p.id)` vẫn tạo ra một function mới mỗi lần render — khiến `ProductItem` luôn re-render bất kể `memo` có được dùng hay không. Với React Compiler, bạn chỉ cần viết: ```tsx theme={null} // ✅ Sau — clean, đơn giản, compiler lo phần còn lại function ProductList({ products, onSelect }) { const sorted = [...products].sort((a, b) => a.price - b.price) const handleSelect = (id: string) => { onSelect(id) } return (
    {sorted.map(p => ( handleSelect(p.id)} /> ))}
) } ``` Compiler sẽ tự phân tích data-flow, xác định những giá trị nào thực sự thay đổi và memoize chính xác từng phần — kể cả từng JSX element riêng lẻ, không phải chỉ toàn bộ component. ## Compiler đã làm gì đằng sau hậu trường? React Compiler hoạt động theo các bước sau ở build time: 1. **Phân tích AST** — Compiler đọc code của bạn và xây dựng một biểu diễn trung gian (HIR — High-level Intermediate Representation) riêng của nó. 2. **Hiểu data-flow và mutability** — Compiler theo dõi từng biến, prop, state để biết cái nào có thể thay đổi giữa các lần render. 3. **Tự động thêm memoization** — Compiler chèn memoization vào đúng chỗ, thậm chí có thể memoize có điều kiện — điều mà `useMemo`/`useCallback` thủ công không thể làm được. Bạn có thể quan sát điều này trong **React DevTools**: các component được compiler tối ưu sẽ hiển thị badge **“Memo ✨”**. ## Điều kiện bắt buộc: Tuân thủ Rules of React React Compiler không phải phép màu tùy tiện. Nó hoạt động **dựa trên giả định** rằng code của bạn tuân theo [Rules of React](https://react.dev/reference/rules). Nếu không, compiler sẽ bỏ qua component đó và không tối ưu — hoặc tệ hơn, tối ưu sai. Có ba nhóm rules chính bạn cần nắm: ### 1. Components và Hooks phải pure > Xem chi tiết: [Components and Hooks must be pure](https://react.dev/reference/rules/components-and-hooks-must-be-pure) * Component phải **idempotent** — cùng props/state thì luôn trả về cùng output. * **Side effects** (gọi API, đọc/ghi file…) không được chạy trong render, chỉ chạy trong `useEffect` hoặc event handler. * **Props và state là immutable** — không được mutate trực tiếp, phải tạo object mới. ```tsx theme={null} // ❌ Sai — mutate props trực tiếp function BadComponent({ items }) { items.push({ id: 99, name: 'Extra' }) // KHÔNG ĐƯỢC làm thế này return } // ✅ Đúng — tạo array mới function GoodComponent({ items }) { const extended = [...items, { id: 99, name: 'Extra' }] return } ``` ### 2. React gọi Components và Hooks, không phải bạn > Xem chi tiết: [React calls Components and Hooks](https://react.dev/reference/rules/react-calls-components-and-hooks) * **Không gọi component như một function thông thường** — chỉ dùng chúng trong JSX. * **Không truyền hook như một giá trị thông thường** — hook chỉ được gọi bên trong component. ```tsx theme={null} // ❌ Sai function Page() { return
{Header()}
// gọi trực tiếp như function } // ✅ Đúng function Page() { return
// dùng như JSX } ``` ### 3. Rules of Hooks > Xem chi tiết: [Rules of Hooks](https://react.dev/reference/rules/rules-of-hooks) * **Chỉ gọi hook ở top level** — không gọi trong vòng lặp, điều kiện, hoặc hàm lồng nhau. * **Chỉ gọi hook trong React function** — không gọi trong function JavaScript thông thường. ```tsx theme={null} // ❌ Sai — hook trong điều kiện function Component({ isLoggedIn }) { if (isLoggedIn) { const [data, setData] = useState(null) // KHÔNG ĐƯỢC } } // ✅ Đúng — hook luôn ở top level function Component({ isLoggedIn }) { const [data, setData] = useState(null) // ... } ``` ## Dùng ESLint để phát hiện code không tương thích Trước khi bật React Compiler cho toàn bộ dự án, bạn nên kiểm tra xem code hiện tại có vi phạm Rules of React không. Công cụ để làm điều này là `eslint-plugin-react-hooks` — từ phiên bản stable 1.0, các compiler rules đã được tích hợp thẳng vào plugin này. ### Cài đặt ```bash theme={null} npm install --save-dev eslint-plugin-react-hooks@latest ``` ### Cấu hình trong `eslint.config.mjs` ```javascript theme={null} import reactHooks from 'eslint-plugin-react-hooks' export default [ { plugins: { 'react-hooks': reactHooks, }, rules: { ...reactHooks.configs['recommended-latest'].rules, }, }, ] ``` Preset `recommended-latest` bao gồm đầy đủ các compiler rules mới nhất, bao gồm: * Phát hiện `setState` trong render gây render loop * Phát hiện side effects nặng bên trong `useEffect` * Phát hiện truy cập `ref` không an toàn trong render ### Chạy lint ```bash theme={null} npx eslint src/ ``` Nếu có lỗi, ESLint sẽ chỉ ra đúng file và dòng code vi phạm. Hãy sửa hết trước khi bật compiler. > **Lưu ý:** ESLint plugin có thể cài và chạy độc lập, không cần bật React Compiler. Đây là cách an toàn để kiểm tra chất lượng code trước. ## Bật React Compiler trong Next.js Sau khi lint sạch, bật compiler chỉ cần một bước: ```bash theme={null} npm install babel-plugin-react-compiler@latest ``` ```typescript theme={null} // next.config.ts import type { NextConfig } from 'next' const nextConfig: NextConfig = { reactCompiler: true, } export default nextConfig ``` Nếu muốn bật từng bước (opt-in per component), dùng `annotation` mode: ```typescript theme={null} const nextConfig: NextConfig = { reactCompiler: { compilationMode: 'annotation', }, } ``` Sau đó thêm directive `"use memo"` vào những component muốn tối ưu: ```tsx theme={null} function HeavyComponent() { 'use memo' // compiler sẽ tối ưu component này } ``` Nếu một component cụ thể gặp vấn đề sau khi bật compiler, bạn có thể opt-out nó: ```tsx theme={null} function ProblematicComponent() { 'use no memo' // compiler sẽ bỏ qua component này } ``` ## Lời kết React Compiler 1.0 là một bước ngoặt thực sự cho hệ sinh thái React. Nó giúp bạn: * **Xóa bỏ hàng loạt** `useMemo`, `useCallback`, `memo` — code sạch hơn, ít bug hơn. * **Tối ưu chính xác hơn** những gì human có thể làm thủ công, kể cả memoize có điều kiện. * **Yên tâm về độ ổn định** — đã stable trên Next.js 15.3.1+, Next.js 16, React 17/18/19, React Native. Điều kiện để tận dụng được tối đa: code của bạn phải tuân thủ **Rules of React**. Hãy dùng `eslint-plugin-react-hooks@latest` với preset `recommended-latest` để kiểm tra trước khi bật. # Routing nâng cao trong Next.js qua ví dụ trang News Archive Source: https://www.youhoc.com/next-js/routing-advanced Đây chính là lúc bạn cần đến Route Groups, Dynamic Routes, Parallel Routes và error handling trong Next.js App Router. Giả sử bạn đang xây dựng một trang **News Archive** — nơi người dùng có thể lọc bài viết theo năm, theo tháng, đồng thời vẫn thấy những bài mới nhất ở một cột bên cạnh, dù đang ở bất kỳ filter nào. Nghe đơn giản, nhưng nếu chỉ dùng routing cơ bản của Next.js, bạn sẽ nhanh chóng gặp một loạt câu hỏi khó: * URL `/archive/2024/03` thì `params` nhận như thế nào? * Làm sao để `/archive` (không có filter) vẫn hiển thị được? * Cột “bài mới nhất” và cột “filter” là hai vùng riêng — render song song thế nào? * Nếu người dùng gõ sai URL thì báo lỗi ra sao? Đây chính là lúc bạn cần đến **Route Groups**, **Dynamic Routes**, **Parallel Routes** và **error handling** trong Next.js App Router. Bài viết này sẽ đi qua từng khái niệm, gắn với ví dụ News Archive thực tế. ## Các khái niệm cần biết ### Route Groups — Nhóm route mà không ảnh hưởng URL Khi dự án lớn lên, bạn sẽ muốn gom các trang theo mục đích (marketing, admin, blog…) mà không làm thay đổi URL. Route Groups giải quyết điều đó bằng cách bọc tên thư mục trong dấu ngoặc đơn: ```text theme={null} /app -- /(marketing) ---- /blog ------ page.js → URL: /blog ---- /about ------ page.js → URL: /about ---- layout.js → Layout chung cho nhóm marketing ``` Thư mục `(marketing)` không xuất hiện trong URL, chỉ dùng để tổ chức file và chia sẻ layout nội bộ. ### Dynamic Routes — Route với tham số thay đổi Dùng dấu ngoặc vuông để tạo segment động: | Cú pháp | Ví dụ URL khớp | Ghi chú | | :------------ | :------------------------------------------------------------- | :------------------------------------------- | | `[slug]` | `/blog/hello-world` | Một segment cố định | | `[...slug]` | `/blog/2014/05/slug` | Nhiều segment, nhưng không khớp `/blog` | | `[[...slug]]` | `/blog`, `/blog/2014/` ,`/blog/2014/09` , `/blog/2014/09/slug` | Nhiều segment, **có khớp cả trang blog gốc** | Với News Archive, ta cần `[[...filter]]` vì cần `/archive` (không có filter) cũng hoạt động được. ```text theme={null} /app/archive ├── [[...filter]] // sẽ match như blog ``` ### Parallel Routes — Render hai vùng song song Parallel Routes cho phép bạn render **nhiều trang độc lập trong cùng một layout**. Khai báo bằng thư mục bắt đầu bằng `@`: ```text theme={null} /archive -- /@archive → slot "archive" -- /@latest → slot "latest" -- layout.tsx → nhận cả hai slot làm props ``` Layout sẽ nhận hai slot như props và render chúng song song — mỗi slot có thể loading, error, và navigate độc lập nhau. ### Parallel Routes vs 2 component — khác nhau ở đâu? Nếu chỉ cần **render hai vùng UI**, dùng component bình thường là đủ: ```tsx theme={null} // Hoàn toàn hợp lệ, không cần Parallel Routes export default function ArchiveLayout() { return (
{/* component thường */} {/* component thường */}
); } ``` Parallel Routes sinh ra để giải quyết thứ mà component thường **không làm được**: **1. Mỗi slot có loading/error state độc lập** Với component thường, nếu `ArchiveFilter` đang fetch data chậm, bạn phải chờ cả hai xong rồi mới render. Với Parallel Routes, mỗi slot có `loading.tsx` và `error.tsx` riêng — slot nào xong trước hiển thị trước, slot nào lỗi thì chỉ slot đó báo lỗi. **2. Mỗi slot có thể navigate độc lập** Đây là điểm mấu chốt. Khi URL đổi từ `/archive/2024` sang `/archive/2024/03`, **chỉ slot** `**@archive**` **re-render** vì chỉ nó mới quan tâm đến `params.filter`. Slot `@latest` không bị ảnh hưởng, không fetch lại, không unmount. Với component thường, cả layout re-render khi URL đổi. **3. Mỗi slot là một route thật sự — có thể navigate trực tiếp** Câu hỏi nhiều người hay thắc mắc: **có access riêng rẽ theo từng path không?** Câu trả lời là **không trực tiếp** — `@archive` và `@latest` không tạo ra URL `/archive/@archive` hay `/archive/@latest`. Dấu `@` báo cho Next.js biết đây là slot, không phải segment URL. Tuy nhiên, mỗi slot **theo dõi URL của chính nó**. Đây là lúc Parallel Routes thường đi kèm với **Intercepting Routes** — ví dụ điển hình là **modal photo**: ```text theme={null} /app ├── @modal │ └── (.)photos/[id] ← intercepting route ├── photos │ └── [id] │ └── page.tsx └── layout.tsx ``` Khi click ảnh từ feed → URL đổi thành `/photos/123`, slot `@modal` “bắt” route đó và hiển thị modal, còn trang nền vẫn giữ nguyên. Nếu người dùng paste URL `/photos/123` vào tab mới → không có gì để intercept, hiển thị trang ảnh bình thường. **Tóm lại:** | | Component thường | Parallel Routes | | :--------------------- | :--------------- | :----------------------------------------------- | | Render song song | ✅ | ✅ | | Loading/error độc lập | ❌ | ✅ | | Navigate độc lập | ❌ | ✅ | | Truy cập qua URL riêng | ❌ | ❌ (nhưng kết hợp với Intercepting Routes thì có) | Dùng component thường khi chỉ cần chia layout. Dùng Parallel Routes khi mỗi vùng cần **lifecycle độc lập** với URL. ## Xây dựng trang News Archive ### Cấu trúc thư mục ```text theme={null} /app/archive ├── @archive │ ├── [[...filter]] │ │ └── page.tsx │ ├── error.tsx │ └── not-found.tsx ├── @latest │ ├── page.tsx │ └── default.tsx ├── layout.tsx └── not-found.tsx ``` ### Bước 1: Layout nhận hai slot `layout.tsx` nhận `@archive` và `@latest` như props thông thường: ```tsx app/archive/layout.tsx theme={null} export default function ArchiveLayout({ archive, latest, }: { archive: React.ReactNode; latest: React.ReactNode; }) { return (

News Archive

{archive}
); } ``` `@latest/page.tsx` luôn hiển thị bài mới nhất, không phụ thuộc vào filter đang chọn. `@latest/default.tsx` re-export lại `page.tsx` để làm fallback khi hard navigate: ```jsx app/archive/@latest/default.tsx theme={null} export { default } from './page' ``` ### Bước 2: Trang filter với catch-all ```tsx app/archive/@archive/[[...filter]]/page.tsx theme={null} import { getNewsForYear, getNewsForYearAndMonth } from '@/lib/news'; import NewsList from '@/components/news-list'; import Link from 'next/link'; export default function FilteredNewsPage({ params, }: { params: { filter?: string[] }; }) { const filter = params.filter; const selectedYear = filter?.[0]; const selectedMonth = filter?.[1]; // Xác định dữ liệu cần hiển thị dựa theo filter let news; let links = getAvailableNewsYears(); if (selectedYear && !selectedMonth) { news = getNewsForYear(selectedYear); links = getAvailableNewsMonths(selectedYear); } if (selectedYear && selectedMonth) { news = getNewsForYearAndMonth(selectedYear, selectedMonth); links = []; } return ( <> {news && news.length > 0 ? ( ) : (

Không tim thấy bài viết.

)} ); } ``` **Giải thích logic** `params.filter`**:** * Truy cập `/archive` → `filter` là `undefined` * Hiển thị: “Không tim thấy bài viết.” * Truy cập `/archive/2024` → `filter` là `['2024']` * Truy cập `/archive/2024/03` → `filter` là `['2024', '03']` Nhờ `[[...filter]]`, tất cả ba trường hợp đều được xử lý trong một file duy nhất. ### Bước 3: Xử lý lỗi và not-found Có hai loại “không tìm thấy” cần phân biệt: **Không tìm thấy bài viết** (năm/tháng hợp lệ nhưng không có bài): ```tsx app/archive/@archive/not-found.tsx theme={null} export default function ArchiveNotFound() { return

Không có bài viết nào trong khoảng thời gian này.

; } ``` Trong page, gọi `notFound()` khi cần: ```tsx theme={null} import { notFound } from 'next/navigation'; if (selectedYear && !getAvailableNewsYears().includes(selectedYear)) { notFound(); } ``` **Lỗi không mong muốn** (params sai hoàn toàn, lỗi server…): ```tsx app/archive/@archive/error.tsx theme={null} 'use client'; // error boundary bắt buộc phải là Client Component export default function ArchiveError({ error }: { error: Error }) { return (

Đã có lỗi xảy ra

{error.message}

); } ``` > **Lưu ý:** `error.tsx` **bắt buộc phải là Client Component** (`'use client'`). Đây là yêu cầu của Next.js vì error boundary sử dụng React state nội bộ. ## Mở rộng: Tại sao đặt `not-found.tsx` ở hai chỗ? Trong cấu trúc trên có hai file `not-found.tsx`: ```text theme={null} /archive ├── @archive │ └── not-found.tsx ← (1) └── not-found.tsx ← (2) ``` * **(1)** `@archive/not-found.tsx`: Chỉ ảnh hưởng slot `@archive`. Khi gọi `notFound()` trong trang filter, **chỉ vùng archive báo lỗi**, còn cột `@latest` vẫn hiển thị bình thường. * **(2)** `archive/not-found.tsx`: Ảnh hưởng toàn bộ trang `/archive`. Dùng khi cả route không tồn tại. Nếu `@latest` gọi `notFound()` — ví dụ không fetch được bài mới nào — Next.js sẽ **bubble up** lên `not-found.tsx` gần nhất ở cấp cha, tức là `(2)`. Nếu xóa `(2)`, lỗi sẽ tiếp tục bubble up lên tận `app/not-found.tsx` — trang 404 toàn site, trông rất lạ khi chỉ có một slot bị lỗi. ### Còn một trường hợp nữa Nếu người dùng truy cập một path **không tồn tại hoàn toàn** — ví dụ `/archive/something/weird/invalid/url` … thì bắt lỗi nào hoàn toàn là **quyết định thiết kế của bạn**, không có quy tắc bắt buộc nào của Next.js cả. Với `/archive/something/weird/invalid/url`, bạn có thể chọn: * `throw new Error()` → `error.tsx` xử lý trong @archive * `notFound()` → `not-found.tsx` xử lý * Hoặc thậm chí **render một UI bình thường** kiểu “không tìm thấy kết quả” mà không cần route nào cả Next.js chỉ cung cấp công cụ. Còn **ngữ nghĩa** — “đây là lỗi” hay “đây là không tìm thấy” — là do bạn tự định nghĩa cho ứng dụng của mình. Thường thì convention phổ biến là: * `notFound()` cho những thứ **có thể không tồn tại một cách bình thường** (bài viết bị xóa, filter không có kết quả…) * `throw error` cho những thứ **không bao giờ nên xảy ra** (URL bị tamper, data corrupt…) Nhưng đó cũng chỉ là convention, không phải luật. Đây là điểm mạnh của Parallel Routes — từng slot có thể xử lý trạng thái lỗi/loading độc lập mà không ảnh hưởng lẫn nhau. ## Mở rộng: `default.tsx` — Fallback cho hard navigation Parallel Routes xử lý navigation theo hai cách khác nhau: **Soft navigation** (click ``): Next.js giữ nguyên state của slot không match — gọi là "sticky". Dù URL đổi thành `/archive/2024`, slot `@latest` vẫn hiển thị nội dung cũ mà không cần render lại. **Hard navigation** (reload, paste URL, mở tab mới): Next.js không còn in-memory state. Với mỗi slot, nó tìm theo thứ tự: 1. `page.tsx` khớp với URL hiện tại 2. `default.tsx` 3. Không có → **404** Trong ví dụ News Archive, khi hard navigate vào `/archive/2024`, slot `@latest` không có `2024/page.tsx` nên Next.js cần `default.tsx` làm fallback. Nếu không có, trang sẽ trả về 404 dù `@archive` hoàn toàn hợp lệ. Cách đơn giản nhất là re-export `page.tsx`: ```tsx app/archive/@latest/default.tsx theme={null} export { default } from './page' ``` Không cần duplicate code — `default.tsx` và `page.tsx` cùng render một component, nhưng Next.js dùng đúng file cho đúng trường hợp. > **Lưu ý:** `@archive` không cần `default.tsx` vì `[[...filter]]` là catch-all — nó tự khớp với mọi URL kể cả khi không có filter, nên không bao giờ rơi vào trạng thái "không tìm thấy gì để render". ## Best practices **Mỗi slot trong Parallel Routes nên có** `**default.tsx**`**.** Slot không có `default.tsx` sẽ trả về 404 khi hard navigate vào bất kỳ URL nào mà slot đó không có `page.tsx` tương ứng. Ngoại lệ duy nhất là slot đã dùng catch-all route `[[...slug]]` — vì catch-all tự khớp mọi URL rồi. **Dùng** `**[[...slug]]**` **thay** `**[...slug]**` **khi cần khớp cả trang gốc.** Nếu dùng `[...slug]`, bạn sẽ cần tạo thêm `page.tsx` ở thư mục cha để xử lý `/archive` — dễ gây trùng lặp logic. **Đặt** `**error.tsx**` **gần với nơi có thể xảy ra lỗi.** Đặt trong slot `@archive` thay vì ngoài layout giúp lỗi được cô lập, không “nuốt” cả trang. **Không nên dùng Parallel Routes cho mọi thứ.** Chỉ dùng khi thực sự cần render hai vùng độc lập trong cùng một layout. Nếu chỉ cần chia layout đơn giản, dùng component thông thường là đủ. **Validate params trước khi query.** Với dynamic routes, `params` đến từ URL nên không thể tin tưởng hoàn toàn. Luôn kiểm tra giá trị hợp lệ trước khi truyền vào hàm query. ## Tóm tắt Qua ví dụ News Archive, bạn đã thấy cách kết hợp các tính năng routing nâng cao của Next.js App Router: * **Route Groups** `(folder)` — tổ chức file mà không thay đổi URL * **Dynamic Routes** `[[...filter]]` — xử lý nhiều cấp URL trong một trang * **Parallel Routes** `@slot` — render hai vùng song song, độc lập trong cùng layout * **Error & Not-found handling** — phân tầng theo từng slot để cô lập lỗi * `**default.tsx**` — fallback cho hard navigation, mỗi slot cần có trừ khi đã dùng catch-all Những bước tiếp theo có thể khám phá: **Intercepting Routes** (mở modal khi click ảnh mà không mất trang nền), hoặc kết hợp Parallel Routes với **Server Actions** để xử lý form phức tạp. # useRouter trong Next.js: Từ đóng modal đến prefetch Source: https://www.youhoc.com/next-js/userouter-you-dont-know Trong bài viết này, chúng ta sẽ đi từ use-case thực tế nhất (đóng modal), mở rộng ra các method hay dùng, rồi kết thúc ở router.prefetch(), tính năng giúp trải nghiệm người dùng mượt hơn hẳn mà ít ai để ý. Bạn đang xây dựng một trang gallery ảnh. Khi người dùng click vào một bức ảnh, một modal hiện ra — URL trên thanh địa chỉ cũng đổi theo, nhưng trang nền phía sau vẫn còn đó. Người dùng nhấn `Escape` hoặc click nút `✕`, modal đóng lại, URL về trạng thái cũ, mọi thứ mượt mà. Đây là pattern [**Intercepting Route**](/next-js/intercepting-routes) — một trong những tính năng nâng cao của Next.js App Router. Và hook đứng sau hầu hết những thao tác điều hướng kiểu này là `useRouter` từ `next/navigation`. ## `useRouter` là gì? `useRouter` là một hook của Next.js App Router, cho phép bạn **điều hướng bằng code** (programmatic navigation) thay vì chỉ dùng ``. ```tsx theme={null} 'use client' import { useRouter } from 'next/navigation' export default function MyComponent() { const router = useRouter() // ... } ``` > ⚠️ Chú ý: `useRouter` từ `next/navigation` khác với `useRouter` từ `next/router` (của Pages Router cũ). Nếu đang dùng App Router, luôn import từ `next/navigation`. Vì là hook, nó chỉ dùng được trong **Client Component** — nên đừng quên `'use client'` ở đầu file. ## 1. `router.back()` — Đóng modal với Intercepting Route ### Intercepting Route hoạt động như thế nào? Intercepting Route cho phép bạn “chặn” một route và hiển thị nội dung của nó trong ngữ cảnh hiện tại (thường là modal), thay vì điều hướng hẳn sang trang mới. Cấu trúc thư mục trông như thế này: ```text theme={null} app/ ├── gallery/ │ ├── page.tsx ← trang gallery chính │ └── [id]/ │ └── page.tsx ← trang ảnh full (khi truy cập trực tiếp) │ └── @modal/ └── (.)gallery/ └── [id]/ └── page.tsx ← modal (khi click từ gallery) ``` Quy ước `(.)` nghĩa là “intercept route cùng cấp”. Next.js sẽ hiển thị `@modal/` khi người dùng *navigate* đến `/gallery/[id]` từ trong app, nhưng vẫn render trang đầy đủ khi truy cập URL đó trực tiếp — đây gọi là **progressive enhancement**. ### Đóng modal bằng `back()` Khi người dùng mở modal, trình duyệt đã đẩy một entry mới vào history stack. Gọi `back()` sẽ quay về entry trước đó — tức là đóng modal và khôi phục URL. ```tsx theme={null} 'use client' import { useRouter } from 'next/navigation' export default function PhotoModal({ src }: { src: string }) { const router = useRouter() return (
router.back()}>
e.stopPropagation()}> Photo
) } ``` Đây là pattern chuẩn, gọn, và không cần quản lý state `isOpen` nào cả — URL chính là “source of truth”. ## 2. `router.push()` và `router.replace()` — Điều hướng có kiểm soát ### `push()` — Thêm vào history ```tsx theme={null} router.push('/dashboard') router.push(`/gallery/${photoId}`) ``` `push()` hoạt động giống `` — thêm một entry mới vào history. Người dùng có thể nhấn nút Back của trình duyệt để quay lại. Dùng khi: sau khi đăng nhập thành công, sau khi submit form, khi muốn người dùng có thể back về. ### `replace()` — Thay thế entry hiện tại ```tsx theme={null} router.replace('/login') // không tạo entry mới trong history ``` `replace()` thay thế entry hiện tại trong history, không tạo thêm mới. Người dùng **không thể** back về trang vừa rời đi. Dùng khi: redirect sau đăng xuất, trang trung gian (loading/callback), hoặc khi bạn **không muốn** người dùng quay lại trang cũ bằng nút Back. ```tsx theme={null} // Sau khi logout async function handleLogout() { await signOut() router.replace('/login') // không thể back về dashboard sau khi logout } ``` ## 3. `router.refresh()` — Làm mới dữ liệu mà không reload trang ```tsx theme={null} router.refresh() ``` `refresh()` yêu cầu Next.js **fetch lại dữ liệu từ server** cho route hiện tại và cập nhật các Server Component — mà không reload toàn bộ trang, không mất state của Client Component. Đây là cách “sync lại” UI sau khi thực hiện một Server Action. ```tsx theme={null} 'use client' import { useRouter } from 'next/navigation' import { likePost } from '@/actions/post' export function LikeButton({ postId }: { postId: string }) { const router = useRouter() async function handleLike() { await likePost(postId) router.refresh() // cập nhật lại số lượt like từ server } return } ``` > Lưu ý: nếu bạn đang dùng `useOptimistic` trong cùng component, `refresh()` sẽ trigger re-fetch và đồng bộ lại giá trị thật từ server sau khi optimistic update đã hiển thị. ## 4. `router.prefetch()` — Tải trước route để điều hướng nhanh hơn ### Vấn đề Khi người dùng click vào một link, Next.js mới bắt đầu tải bundle JavaScript và dữ liệu của trang đó. Với kết nối chậm, có thể cảm nhận được độ trễ này. ### Giải pháp: prefetch `router.prefetch(url)` tải trước code và dữ liệu của một route **ngay cả khi người dùng chưa click** — thường là khi họ hover vào một phần tử, hoặc khi component mount. ```tsx theme={null} 'use client' import { useRouter } from 'next/navigation' export function ProductCard({ id, name }: { id: string; name: string }) { const router = useRouter() return (
router.prefetch(`/products/${id}`)} onClick={() => router.push(`/products/${id}`)} className="product-card" > {name}
) } ``` Khi người dùng hover vào card, Next.js bắt đầu tải trước trang sản phẩm. Đến khi họ thực sự click, trang đã sẵn sàng — cảm giác gần như tức thì. ### So sánh với `` `` từ `next/link` **tự động prefetch** các route xuất hiện trong viewport (trong production). Vậy khi nào cần `router.prefetch()` thủ công? * Khi bạn dùng `

) } ``` Với `replace()`, history stack chỉ có một entry cho modal dù người dùng đã xem bao nhiêu ảnh — và `back()` luôn đưa họ về trang gallery một cách chính xác. Nguyên tắc: `push()` **khi muốn người dùng có thể back lại,** `replace()` **khi chỉ muốn cập nhật URL mà không tạo checkpoint mới.** ## Tóm tắt | Method | Tác dụng | Khi nào dùng | | :-------------- | :---------------------------------------- | :---------------------------------- | | `back()` | Quay lại trang trước trong history | Đóng modal, cancel flow | | `push(url)` | Điều hướng, thêm vào history | Sau submit form, click link động | | `replace(url)` | Điều hướng, thay thế history hiện tại | Redirect auth, navigate trong modal | | `refresh()` | Re-fetch dữ liệu server, giữ client state | Sau Server Action, cần sync data | | `prefetch(url)` | Tải trước route | Hover, slideshow, multi-step flow | `useRouter` là công cụ điều hướng “có não” — dùng đúng method đúng chỗ sẽ giúp UX mượt hơn nhiều so với chỉ dùng `` ở khắp nơi. Bước tiếp theo nếu bạn muốn đào sâu hơn về routing trong Next.js App Router: **Parallel Routes** (hiển thị nhiều page slot song song), **Route Groups** (tổ chức route mà không ảnh hưởng URL), và **Middleware** (xử lý redirect/auth ở tầng edge trước khi render). # Tổng quan Source: https://www.youhoc.com/react/index React trong năm 2026 có gì mới? # Hiểu gốc rễ của React Source: https://www.youhoc.com/react/low-level-react Hầu hết mọi người học React đều bắt đầu bằng Vite hoặc Create React App — chạy một lệnh, mọi thứ tự dựng lên, và bạn bắt đầu viết component luôn. Tiện, nhưng có một vấn đề: bạn đang lái xe mà chưa biết động cơ hoạt động thế nào. Bài này sẽ không dạy bạn cách dùng React nhanh nhất. Thay vào đó, chúng ta sẽ bắt đầu từ mức thấp nhất có thể — không build tool, không CLI — để hiểu React thực sự là gì trước khi các công cụ che đi sự phức tạp. ## Bước 1: React thuần, không JSX Cách đơn giản nhất để chạy React là dùng CDN — nhúng thẳng vào file HTML như bất kỳ thư viện JS nào khác. ```html theme={null}
``` Sau đó tạo component và render bằng JavaScript thuần: ```javascript theme={null} // script.js // tạo component con function Header() { return React.createElement('h2', null, 'My App Header'); // ^tag ^props ^children } // tạo component cha, lồng Header bên trong function App() { return React.createElement('div', null, React.createElement(Header), // component tự định nghĩa React.createElement('h1', null, 'Hello, React') // thẻ HTML thông thường ); } // gắn React vào #root và render const root = ReactDOM.createRoot(document.getElementById('root')); root.render(React.createElement(App)); ``` `React.createElement(tag, props, ...children)` là hàm cốt lõi của React. Mọi component, mọi thẻ JSX bạn viết sau này — đều được biên dịch về hàm này. Đây là mức thấp nhất, và bạn thấy ngay: nó rất dài dòng, khó đọc. ## Bước 2: JSX ra đời để giải quyết vấn đề đó **JSX** (JavaScript XML) là cú pháp mở rộng cho phép bạn viết HTML-like trực tiếp trong JavaScript. Thay vì: ```jsx theme={null} React.createElement('div', null, React.createElement(Header), React.createElement('h1', null, 'Hello, React') ); ``` Bạn viết: ```jsx theme={null}

Hello, React

``` Dễ đọc hơn rất nhiều. Nhưng JSX **không phải JavaScript hợp lệ** — browser không hiểu nó. Cần có một bước biên dịch (transpile) để chuyển JSX về `React.createElement` trước khi chạy. Công cụ làm việc đó là **Babel**. ## Bước 3: Thêm Babel để chạy JSX trên browser ```html theme={null} ``` ```html theme={null} ``` Babel đọc `type="text/babel"`, biên dịch JSX thành `React.createElement`, sau đó browser chạy bình thường. Đây là cách Vite và Next.js làm — chỉ là tự động hóa bước này đi. > ⚠️ Cách dùng Babel qua CDN chỉ phù hợp để học và thử nghiệm. Không dùng cho production vì Babel biên dịch trực tiếp trên browser, gây chậm. ## Những điều cần biết về JSX ### 1. Luôn có một root element JSX bắt buộc phải được bọc trong **một thẻ duy nhất** ở ngoài cùng. Nếu không muốn thêm thẻ `
` thừa vào DOM, dùng Fragment: ```jsx theme={null} // ❌ Lỗi — hai thẻ ngang hàng, không có root return (

Hello

World

); // ✅ Dùng Fragment — không tạo thêm thẻ trong DOM return ( <>

Hello

World

); ``` `<>...` là viết tắt của `...`. ### 2. Component tự định nghĩa thì tự đóng ```jsx theme={null}
// ✅ component
// thẻ HTML, cần đóng đầy đủ ``` ### 3. Nhúng JavaScript bằng `{}` Bên trong JSX, dùng `{}` để chạy bất kỳ biểu thức JavaScript nào: ```jsx theme={null} const name = 'John'; const isMember = true; function App() { return ( <>

Hello, {name}!

{/* Ternary operator */} {isMember ?

Welcome back!

:

Join us!

} {/* Short-circuit — chỉ render nếu điều kiện true */} {isMember &&

Welcome back!

} ); } ``` Lưu ý: `{}` chỉ nhận **biểu thức** (expression), không nhận **câu lệnh** (statement). Đó là lý do `if/else` thông thường không dùng được trực tiếp trong JSX — phải dùng ternary, hoặc tách ra ngoài: ```jsx theme={null} function App() { let message; // if/else viết bên ngoài JSX — hoàn toàn bình thường if (isMember) { message = 'Welcome back'; } else { message = 'Join us'; } return

{message}

; } ``` ### 4. Một số attribute phải đổi tên Vì JSX chạy trong JavaScript, một số từ khóa bị trùng, nên... * `class` phải đổi thành `className` * `for` (trong `label` của `form`) phải đổi thành `htmlFor` ## Lời kết trước khi vào học Bản chất của React không thay đổi — JSX chỉ là lớp cú pháp bên trên `React.createElement`. Hiểu điều này giúp bạn không bị bối rối khi gặp lỗi biên dịch, và hiểu tại sao JSX lại có những quy tắc “kỳ lạ” như bắt buộc có root element hay không dùng được `if` trực tiếp. Bước tiếp theo: khi đã hiểu nền tảng, chuyển sang dùng **Vite** để có môi trường phát triển thực sự — hot reload, module bundling, và không cần lo về Babel nữa. # Hiểu React Component cơ bản với Rating App Source: https://www.youhoc.com/react/react-component Trong React, mọi thứ đều là component. Lưu ý nhất phần Event Handler. **Component là đơn vị xây dựng UI cơ bản nhất, giúp bạn chia nhỏ giao diện thành các mảnh độc lập, tái sử dụng được, và dễ bảo trì.** Từ một nút bấm nhỏ, một thanh điều hướng, cho đến cả một trang web hoàn chỉnh, tất cả đều được xây dựng từ các component lồng vào nhau. ## Component là gì? Component trong React đơn giản là một **hàm JavaScript trả về JSX** — một đoạn mô tả giao diện. ```jsx theme={null} const Rating = () => { return (

Rate Your Experience

); }; ``` Sau đó bạn có thể “nhúng” nó vào bất kỳ component nào khác như một thẻ HTML: ```jsx theme={null} function App() { return (
); } ``` Một vài quy tắc cần nhớ ngay từ đầu: * **Tên component phải viết hoa chữ cái đầu** — `Rating`, không phải `rating`. React phân biệt component tự định nghĩa với thẻ HTML gốc chính bằng cách này. * **Mỗi component nên có một file riêng** — đặt trong thư mục `components/`, tên file trùng với tên component (ví dụ: `Rating.jsx`). ```jsx components/Rating.jsx theme={null} const Rating = () => { return (

Rate Your Experience

); }; export default Rating; ``` ```jsx App.jsx theme={null} import Rating from './components/Rating'; ``` ## Render danh sách với `.map()` Khi cần render nhiều phần tử lặp lại — ví dụ 5 ngôi sao — bạn dùng `.map()` để biến một mảng thành danh sách JSX. ```jsx theme={null} const Rating = () => { const stars = [1,2,3,4,5] return (

Rate Your Experience

{stars.map((star) => ( {'\u2605'} ))}
); }; ``` **Lưu ý:** * **Prop** `key` là bắt buộc khi render danh sách. React dùng nó để theo dõi từng phần tử khi UI cập nhật. Nếu thiếu, bạn sẽ thấy warning trên console — và có thể gặp bug khó hiểu khi danh sách thay đổi. Dùng giá trị duy nhất, ổn định — ở đây là `star` (1, 2, 3, 4, 5). * Không dùng `index` của `.map()` làm `key` nếu danh sách có thể bị sắp xếp lại hoặc lọc bớt. Nhân tiện, ngoài lề một về hàm tạo mảng tự động. Nếu bạn muốn tạo 1 array với các số tuần tự: ```jsx theme={null} const stars = Array.from({ length: 5 }, (_, i) => i + 1); // hàm Array.from() tạo array mới từ array-like object hoặc iterable Array.from([1, 2, 3]); // [1, 2, 3] - từ array Array.from("hello"); // ['h', 'e', 'l', 'l', 'o'] - từ string // Array.from sẽ tạo array từ object { length: 5 } với 5 phần tử 'undefined' Array.from({ length: 5 }); // [undefined, undefined, undefined, undefined, undefined] // (_, i) => i + 1) // là map function (tham số thứ 2 của Array.from) // _ = current value (bỏ qua vì là undefined) // i = index (0, 1, 2, 3, 4) // Return i + 1 = (1, 2, 3, 4, 5) ``` ## Styling Component Có nhiều cách style trong React. Với component nhỏ như `Rating`, hai cách phổ biến nhất là: **Inline style** — dùng khi style đơn giản, không cần tách file: ```jsx theme={null} // Hai dấu {{ }}: ngoài là JSX, trong là CSS object
``` **External CSS** — cách phổ biến nhất, dễ maintain hơn khi dự án lớn: ```jsx theme={null} import './Rating.css';
``` Lưu ý: trong JSX dùng `className` thay cho `class` (vì `class` là từ khóa trong JavaScript). ### Styling với CSS Modules CSS Modules thực ra khá phổ biến trong các dự án React, đặc biệt khi dùng với Vite hoặc Next.js vì được hỗ trợ sẵn, không cần cài thêm gì. Nó nằm giữa “inline style” (quá đơn giản) và Tailwind/styled-components (cần setup thêm). Nhiều team chọn nó vì tránh được xung đột class name mà vẫn viết CSS thuần. **CSS Modules** là cách viết CSS riêng cho từng component, trong đó tên class được **tự động đổi thành duy nhất** khi build — nên không bao giờ bị trùng với component khác, dù bạn đặt tên giống nhau. ```css Rating.module.css theme={null} .container { text-align: center; padding: 20px; } .stars { display: flex; gap: 8px; justify-content: center; } .star { font-size: 32px; color: #ccc; cursor: pointer; } .star:hover { color: #f5a623; } ``` ```jsx Rating.jsx theme={null} import styles from './Rating.module.css'; const Rating = () => { const stars = Array.from({ length: 5 }, (_, i) => i + 1); return (

Rate Your Experience

{stars.map((star) => ( {'\u2605'} ))}
); }; ``` Hai điểm khác so với CSS thông thường: **Import như một object** — `import styles from './Rating.module.css'` trả về một object, mỗi class name là một key. Dùng `styles.container` thay vì `"container"`. **Tên file phải có** `.module.css` — đây là quy ước để bundler (Vite, webpack) nhận ra và xử lý class scoping tự động. File `Rating.css` bình thường sẽ không có tính năng này. Khi build xong, class `.star` trong `Rating.module.css` sẽ thành tên kiểu `_star_x7k2p_1` — unique, không đụng hàng với `.star` ở bất kỳ component nào khác. ## Event Handler Đây là phần quan trọng nhất trong bài, nơi component bắt đầu có **tương tác**. ### Cú pháp cơ bản React dùng camelCase cho tên event, và nhận vào **một hàm** (không phải chuỗi như HTML thuần): ```jsx theme={null} // HTML thuần // JSX ``` ### Lưu ý quan trọng: có `()` hay không? Đây là điểm dễ nhầm nhất: ```jsx theme={null} // ✅ Đúng — truyền hàm vào, chỉ gọi khi click // ❌ Sai — gọi hàm ngay lúc render, không phải lúc click ``` Nếu cần **truyền tham số**, bọc trong arrow function: ```jsx theme={null} // ✅ Đúng — dùng arrow fn để truyền args // ❌ Sai — sẽ tự gọi ngay khi render ``` **Quy tắc đơn giản để nhớ:** nếu cần truyền tham số → bọc trong `() =>`. Nếu không cần → truyền thẳng tên hàm. Về bản chất đây là `callback function`. Hãy nghĩ theo cách này: `onClick` chờ bạn truyền vào **một hàm để gọi sau** — tức là chỉ gọi khi có sự kiện xảy ra, không gọi ngay. Đó là định nghĩa của callback. ```javascript theme={null} onClick={handleClick} // handleClick là một hàm → truyền thẳng vào, React sẽ gọi nó sau khi click ``` Khi cần truyền tham số, bạn không thể viết `onClick={handleClick(star)}` vì `handleClick(star)` **gọi hàm ngay lập tức** và trả về kết quả — thay vì truyền một hàm. Lúc này `onClick` nhận được `undefined` thay vì một callback. Giải pháp là bọc trong một arrow function rỗng: ```jsx theme={null} onClick={() => handleClick(star)} // () => handleClick(star) là một hàm mới, rỗng, chưa chạy gì cả // React giữ nó lại, đợi đến khi click mới gọi // lúc đó arrow fn chạy → bên trong gọi handleClick(star) ``` Cơ chế này không phải đặc thù của React — đây là JavaScript thuần. Hoàn toàn tương đương với `addEventListener` trong Vanilla JS: ```javascript theme={null} // Vanilla JS button.addEventListener('click', handleClick); // không có args button.addEventListener('click', () => handleClick(star)); // có args ``` ```jsx theme={null} // React JSX {isExpanded && (