วิธีแยก server action และ data access ออกจาก component

ให้ไฟล์ component มีแต่ JSX การอ่านข้อมูลไปอยู่ใน data layer ที่ขึ้นต้นด้วย server-only และตรวจสิทธิ์ทุกครั้ง ส่วนการแก้ข้อมูลไปอยู่ในไฟล์ 'use server' บาง ๆ ที่ตรวจ input เอา user จาก session แล้วเรียก body ในไฟล์ที่ไม่มี directive เพราะทุก export ของไฟล์ 'use server' คือ endpoint ที่ใครก็เรียกได้

เผยแพร่
เวลาอ่าน
7 นาที
แท็ก
#Next.js#React

หมวด 2 · Server และ Clientบทที่ 5 จาก 14

สารบัญบทเรียน บทที่ 5 จาก 14
ในบทนี้ 12 หัวข้อ
  1. ทุก export ในไฟล์ 'use server' คือ endpoint
  2. อาการที่บอกว่าถึงเวลาแยก
  3. ตัวอย่าง: หน้าตั้งค่าร้านที่ query และ action อยู่ในไฟล์เดียว
  4. ไฟล์ 'use server' ต้องบาง ส่วนงานจริงอยู่ใน body ที่ไม่มี directive
  5. ไฟล์ 'use server' ห้าม re-export
  6. data layer ที่ดีมีหน้าตาแบบนี้
  7. ตรวจสิทธิ์ไว้หลายชั้น แต่ชั้นที่ขาดไม่ได้คือชั้นที่ใกล้ข้อมูลที่สุด
  8. เรียก action จากฟอร์มด้วย useActionState
  9. ย้าย action ไปไฟล์อื่นแล้ว action ID เปลี่ยน
  10. จัดกลุ่ม export ตามเรื่อง ไม่ให้ไฟล์ไหนเกิน 20
  11. เมื่อไหร่ไม่ต้องเพิ่มชั้น
  12. จากงานจริง

ในบทใช้ Server Component เป็นหลัก เราให้ page ดึงข้อมูลเองได้ บทนี้ว่าด้วยข้อมูลควรอ่านและแก้จากตรงไหน กฎมีสามข้อ

  1. ไฟล์ component ไม่มี query และไม่มีฟังก์ชัน 'use server' อยู่ข้างใน มีแค่การประกอบหน้า
  2. การอ่านข้อมูลผ่าน data layer ที่ขึ้นต้นด้วย import 'server-only' ตรวจสิทธิ์ทุกครั้ง และคืนเฉพาะ field ที่หน้าใช้
  3. การแก้ข้อมูลผ่านไฟล์ 'use server' ที่บาง แต่ละ export ตรวจ input ด้วย schema เอา user จาก session แล้วเรียก body ที่อยู่ในไฟล์ธรรมดาซึ่งไม่มี directive

ถ้าเคยเขียน Laravel มาก่อน ภาพนี้คุ้นอยู่แล้ว data layer เทียบได้กับ query ที่อยู่ใน model หรือ service ไฟล์ 'use server' เทียบได้กับ controller ที่มี FormRequest ตรวจ input และ policy ตรวจสิทธิ์ ส่วน body คือ action class ที่ทำงานจริง ต่างกันตรงที่ Next.js ไม่มี route file ให้เห็นว่า endpoint อยู่ไหน เพราะ endpoint เกิดจาก directive บรรทัดเดียว

ทุก export ในไฟล์ 'use server' คือ endpoint

เอกสารของ React เขียนไว้ตรง ๆ ว่าทุกฟังก์ชันที่ export จากไฟล์ที่มี 'use server' เป็น server endpoint Next.js สร้าง action ID ให้แต่ละฟังก์ชันตอน build แล้วรับ POST request ที่มี ID นั้นจากที่ไหนก็ได้ ไม่จำกัดแค่ฟอร์มในเว็บของเรา

