Đang tải...

Ếch Trendy

Cùng chú ếch nhỏ khám phá thế giới Web đầy biến động. Cập nhật trending nhanh như cách ếch đớp mồi! ⌨️🌿


0

React 19 và Next.js 15: Server Components, Server Actions và Partial Prerendering

Ếch Trendy
Ếch Trendy

5 tháng trước · 10 phút đọc

React 19 và Next.js 15 có gì mới để dev phải quan tâm?

Mình nhớ lần đầu đọc RFC về Server Components - phản ứng đầu tiên là "cái này phức tạp quá, thôi bỏ qua". Hai năm sau, khi Next.js 15 stable ra và React 19 chính thức được release, mình mới nhận ra mình đã bỏ lỡ một paradigm shift thực sự.

Vấn đề là nhiều bài viết giải thích những tính năng này theo kiểu "đây là định nghĩa, đây là cú pháp" - không nói rõ tại sao bạn cần chúng hay khi nào không nên dùng. Bài này mình sẽ làm khác: tập trung vào trade-off thực tế.

344368 React 19 và Next.js 15 cùng release trong 2024 - cặp đôi đáng để học đúng cách

React 19 stable ra tháng 12/2024. Next.js 15 stable ra tháng 10/2024. Cả hai đều có những thay đổi đủ lớn để ảnh hưởng đến cách bạn kiến trúc app - không phải chỉ syntax mới mà là cách tư duy mới về ranh giới server/client.


Server Components: không phải SSR, không phải RSC cũ

Đây là điểm nhầm lẫn phổ biến nhất. Server Components không phải là server-side rendering (SSR) bạn đã biết.

SSR truyền thống: render HTML trên server → gửi xuống client → client tải JS → hydrate lại toàn bộ. Kết quả là JS bundle của bạn vẫn phải chứa code cho mọi component.

Server Components khác hẳn: component chạy hoàn toàn trên server, không bao giờ ship JS xuống client, không hydrate. HTML được gửi xuống là final.

344369 SSR vs Server Components - điểm khác biệt nằm ở JS bundle gửi về client

Lợi ích thực tế? Bundle size giảm đáng kể. Một component dùng thư viện nặng như date-fns, lodash, hay marked để parse markdown - với Server Components, toàn bộ code đó chỉ chạy ở server. Client không nhận một byte JS nào từ những thư viện này.

// Server Component - chạy hoàn toàn trên server
// Không cần 'use client', đây là default trong Next.js 15 app router
import { marked } from 'marked'; // thư viện này không ship xuống client
import { db } from '@/lib/db';

async function BlogPost({ slug }: { slug: string }) {
  // Truy vấn DB trực tiếp - không qua API
  const post = await db.posts.findUnique({ where: { slug } });
  
  return (
    <article>
      <h1>{post.title}</h1>
      {/* Parse markdown trên server */}
      <div dangerouslySetInnerHTML={{ __html: marked(post.content) }} />
    </article>
  );
}

Khi nào dùng Server Components:

  • Fetch data từ database hoặc API nội bộ
  • Dùng thư viện nặng chỉ để transform data
  • Render nội dung tĩnh hoặc ít thay đổi
  • Cần truy cập environment variables nhạy cảm

Khi nào KHÔNG dùng:

  • Component cần onClick, onChange, event handlers
  • Dùng useState, useEffect, browser APIs
  • Cần real-time interactivity

Trade-off cần biết: Server Components không thể được cached ở edge mặc định (khác với static generation). Mỗi request có thể trigger một lần chạy trên server. Nếu component query DB phức tạp mà không có caching strategy, bạn có thể tạo ra bottleneck mới.


Server Actions: form submission không cần viết API endpoint

Bạn có bao giờ tính xem một feature CRUD đơn giản cần bao nhiêu file không? Route handler, validation schema, error handling, fetch từ client, loading state... Server Actions giải quyết phần lớn boilerplate này.

Server Actions là async functions chạy trên server, được gọi trực tiếp từ form hoặc event handler ở client. Không cần tạo /api/route.ts, không cần fetch, không cần manage endpoint.

// actions.ts - đánh dấu 'use server' ở đầu file
'use server';

import { db } from '@/lib/db';
import { revalidatePath } from 'next/cache';

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string;
  const content = formData.get('content') as string;
  
  // Validate và save thẳng vào DB
  await db.posts.create({ data: { title, content } });
  
  // Revalidate cache để UI cập nhật
  revalidatePath('/blog');
}

// Trong Server Component
import { createPost } from './actions';

export default function NewPostForm() {
  return (
    <form action={createPost}>
      <input name="title" placeholder="Tiêu đề" />
      <textarea name="content" placeholder="Nội dung" />
      <button type="submit">Đăng bài</button>
    </form>
  );
}

344370 Server Actions loại bỏ lớp API trung gian - ít code hơn, ít bug hơn

Thực tế? Mình đã thử migrate một form đăng ký đơn giản từ pattern fetch-to-API sang Server Actions. Code client giảm từ ~60 dòng xuống ~15 dòng. Không còn phải quản lý loading state thủ công vì React 19 có useFormStatus và useActionState tích hợp sẵn.

// Client Component với useActionState (React 19)
'use client';

import { useActionState } from 'react';
import { createPost } from './actions';

export function PostForm() {
  // state: kết quả trả về từ action, action: wrapped function, isPending: đang xử lý
  const [state, action, isPending] = useActionState(createPost, null);
  
  return (
    <form action={action}>
      <input name="title" />
      <button disabled={isPending}>
        {isPending ? 'Đang lưu...' : 'Lưu bài'}
      </button>
      {state?.error && <p>{state.error}</p>}
    </form>
  );
}

Khi nào Server Actions phù hợp:

  • Mutations đơn giản: tạo, sửa, xóa data
  • Form submission với validation
  • Các thao tác cần authenticate (middleware bảo vệ được áp dụng tự động)

Khi nào vẫn cần REST/GraphQL API:

  • App cần public API cho bên thứ ba
  • Mobile app hoặc nhiều client khác nhau cùng dùng
  • Real-time updates (WebSocket, SSE)
  • Cần caching phức tạp ở tầng API riêng biệt

Server Actions không phải replacement cho API - chúng là shortcut cho internal mutations trong Next.js app.


Partial Prerendering: tại sao static và dynamic không cần chọn 1

Đây là tính năng mình thấy ít người giải thích đúng nhất. Partial Prerendering (PPR) giải quyết một vấn đề cụ thể: trang của bạn có cả nội dung tĩnh (header, layout, blog post content) lẫn nội dung động (số lượt xem, comment, cart count) - bạn phải chọn static hay dynamic cho cả trang?

Trước PPR, Next.js xử lý theo page-level: trang là static (SSG) hoặc dynamic (SSR). Nếu một component nhỏ cần dynamic data, cả trang bị kéo xuống dynamic.

PPR thay đổi điều đó: shell tĩnh được prerender và cached, dynamic parts được stream vào sau.

// next.config.ts - bật PPR (experimental trong Next.js 15)
module.exports = {
  experimental: {
    ppr: true,
  },
};

// app/product/[id]/page.tsx
import { Suspense } from 'react';

export default function ProductPage({ params }) {
  return (
    <div>
      {/* Phần này static - prerendered, cached ở CDN */}
      <ProductInfo productId={params.id} />
      
      {/* Phần này dynamic - stream sau khi shell load */}
      <Suspense fallback={<div>Đang tải đánh giá...</div>}>
        <UserReviews productId={params.id} />
      </Suspense>
      
      <Suspense fallback={<div>Đang tải giỏ hàng...</div>}>
        <CartStatus userId="current" />
      </Suspense>
    </div>
  );
}

344371 Shell tĩnh load ngay từ CDN, dynamic content stream sau - người dùng thấy nội dung nhanh hơn

Lợi ích thực tế cho e-commerce hay news site:

  • Trang product: content chính (static) load ngay từ CDN, reviews và cart status stream sau
  • Trang blog: bài viết (static) hiện ngay, comment count và recommendation (dynamic) đến sau
  • Time to First Contentful Paint giảm đáng kể vì shell đã cached

Lưu ý quan trọng: PPR vẫn còn experimental trong Next.js 15. Vercel đang test và tối ưu. Nếu bạn tự host (không dùng Vercel), cần đảm bảo server infrastructure hỗ trợ streaming responses đúng cách. Không nên bật PPR blindly cho production app mà chưa test kỹ.


Áp dụng vào dự án thực tế: cách mình tổ chức code

Biết lý thuyết là một chuyện, đưa vào dự án lại là chuyện khác. Mình sẽ chia sẻ pattern mình đang dùng sau khi migrate một số project sang Next.js 15.

Nguyên tắc đầu tiên: default là Server Component, thêm 'use client' khi có lý do cụ thể.

Nhiều dev mình gặp có thói quen đặt 'use client' ở đầu mọi file vì "cho chắc". Điều này vô hiệu hóa toàn bộ lợi ích của Server Components. Nguyên tắc: chỉ thêm 'use client' khi component đó cần event handlers, state, hoặc browser APIs.