สิ่งที่ตามมาคือ:

  • argument ทุกตัวมาจากคนเรียก TypeScript บอกว่า id: string แต่ตอน runtime คนเรียกส่งอะไรมาก็ได้ ต้องตรวจด้วย schema เสมอ
  • ฟังก์ชันที่ตั้งใจให้เป็น helper ภายใน ถ้า export จากไฟล์นี้ ก็เป็น endpoint ด้วย แม้ไม่มีฟอร์มไหนเรียกเลยก็ตาม
  • ใครเป็นคนเรียก ต้องรู้จาก session บน server ห้ามเชื่อ argument ที่ส่งมา

ข้อสุดท้ายเป็นตัวกำหนดรูปร่างของโค้ดทั้งบท ถ้า action รับ userId เป็น argument ใครก็ใส่ user id ของคนอื่นมาได้ ทางที่ถูกคือให้ endpoint อ่าน user จาก session เอง แล้วส่งต่อให้ body ซึ่งไม่ได้เป็น endpoint

อาการที่บอกว่าถึงเวลาแยก

  • ไฟล์ .tsx import ตัวต่อฐานข้อมูลหรือ ORM
  • มีฟังก์ชันที่มี 'use server' เขียนอยู่ใน component
  • ไฟล์ 'use server' มี export เกิน 12 ตัว (เริ่มระวัง) หรือเกิน 20 ตัว (ควรแยก)
  • มี export ที่รับ userId, orgId หรือ role เป็น argument
  • มี export { x } from './y' ในไฟล์ 'use server'
  • action ที่ไม่มี schema ตรวจ input
  • client component import ไฟล์ฝั่ง server เพื่อเอาแค่ type แต่ไม่ได้ใช้ import type

ตัวอย่าง: หน้าตั้งค่าร้านที่ query และ action อยู่ในไฟล์เดียว

หน้าตั้งค่าชื่อร้านแบบสั้นที่สุด อ่านค่าเดิมจากฐานข้อมูลมาใส่ในฟอร์ม แล้วบันทึกกลับ

// ก่อน: app/settings/page.tsx query กับ action อยู่ข้าง JSX
import { db } from '@/lib/db';

export default async function SettingsPage() {
  const [row] = await db.select().from(settings).where(eq(settings.key, 'store'));

  async function save(form: FormData) {
    'use server';
    await db.update(settings)
      .set({ value: String(form.get('name')) })
      .where(eq(settings.key, 'store'));
  }

  return (
    <form action={save}>
      <input name="name" defaultValue={row.value} />
      <button>บันทึก</button>
    </form>
  );
}

// หลัง: data layer อ่าน, action แก้, page แค่ประกอบ
// lib/data/settings.ts
import 'server-only';
import { requireAdmin } from '@/lib/auth';

export async function getStoreSettings() {
  await requireAdmin();
  const row = await readSetting('store');
  return { name: row.value };                       // คืนเฉพาะที่หน้าใช้
}

// app/settings/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
import { requireAdmin } from '@/lib/auth';
import { StoreNameSchema } from './schema';
import { saveStoreNameBody } from './save-store-name';

export async function saveStoreName(_prev: FormState, form: FormData): Promise<FormState> {
  const user = await requireAdmin();
  const parsed = StoreNameSchema.safeParse({ name: form.get('name') });
  if (!parsed.success) return { error: 'กรอกชื่อร้าน 1–80 ตัวอักษร' };

  await saveStoreNameBody(user.id, parsed.data.name);
  revalidatePath('/settings');
  return { ok: true };
}

// app/settings/page.tsx
import { getStoreSettings } from '@/lib/data/settings';
import { StoreNameForm } from './_components/store-name-form';

export default async function SettingsPage() {
  const settings = await getStoreSettings();
  return <StoreNameForm initialName={settings.name} />;
}