Cấu trúc thư mục mình hay dùng:

app/
  dashboard/
    page.tsx          # Server Component - fetch data, no JS
    _components/
      DashboardStats.tsx    # Server Component
      ChartWidget.tsx       # 'use client' - cần interactivity
      FilterBar.tsx         # 'use client' - controlled inputs
  actions/
    user-actions.ts   # 'use server' - all mutations
    post-actions.ts

344374 Tách rõ Server và Client components giúp bundle size giảm và code dễ maintain

Pattern thứ hai: "push client down"

Thay vì wrap toàn bộ section bằng 'use client', hãy đẩy interactivity xuống component nhỏ nhất có thể.

// Không tốt: cả ProductSection là client
'use client';
export function ProductSection({ product }) {
  const [liked, setLiked] = useState(false);
  return (
    <div>
      <h1>{product.title}</h1>   {/* Static - không cần client */}
      <p>{product.description}</p>  {/* Static */}
      <button onClick={() => setLiked(!liked)}>Like</button>  {/* Cần client */}
    </div>
  );
}

// Tốt hơn: chỉ button là client
export function ProductSection({ product }) {  // Server Component
  return (
    <div>
      <h1>{product.title}</h1>
      <p>{product.description}</p>
      <LikeButton />  {/* Client Component riêng */}
    </div>
  );
}

Nếu bạn chưa tự tin với React nền tảng trước khi chuyển sang những pattern này, khóa Xây Dựng Website với ReactJS của F8 cover khá kỹ từ hooks đến component architecture - sẽ giúp bạn hiểu rõ hơn tại sao "push client down" lại quan trọng.

Pattern thứ ba: đặt data fetching gần nơi dùng nhất

Với Server Components, bạn không cần prop drilling hay global state chỉ để pass data xuống. Mỗi Server Component tự fetch data của nó. Next.js deduplicates requests tự động nếu bạn dùng fetch với cùng URL.

// Mỗi component tự fetch - không cần pass props từ parent
async function UserAvatar({ userId }: { userId: string }) {
  const user = await fetchUser(userId);  // Next.js dedup request này
  return <img src={user.avatar} alt={user.name} />;
}

async function UserBio({ userId }: { userId: string }) {
  const user = await fetchUser(userId);  // Cùng request -> dedup, không fetch 2 lần
  return <p>{user.bio}</p>;
}

Khi nào KHÔNG nên dùng những tính năng này

Mình thấy nhiều bài viết chỉ nói về lợi ích mà bỏ qua phần quan trọng nhất: khi nào bạn không cần chúng.

Không cần Server Components nếu:

  • App của bạn là SPA thuần (dashboard nội bộ, tool dùng nội bộ không cần SEO)
  • Team chưa quen với mental model mới - learning curve có thể làm chậm delivery
  • Bạn đang dùng CRA hoặc Vite và mọi thứ đang chạy tốt

Không cần Server Actions nếu:

  • Bạn đã có API backend riêng (Spring Boot, Laravel, FastAPI...) và chỉ dùng Next.js làm frontend
  • Cần public API cho mobile app hoặc partner

Không cần PPR nếu:

  • Trang hoàn toàn static: dùng SSG thông thường, đơn giản hơn
  • Trang hoàn toàn dynamic: dùng SSR thông thường
  • Bạn không deploy trên infrastructure hỗ trợ streaming

344375 Không phải tool mới nào cũng cần dùng - chọn đúng tool cho đúng vấn đề

Thật ra, điểm mình muốn nhấn mạnh nhất: React 19 và Next.js 15 không phải bắt buộc adopt ngay. Nếu dự án bạn đang chạy ổn với Next.js 13/14, không có lý do gì phải rush migrate.

Nhưng nếu bạn bắt đầu dự án mới, hay đang thiết kế kiến trúc cho app tiếp theo - đây là lúc hợp lý để cân nhắc. Server Components và Server Actions giải quyết những pain point thực sự: bundle size, data fetching boilerplate, và ranh giới server/client.

Bước tiếp theo nếu bạn muốn thử:

  1. Tạo Next.js 15 app mới với npx create-next-app@latest - App Router là default
  2. Tham khảo Next.js 15 Upgrade Guide nếu migrate từ project cũ
  3. Thử build một feature nhỏ với Server Components trước khi commit cả team
  4. Profile bundle size trước và sau để có số liệu cụ thể

Gặp vướng mắc hay có case study thú vị, drop comment bên dưới - mình hay đọc và reply.

Trang web dùng cookie để hoạt động và đo hiệu quả quảng cáo. Xem chính sách bảo mật