ทีละจุด:

  • server-only บนสุดของ data layer ถ้ามี client component ไหน import ไฟล์นี้เข้าไป build จะล้มพร้อมบอกเส้นทาง ข้อมูลจากฐานข้อมูลจึงไม่มีทางหลุดไปเป็นโค้ดฝั่ง client
  • ตรวจสิทธิ์ในฟังก์ชันอ่านข้อมูลด้วย หลายคนตรวจแค่ที่ page หรือ layout แต่เอกสาร Data Security ของ Next.js แนะนำให้ data layer ตรวจเอง เพราะหลายหน้าเรียกใช้ฟังก์ชันอ่านข้อมูลตัวเดียวกัน และ layout ไม่ได้ render ใหม่ทุกครั้งที่เปลี่ยนหน้า
  • คืนเฉพาะ field ที่ใช้ หน้านี้ต้องการแค่ชื่อร้าน ก็คืน { name } ไม่ใช่ทั้งแถว อะไรที่ Server Component ส่งต่อให้ client component จะไปอยู่ใน HTML ของหน้า ถ้าส่งทั้งแถวก็ส่งทุกคอลัมน์ไปด้วย
  • action รับ FormData แล้วตรวจด้วย schema ก่อนทำอะไร ข้อความ error ที่คืนไปเป็นข้อความสำหรับผู้ใช้ ไม่ใช่ error จากฐานข้อมูล
  • page เหลือแค่การประกอบ อ่านจบในไม่กี่บรรทัดว่าหน้านี้แสดงอะไร ฟอร์มย้ายไปเป็น client component เพราะต้องแสดงสถานะกำลังบันทึกและข้อความ error

ไฟล์ 'use server' ต้องบาง ส่วนงานจริงอยู่ใน body ที่ไม่มี directive

ตัวอย่างข้างบนมี saveStoreNameBody แยกออกไปอีกไฟล์ เหตุผลคือแยกสองเรื่องที่ต่างกันออกจากกัน

  • ไฟล์ 'use server' คือประตูหน้าบ้าน รับของจากข้างนอก ตรวจว่าใครมา ตรวจของที่ส่งมา แล้วส่งต่อ
  • ไฟล์ body คือคนทำงานข้างใน รับ user id ที่ประตูหน้ายืนยันแล้ว และข้อมูลที่ผ่าน schema แล้ว ทำงานจริงกับฐานข้อมูล

body รับ userId เป็น argument ได้อย่างปลอดภัย เพราะไม่ได้เป็น endpoint ไม่มีใครจากข้างนอกเรียกได้ และ test ง่ายกว่าด้วย เพราะไม่ต้องจำลอง session

// ก่อน: actions/addresses.ts ทุก helper กลายเป็น endpoint
'use server';

export async function saveAddress(userId: string, input: AddressInput) {
  // เชื่อทั้ง userId และ input ที่ส่งมา
}

export async function formatAddressLine(a: Address) {
  // ตั้งใจเป็น helper แต่ export จากไฟล์นี้ก็เป็น endpoint ไปด้วย
}

// หลัง: endpoint บาง ๆ เอา user จาก session, body ไม่มี directive
// actions/address-actions.ts
'use server';
import { requireUser } from '@/lib/auth';
import { AddressSchema } from './address-schema';
import { saveAddressBody } from './address-body';

export async function saveAddress(input: unknown) {
  const user = await requireUser();
  const data = AddressSchema.parse(input);
  return saveAddressBody(user.id, data);
}

// actions/address-body.ts (ไม่มี 'use server' จึงไม่เป็น endpoint)
import 'server-only';

export async function saveAddressBody(userId: string, input: AddressInput) {
  // บันทึกที่อยู่ของ userId นี้
}

// lib/format-address.ts (ฟังก์ชันล้วน ใช้ได้ทั้งสองฝั่ง)
export function formatAddressLine(a: Address) { /* … */ }

ถ้าโปรเจกต์มี action หลายสิบตัว การเขียน requireUser() กับ safeParse ซ้ำทุกตัวจะเริ่มน่าเบื่อและลืมง่าย ทางที่นิยมคือห่อด้วยตัวช่วยตัวเดียว เช่น library next-safe-action ที่ประกอบ schema กับ middleware ตรวจสิทธิ์ให้ หรือเขียน wrapper เองไม่กี่บรรทัด

// lib/action.ts
import 'server-only';
import type { z } from 'zod';
import { requireUser } from '@/lib/auth';

export function userAction<S extends z.ZodType, R>(
  schema: S,
  body: (input: z.infer<S>, ctx: { user: User }) => Promise<R>,
) {
  return async (input: unknown) => {
    const user = await requireUser();
    return body(schema.parse(input), { user });
  };
}

// actions/address-actions.ts
'use server';
export const saveAddress = userAction(AddressSchema, (input, { user }) =>
  saveAddressBody(user.id, input),
);

แบบนี้ทุก export ในไฟล์ 'use server' อ่านได้ในบรรทัดเดียวว่าใช้ schema อะไร และใครเรียกได้

ไฟล์ 'use server' ห้าม re-export

บรรทัดอย่าง export { saveAddressBody } from './address-body' ในไฟล์ 'use server' ทำให้ body กลายเป็น endpoint ทันที ทั้งที่เราแยกไฟล์ออกมาก็เพื่อไม่ให้เป็น

กับดักนี้มักเกิดตอนแยกไฟล์ใหญ่ เพราะวิธีที่เร็วที่สุดที่จะไม่ต้องแก้ import ทั้งโปรเจกต์คือทิ้ง re-export ไว้ที่ไฟล์เดิม ซึ่งใช้ได้กับไฟล์ธรรมดา แต่ใช้ไม่ได้กับไฟล์ 'use server' ให้ไฟล์เดิมเหลือเฉพาะ export ที่ตั้งใจให้เป็น endpoint แล้วเขียนแต่ละตัวเป็นฟังก์ชันหรือ wrapper ที่เรียก body อีกทีแบบตัวอย่างข้างบน

data layer ที่ดีมีหน้าตาแบบนี้

data layer ไม่ต้องเป็นอะไรซับซ้อน เป็นโฟลเดอร์ lib/data/ ที่มีไฟล์ละเรื่องก็พอ แต่ละฟังก์ชันทำสามอย่างเสมอ คือตรวจสิทธิ์ อ่านข้อมูล แล้วคืน object ที่หน้าใช้ (Next.js เรียกว่า DTO หรือ Data Transfer Object)

// lib/data/orders.ts
import 'server-only';
import { cache } from 'react';
import { requireUser } from '@/lib/auth';

export const getMyOrders = cache(async () => {
  const user = await requireUser();
  const rows = await findOrdersByCustomer(user.id);

  return rows.map((o) => ({
    id: o.id,
    number: o.number,
    total: o.total,
    status: o.status,
    placedAt: o.createdAt,
  }));
});

สามจุดที่ควรสังเกต:

  • ฟังก์ชันไม่รับ user id เพราะรู้เองจาก session หน้าไหนเรียก getMyOrders() ก็ได้คำสั่งซื้อของคนที่ล็อกอินอยู่เท่านั้น
  • cache ของ React ทำให้ใน request เดียวกัน ถ้า header กับเนื้อหาของหน้าเรียก getMyOrders() ทั้งคู่ query จะวิ่งแค่ครั้งเดียว Server Component จึงดึงข้อมูลตรงที่ใช้ได้โดยไม่ต้องส่ง props ต่อกันหลายชั้น ส่วน cache ระหว่าง request เป็นอีกเรื่องหนึ่ง ซึ่ง Next.js 16 มี 'use cache' ให้ใช้แยกต่างหาก
  • map เป็น object ใหม่ทุกครั้ง คอลัมน์ที่เพิ่มเข้าตารางในอนาคตจะไม่ไหลไปถึงหน้าเว็บเอง ต้องมีคนตั้งใจเพิ่มใน DTO

ตรวจสิทธิ์ไว้หลายชั้น แต่ชั้นที่ขาดไม่ได้คือชั้นที่ใกล้ข้อมูลที่สุด

Next.js 16 มีที่ให้ตรวจสิทธิ์ได้หลายที่ แต่ละที่กันได้ไม่เท่ากัน

ตรวจที่ กันอะไรได้ ข้อจำกัด
proxy.ts (เดิมชื่อ middleware) ส่งคนที่ไม่ได้ล็อกอินไปหน้า login เร็ว ๆ เห็นแค่ request ไม่ควรอ่านฐานข้อมูล
layout ซ่อนเมนูหรือทั้งส่วนหลังร้าน ไม่ render ใหม่ทุกครั้งที่เปลี่ยนหน้า และไม่ครอบ server action
data layer ทุกการอ่านข้อมูล ไม่ว่าหน้าไหนเรียก ต้องเขียนในทุกฟังก์ชัน
ไฟล์ 'use server' ทุกการแก้ข้อมูล ไม่ว่าใครเรียกมา ต้องเขียนในทุก export

proxy กับ layout ช่วยเรื่องประสบการณ์ใช้งาน แต่ server action เรียกตรงได้โดยไม่ผ่าน layout เลย ชั้นที่ต้องมีเสมอจึงเป็นสองชั้นล่าง คือ data layer กับไฟล์ 'use server' ถ้าลืมชั้นบน ผู้ใช้แค่เห็นหน้าที่ไม่ควรเห็นแล้วกดอะไรไม่ได้ ถ้าลืมชั้นล่าง ข้อมูลรั่ว

เรียก action จากฟอร์มด้วย useActionState

ฝั่งฟอร์มที่เป็น client component ใช้ useActionState ของ React 19 ได้ตรง ๆ ได้ทั้งผลลัพธ์ล่าสุดและสถานะกำลังส่ง

// app/settings/_components/store-name-form.tsx
'use client';

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

export function StoreNameForm({ initialName }: { initialName: string }) {
  const [state, formAction, isPending] = useActionState(saveStoreName, {});

  return (
    <form action={formAction}>
      <input name="name" defaultValue={initialName} />
      {state.error && <p role="alert">{state.error}</p>}
      <button disabled={isPending}>{isPending ? 'กำลังบันทึก…' : 'บันทึก'}</button>
    </form>
  );
}

ฟอร์มนี้ไม่รู้ว่าข้างหลังบันทึกยังไง รู้แค่ว่ามี action ให้เรียก และจะได้ { error } หรือ { ok } กลับมา ส่วนฟอร์มที่ซับซ้อนกว่านี้ ซึ่งต้องกันกดซ้ำหรือมีหลายเงื่อนไขก่อนส่ง อยู่ในบทกันฟอร์มส่งซ้ำด้วย hook ที่คุมการ submit ที่เดียว

ย้าย action ไปไฟล์อื่นแล้ว action ID เปลี่ยน

Next.js อ้างถึง action แต่ละตัวด้วย ID ที่สร้างตอน build ตามเอกสารของ Next.js 16 ID เหล่านี้หมุนใหม่อย่างน้อยทุก 14 วันอยู่แล้วแม้โค้ดไม่เปลี่ยน และเปลี่ยนเมื่อ build ใหม่ การย้าย action ไปอีกไฟล์ทำให้ ID เปลี่ยนแน่นอนใน deploy ถัดไป

ผลที่เห็นคือ ผู้ใช้ที่เปิดหน้าค้างไว้ตั้งแต่ก่อน deploy กดบันทึกแล้วเจอ error "Failed to find Server Action" จนกว่าจะ reload หน้า การแยกไฟล์ทำงานถูกต้องแล้ว แค่ควรบอกไว้ในคำอธิบายของ PR และถ้าเป็นหน้าหลังร้านที่คนเปิดทิ้งไว้ทั้งวัน ก็ควรมีข้อความให้ reload เมื่อเจอ error นี้

ถ้ารันหลาย instance ต้องตั้ง encryption key ของ server action ให้เหมือนกันทุกเครื่องด้วย ไม่อย่างนั้นเจอ error เดียวกันตอน request วิ่งไปคนละเครื่อง

จัดกลุ่ม export ตามเรื่อง ไม่ให้ไฟล์ไหนเกิน 20

ไฟล์ action ที่โตมาเรื่อย ๆ มักเป็นไฟล์เดียวต่อหนึ่งตาราง เช่น user-actions.ts ที่มีทุกอย่างเกี่ยวกับผู้ใช้ ตั้งแต่เปลี่ยนรหัสผ่านถึงเชิญเข้าทีม พอเกิน 20 export ให้แบ่งตามเรื่องย่อยที่ผู้ใช้ทำ เช่น profile-actions.ts, invite-actions.ts, password-actions.ts แต่ละไฟล์อ่านจบในครั้งเดียว และตรวจได้ง่ายว่าทุก export มี schema กับการตรวจสิทธิ์ครบ

ตอนย้ายแต่ละ action ให้ย้ายสิ่งที่ผูกกับ action นั้นไปด้วย เช่น การตรวจ capability, revalidatePath หรือการบันทึก log ถ้าบรรทัดพวกนี้ตกหล่น typecheck กับ test ส่วนใหญ่ยังผ่าน แต่พฤติกรรมเปลี่ยนไปแล้ว วิธีเทียบว่าย้ายครบอยู่ในบทrefactor component React ที่ใหญ่เกิน

เมื่อไหร่ไม่ต้องเพิ่มชั้น

  • หน้าอ่านอย่างเดียวที่ไม่มีเรื่องสิทธิ์ เช่น หน้าบทความสาธารณะ เรียกฟังก์ชันอ่านข้อมูลตรง ๆ ได้ ไม่ต้องสร้าง data layer แยกเพื่อห่ออีกชั้น กฎนี้มีไว้กันไม่ให้ query ไปปนกับ JSX ไม่ได้บังคับให้มีชั้นเยอะ ๆ
  • action เล็ก ๆ ตัวเดียวของหน้า อยู่ในไฟล์ actions.ts ข้างหน้านั้นก็พอ ไม่ต้องมีไฟล์ body แยก ถ้ายังไม่มีใครอื่นเรียกใช้งานข้างในและไม่ได้รับ id ของใครเป็น argument
  • Route Handler (route.ts) สำหรับ API ที่ระบบอื่นเรียก ยังเหมาะกว่า server action เมื่อต้องการ URL ที่คงที่ หรือรับ webhook

จากงานจริง

ในร้านค้าออนไลน์ Next.js 16 กับ CMS ที่เป็น Next.js admin ใน pnpm monorepo ที่เรา refactor ไป ไฟล์ฝั่ง server ที่ใหญ่ที่สุดยาว 1,000 ถึง 1,700 บรรทัด มีราวสิบสองไฟล์ ส่วนใหญ่เป็นไฟล์ action รวมของทั้งเรื่อง ไฟล์หนึ่งมี 22 export พอแบ่งตามเรื่องย่อยก็ได้สี่ไฟล์ที่มี 4, 5, 8 และ 5 export ซึ่งเปิดอ่านแล้วไล่ตรวจได้ทีละตัวว่าทุก endpoint ตรวจ input และสิทธิ์ครบ สคริปต์ตรวจโค้ดยังเจอไฟล์ .tsx ที่ import ตัวต่อฐานข้อมูลอีกสี่ไฟล์ ซึ่งย้าย query ไปไว้ใน data layer ทั้งหมด ทุก PR ที่ย้าย action เราเขียนไว้ในคำอธิบายด้วยว่าหน้าที่เปิดค้างไว้ต้อง reload หลัง deploy

บทถัดไป

อ่านต่อ

ถูกอ้างถึงใน

ล่าสุด

ดูทั้งหมด →