โหมดมืด
บทที่ 3 — CRUD + Optimistic UI
หลังจาก auth พร้อมแล้ว — บทนี้ทำ CRUD ที่ "ดูเหมือนแอปจริง"
ใช้ตัวอย่าง: Task Manager — User ทำงานกับ task ของตัวเอง (CRUD + filter + pagination + optimistic UI)
หลังจบบท คุณจะ:
- ออกแบบ entity + relation (User ↔ Task)
- สร้าง full CRUD endpoint ที่ secure (user เห็นแต่ task ของตัวเอง)
- React UI ที่ใช้ TanStack Query optimistic update
- Search + Filter + Pagination ที่ทำงานข้าม stack
- Bulk operation (delete multiple)
1. Spec ของฟีเจอร์
กำหนด spec ให้ชัดก่อนลงมือ: มี field อะไร, user ทำอะไรได้บ้าง, มี constraint อะไร (เช่น เห็นแต่ task ตัวเอง)
Task:
- id, title, description, status, priority, due_date, created_at, updated_at, owner_id
- status: TODO / IN_PROGRESS / DONE
- priority: LOW / MEDIUM / HIGH
User actions:
✓ List own tasks + filter by status, priority, search by title
✓ Pagination (10 per page)
✓ Create new task
✓ Update task (toggle status, edit title)
✓ Delete task (single + bulk)
✓ Sort by due_date / priority
Constraints:
- User เห็นแต่ task ของตัวเอง
- Task ไม่มี owner_id ใน DTO (auto จาก JWT)Part 1: Backend
2. Entity
เริ่มที่ชั้นล่างสุด — entity ที่ map กับตาราง tasks สังเกตการใช้ @Enumerated(STRING) เก็บ enum (อี-นัม = enumeration ชุดค่าที่กำหนดล่วงหน้า) เป็น string (อ่านง่ายใน DB), timestamp อัตโนมัติ (@CreationTimestamp) และ @ManyToOne ผูกกับ owner ที่เป็นพื้นฐานของ "เห็นแต่ task ตัวเอง":
📚 ต้องรู้ JPA basics ก่อน — บทนี้ใช้ JPA annotation เยอะ (
@Entity,@Table,@Id,@GeneratedValue,@Enumerated,@CreationTimestamp,@ManyToOne,@JoinColumn) ถ้ายังไม่คุ้น แนะนำอ่าน Spring Boot — Database & JPA ก่อน
💡 คำศัพท์ JPA (พบบ่อยทั้งบท):
- entity (เอน-ทิ-ตี้) = คลาส Java ที่ map กับ 1 ตารางใน DB; field = column
- dirty checking (เดอร์-ตี้ เช็ค-คิ้ง = "ตรวจของที่เปื้อน/เปลี่ยน") = ภายใน transaction เดียวกัน JPA จะคอย "ดู" ว่า field ไหนของ entity ที่ load ขึ้นมาแล้วถูกแก้ → ตอน transaction commit จะส่ง SQL UPDATE ให้เอง โดยที่เราไม่ต้องเรียก
save()- Specification (สเปก-ซิ-ฟิ-เค-ชั่น = ข้อกำหนด) = pattern ของ Spring Data ให้ประกอบ where-clause แบบ dynamic
- N+1 problem = ดึง parent 1 query แล้ววน loop ดึง child ทีละแถว (รวมเป็น N+1 queries) → ช้ามาก แก้ด้วย
@EntityGraphหรือ JPQLJOIN FETCH- LazyInitializationException = พยายามอ่าน field ของ lazy entity นอก transaction → exception
java
@Entity
@Table(name = "tasks")
@Getter @Setter
@NoArgsConstructor
public class Task {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String title;
@Column(columnDefinition = "TEXT")
private String description;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private TaskStatus status = TaskStatus.TODO; // ⚠️ field initializer (ดูหมายเหตุใต้โค้ด)
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private TaskPriority priority = TaskPriority.MEDIUM;
@Column(name = "due_date")
private LocalDate dueDate;
@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@UpdateTimestamp
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "owner_id", nullable = false)
private User owner;
}
public enum TaskStatus { TODO, IN_PROGRESS, DONE }
public enum TaskPriority { LOW, MEDIUM, HIGH }⚠️ หมายเหตุเรื่อง field initializer + JPA การเขียน
private TaskStatus status = TaskStatus.TODO;แบบนี้ทำงานได้กับnew Task()(ในcreate()ที่เราเรียก constructor เอง) เพราะ Java จะรัน field initializer ตอน constructor เริ่มทำงาน — แต่ ตอน JPA hydrate (สร้าง entity จากผลลัพธ์ SQL) Hibernate บาง version หรือ bytecode-instrumented build จะข้าม initializer แล้ว set field โดยตรง ทำให้ "default" ไม่ถูกใช้ — ในเคสนี้ไม่กระทบเพราะ DB มีDEFAULT 'TODO'อยู่แล้ว แต่ถ้าอยาก robust จริง ใช้@PrePersistหรือกำหนดใน constructor explicit:java@PrePersist void prePersist() { if (status == null) status = TaskStatus.TODO; if (priority == null) priority = TaskPriority.MEDIUM; }
3. Flyway Migration
แทนที่จะให้ JPA สร้างตารางเอง (อันตรายใน production) เราคุม schema ด้วย migration tool อย่าง Flyway — เขียน SQL เป็นไฟล์ versioned ที่ Flyway รันตามลำดับ ทำให้ schema เป็น version control และ deploy ได้แน่นอน สังเกต index ที่สร้างให้ตรงกับ query ที่จะใช้:
📚 ยังไม่คุ้น Flyway หรือ PostgreSQL? อ่าน Spring Boot — Database & JPA ก่อน และศัพท์ PostgreSQL ที่ใช้ในไฟล์นี้:
BIGSERIAL= คอลัมน์BIGINTที่ auto-increment (ทางเลือกแบบ SQL-standard ที่โมเดิร์นกว่าใช้BIGINT GENERATED ALWAYS AS IDENTITY— ทั้งสองทำงานเทียบเท่า)REFERENCES users(id)= foreign key ผูกกับusers.idON DELETE CASCADE= ถ้า user ถูกลบ → task ทุกตัวของเขาถูกลบตามอัตโนมัติ
sql
-- V2__create_tasks_table.sql
CREATE TABLE tasks (
id BIGSERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
description TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'TODO',
priority VARCHAR(20) NOT NULL DEFAULT 'MEDIUM',
due_date DATE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
owner_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE
);
-- หมายเหตุ: index ด้านล่างเลือกให้ตรงกับ query หลัก (filter by owner + status, sort by due_date)
CREATE INDEX idx_tasks_status ON tasks(owner_id, status); -- composite index — ใช้ได้ทั้ง query แบบ (owner_id) อย่างเดียว และ (owner_id, status)
CREATE INDEX idx_tasks_due_date ON tasks(due_date);🔧 ตัวเลือกขั้นสูง / production:
- SQL-standard syntax: ใช้
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEYแทนBIGSERIAL— Hibernate 6.x รองรับเต็มและพกพาข้าม DB ได้ดีกว่าON DELETE CASCADEvsRESTRICT: ตัวอย่างนี้ใช้CASCADEเพื่อความเรียบง่าย; ใน production ที่มี audit/compliance/soft-delete ควรใช้ON DELETE RESTRICTแล้วลบ task ผ่าน service layer (รู้จำนวน, log ได้, ยกเลิกได้)- redundant index: ลบ
idx_tasks_ownerแบบเดี่ยวออก เพราะ composite(owner_id, status)ใช้กับ query ที่มีแค่owner_idได้อยู่แล้ว (leading-column rule) — ตัวอย่างนี้แก้ให้แล้ว- ค้นด้วย LIKE จะช้าที่ scale ใหญ่: เพิ่ม trigram index ของ Postgres
sqlหรือใช้ full-text search (CREATE EXTENSION IF NOT EXISTS pg_trgm; CREATE INDEX idx_tasks_title_trgm ON tasks USING gin(title gin_trgm_ops);tsvector+ GIN index)
4. Repository (Spring Data)
repository คือชั้นเข้าถึงข้อมูล — Spring Data ให้ method พื้นฐานฟรี (save, findById) และ generate query จากชื่อ method (findByIdAndOwnerId — เป็นการ enforce ownership ในตัว) ส่วน JpaSpecificationExecutor เปิดทางให้ dynamic filter ในหัวข้อถัดไป:
💡 ศัพท์ในบล็อกนี้:
@Modifying= บอก Spring Data ว่า query ตัวนี้เป็น UPDATE/DELETE (ไม่ใช่ SELECT) ต้อง execute แบบเขียนข้อมูล@Query("...")= เขียน query เอง (ภาษาคือ JPQL — เหมือน SQL แต่อ้างชื่อ entity/field ของ Java ไม่ใช่ชื่อตาราง/คอลัมน์)@Param("ids")= ผูกชื่อใน query (:ids) กับ method parameter — Spring Data จะใส่ค่าให้แบบ parameterized (กัน SQL injection)
java
public interface TaskRepository extends JpaRepository<Task, Long>, JpaSpecificationExecutor<Task> {
Optional<Task> findByIdAndOwnerId(Long id, Long ownerId);
@Modifying
@Query("DELETE FROM Task t WHERE t.id IN :ids AND t.owner.id = :ownerId")
int deleteByIdsAndOwner(@Param("ids") List<Long> ids, @Param("ownerId") Long ownerId);
// ทางเลือก: ลบโดยไม่ต้อง load entity ก่อน (เร็วกว่า — 1 SQL แทน 2)
int deleteByIdAndOwnerId(Long id, Long ownerId);
}⚠️
@Modifyingต้องอยู่ใน transactiondeleteByIdsAndOwnerใช้@Modifying— ต้อง ถูกเรียกในบริบทที่มี@Transactional(เช่นจาก service ที่ class-level@Transactionalแบบบทนี้) ถ้าเรียกตรงจาก test หรือ controller โดยไม่มี transaction →TransactionRequiredExceptionถ้าอยากให้ method นี้ self-contained ใส่@Transactionalเพิ่มที่ตัว method ใน repository ได้
🔍 Spring generate SQL อะไรจาก
findByIdAndOwnerId? Spring Data แปลชื่อ methodfindByIdAndOwnerId(Long id, Long ownerId)→ JPQL → SQL ประมาณนี้:sqlSELECT t.* FROM tasks t WHERE t.id = ? AND t.owner_id = ?ทั้ง
idและowner_idอยู่ใน WHERE — ถ้า user A (owner_id=1) ขอ task ที่เป็นของ user B (owner_id=2) DB จะคืน "ไม่เจอ" → service ของเราโยนEntityNotFoundException(404) แทนที่จะคืนข้อมูลของคนอื่น (กัน BOLA ที่ DB layer)
🚀 กัน N+1: ถ้าต้อง access
task.getOwner().getXxx()ใน list response default@ManyToOne(fetch = LAZY)จะยิง 1 SQL ต่อ task เพื่อโหลด owner → list 10 task = 11 queries (N+1) แก้ด้วย@EntityGraph:java@EntityGraph(attributePaths = "owner") Optional<Task> findByIdAndOwnerId(Long id, Long ownerId);หรือใน JPQL ใช้
JOIN FETCH:java@Query("SELECT t FROM Task t JOIN FETCH t.owner WHERE t.id = :id AND t.owner.id = :ownerId")
5. Specification (สเปก-ซิ-ฟิ-เค-ชั่น) — Dynamic Filter
ฟีเจอร์ filter ที่ผู้ใช้เลือกได้หลายเงื่อนไข (status + priority + search) ถ้าเขียน query แยกทุก combination จะระเบิด — Specification ของ Spring Data ให้ประกอบเงื่อนไขแบบ dynamic (เงื่อนไขไหน null ก็ข้าม) แล้วต่อกันด้วย .and() ทำให้ filter ยืดหยุ่นโดยใช้โค้ดชุดเดียว
💡 เดินตามโค้ดทีละบรรทัด — Criteria API คืออะไร?
Specification<Task>คือ functional interface ที่มี 1 method:Predicate toPredicate(Root<Task> root, CriteriaQuery<?> query, CriteriaBuilder cb)คืนPredicate(เงื่อนไข WHERE 1 ตัว)
root= ตัวแทนตารางtasksใน query (ใช้root.get("ชื่อ field")อ้างคอลัมน์)query= ตัว query ทั้งก้อน (ใช้ปรับ join/distinct ได้)cb(CriteriaBuilder) = "โรงงาน" สำหรับสร้าง predicate (cb.equal,cb.like,cb.and, ...)เปรียบเทียบกับ SQL:
javacb.equal(root.get("status"), status) // SQL: status = ? cb.like(cb.lower(root.get("title")), "%abc%") // SQL: lower(title) LIKE '%abc%'คืน
nullหมายถึง "ข้ามเงื่อนไขนี้" — Spring Data จะตัดออกจาก WHERE ให้
java
public class TaskSpecifications {
public static Specification<Task> byOwner(Long ownerId) {
return (root, query, cb) -> cb.equal(root.get("owner").get("id"), ownerId);
}
public static Specification<Task> withStatus(TaskStatus status) {
return (root, query, cb) -> status == null ? null : cb.equal(root.get("status"), status);
}
public static Specification<Task> withPriority(TaskPriority priority) {
return (root, query, cb) -> priority == null ? null : cb.equal(root.get("priority"), priority);
}
/**
* ค้นชื่อ task แบบ case-insensitive (`LIKE %xxx%`)
* — ต้อง escape wildcard `%`, `_`, `\` จาก user input ก่อน
* ไม่งั้น user พิมพ์ `%` จะแมตช์ทุกอัน (เซอร์ไพรส์ UX)
*/
public static Specification<Task> titleContains(String search) {
return (root, query, cb) -> {
if (search == null || search.isBlank()) return null;
String escaped = escapeLikeWildcards(search.toLowerCase());
return cb.like(cb.lower(root.get("title")), "%" + escaped + "%", '\\');
// ⬆ ตัวที่ 3 ของ cb.like คือ escape character — บอกว่า '\' คือสัญลักษณ์ escape
// SQL ที่ generate: lower(title) LIKE ? ESCAPE '\'
};
}
/** แปลง `%`, `_`, `\` ใน user input ให้ literal — ป้องกัน wildcard injection */
private static String escapeLikeWildcards(String input) {
return input
.replace("\\", "\\\\") // ต้องทำเป็นตัวแรก ไม่งั้นจะ escape ตัว `\` ที่เราใส่เองด้วย
.replace("%", "\\%")
.replace("_", "\\_");
}
}⚠️ เกิดอะไรขึ้นถ้าไม่ escape? User พิมพ์
%→ SQL กลายเป็นLIKE '%%%'(3 ตัว wildcard) → match ทุก row User พิมพ์_→LIKE '%_%'→ match ทุก row ที่มีตัวอักษรอย่างน้อย 1 (= แทบทั้งหมด) นี่ไม่ใช่ SQL injection (JPA parameterize ให้แล้ว) แต่เป็น LIKE wildcard injection — user ควบคุมความหมายของ pattern ได้
🧩 ประกอบ Specification หลายตัวเข้าด้วยกัน (ดู
TaskService.list()ในหัวข้อถัดไป):javaSpecification<Task> spec = Specification .where(TaskSpecifications.byOwner(ownerId)) .and(TaskSpecifications.withStatus(status)) .and(TaskSpecifications.withPriority(priority)) .and(TaskSpecifications.titleContains(search));Spring จะ AND เฉพาะ predicate ที่ไม่ใช่ null Spring Data ≥ 3.0 มี
Specification.allOf(...)/anyOf(...)ที่อ่านง่ายกว่าโซ่.and()ยาว ๆ:javaSpecification<Task> spec = Specification.allOf( TaskSpecifications.byOwner(ownerId), TaskSpecifications.withStatus(status), TaskSpecifications.withPriority(priority), TaskSpecifications.titleContains(search) );
6. DTO
แยก DTO ออกจาก entity — Response (สิ่งที่ส่งให้ client, ไม่มี owner_id), CreateRequest/UpdateRequest (รับ input พร้อม validation) สังเกตว่า owner_id ไม่อยู่ใน request เลย เพราะมาจาก JWT แทน เพื่อกัน user ปลอม owner ของคนอื่น:
💡 DTO (Data Transfer Object) คืออ็อบเจกต์ที่ใช้รับ-ส่งข้อมูลผ่าน API โดยเฉพาะ แยกจาก entity ในฐานข้อมูล — pattern นี้ยังกัน over-posting (โอ-เวอร์-โพส-ติ้ง = ยัด field เกินที่อนุญาต เช่น user แอบส่ง
owner_idเพื่อย้าย task ของคนอื่นมาเป็นของตัวเอง) ด้วยการไม่รับ field นั้นเข้า DTO เลย
java
public record TaskResponse(
Long id,
String title,
String description,
TaskStatus status,
TaskPriority priority,
LocalDate dueDate,
Instant createdAt,
Instant updatedAt
) {
public static TaskResponse from(Task t) {
return new TaskResponse(
t.getId(), t.getTitle(), t.getDescription(),
t.getStatus(), t.getPriority(), t.getDueDate(),
t.getCreatedAt(), t.getUpdatedAt()
);
}
}
public record CreateTaskRequest(
@NotBlank @Size(max = 255) String title,
@Size(max = 5000) String description,
TaskPriority priority,
LocalDate dueDate
) {}
public record UpdateTaskRequest(
@Size(max = 255) String title,
@Size(max = 5000) String description,
TaskStatus status,
TaskPriority priority,
LocalDate dueDate
) {}
public record BulkDeleteRequest(@NotEmpty List<Long> ids) {}7. Service
business logic ทั้งหมดรวมที่ TaskService — สังเกตว่าทุก method รับ ownerId แล้วใช้ findByIdAndOwnerId เพื่อ enforce (บังคับ) ว่า user แตะได้แค่ task ตัวเอง (กัน BOLA), ใช้ dirty checking (แก้ entity แล้ว auto save) และ @Transactional(readOnly) สำหรับ query อ่าน:
คำศัพท์ความปลอดภัย/JPA ในหัวข้อนี้ (อ่านก่อน — เป็นหัวใจของบท):
- BOLA (Broken Object Level Authorization) = ช่องโหว่ที่ user A เดา/เปลี่ยน
idในคำขอ แล้วเข้าถึงข้อมูลของ user B ได้ (เช่นเปิด/api/tasks/999ที่เป็นของคนอื่น) — เรากันด้วยการ query แบบfindByIdAndOwnerIdที่บังคับว่า task ต้องเป็นของ owner คนที่ login เท่านั้น- dirty checking (เดอร์-ตี้ เช็ค-คิ้ง) = พฤติกรรมของ JPA: ภายใน transaction เดียวกัน ถ้าเราแก้ค่า field ของ entity ที่ load (ดึง) ขึ้นมา JPA จะ "จำว่ามีการเปลี่ยน" แล้ว save ลง DB ให้เองตอน transaction จบ — ไม่ต้องเรียก
save()เอง (สังเกตเมธอดupdate()ข้างล่างที่แก้ค่าแล้วไม่มีsave())- transaction (ทรานแซก-ชั่น) = กลุ่มคำสั่ง DB ที่ทำงานเป็นชุดเดียว (สำเร็จทั้งหมดหรือยกเลิกทั้งหมด);
@Transactional(readOnly = true)บอกว่าเมธอดนี้แค่อ่าน ไม่เขียน → DB ทำงานเร็วขึ้น@Transactionaldefault rollback rule = Spring จะ rollback เฉพาะเมื่อโยนRuntimeExceptionหรือErrorเท่านั้น ถ้าโยน checked exception (เช่นIOException) จะ commit เงียบ ๆ — bug ที่หาไม่เจอ! เลือกอย่างใดอย่างหนึ่ง:
- ใช้ unchecked exception (RuntimeException) เสมอใน service (วิธีนี้คือสิ่งที่บทนี้ใช้ —
EntityNotFoundExceptionเป็น unchecked)- หรือเขียน
@Transactional(rollbackFor = Exception.class)ให้ rollback ทุก exception
java
@Service
@RequiredArgsConstructor
@Transactional
public class TaskService {
private final TaskRepository taskRepo;
private final UserRepository userRepo;
@Transactional(readOnly = true)
public Page<TaskResponse> list(
Long ownerId,
TaskStatus status,
TaskPriority priority,
String search,
Pageable pageable
) {
Specification<Task> spec = Specification
.where(TaskSpecifications.byOwner(ownerId))
.and(TaskSpecifications.withStatus(status))
.and(TaskSpecifications.withPriority(priority))
.and(TaskSpecifications.titleContains(search));
return taskRepo.findAll(spec, pageable).map(TaskResponse::from);
}
@Transactional(readOnly = true)
public TaskResponse getById(Long id, Long ownerId) {
Task task = taskRepo.findByIdAndOwnerId(id, ownerId)
.orElseThrow(() -> new EntityNotFoundException("Task not found"));
return TaskResponse.from(task);
}
public TaskResponse create(CreateTaskRequest req, Long ownerId) {
// getReferenceById คืน lazy proxy — ไม่ยิง SQL SELECT ตอนนี้
// เราใช้แค่เพื่อเซ็ต FK (owner_id) ใน task เลยพอ ไม่ต้องอ่าน field ของ owner
// ปลอดภัยเพราะ ownerId มาจาก JWT ที่ผ่าน auth filter แล้ว → user ต้องมีจริง
// ⚠️ ถ้าโค้ดข้างใต้นี้ดัน access field ของ owner (เช่น owner.getEmail()) จะเจอ LazyInitializationException หรือ EntityNotFoundException ลอยมาตอน flush
User owner = userRepo.getReferenceById(ownerId);
Task task = new Task();
task.setTitle(req.title());
task.setDescription(req.description());
task.setPriority(req.priority() != null ? req.priority() : TaskPriority.MEDIUM);
task.setDueDate(req.dueDate());
task.setOwner(owner);
return TaskResponse.from(taskRepo.save(task));
}
public TaskResponse update(Long id, UpdateTaskRequest req, Long ownerId) {
Task task = taskRepo.findByIdAndOwnerId(id, ownerId)
.orElseThrow(() -> new EntityNotFoundException("Task not found"));
// ⚠️ PATCH semantics: field == null หมายถึง "ไม่ส่งมา ไม่แก้"
// ผลข้างเคียง: ไม่สามารถ "ตั้งเป็น null" ได้ (เช่น clear dueDate)
// ถ้าต้องการ clear field → ใช้ Optional<X> wrappers หรือ JSON Merge Patch (RFC 7396)
if (req.title() != null) task.setTitle(req.title());
if (req.description() != null) task.setDescription(req.description());
if (req.status() != null) task.setStatus(req.status());
if (req.priority() != null) task.setPriority(req.priority());
if (req.dueDate() != null) task.setDueDate(req.dueDate());
return TaskResponse.from(task); // ไม่ต้องเรียก save() — dirty checking ของ JPA จะ save ให้เองตอน transaction จบ
}
public void delete(Long id, Long ownerId) {
// ทางเลือก: ถ้า authz คือ ownership-only อย่างเดียว ใช้ taskRepo.deleteByIdAndOwnerId(id, ownerId) จะเร็วกว่า (1 SQL แทน 2)
// ที่นี่ใช้ load-then-delete เพื่อให้ส่ง 404 ที่ชัด ถ้า task ไม่มี/ไม่ใช่ของ user
Task task = taskRepo.findByIdAndOwnerId(id, ownerId)
.orElseThrow(() -> new EntityNotFoundException("Task not found"));
taskRepo.delete(task);
}
public int bulkDelete(List<Long> ids, Long ownerId) {
return taskRepo.deleteByIdsAndOwner(ids, ownerId);
}
}🔍
getReferenceByIdvsfindById— ต่างกันยังไง?
findById(id)getReferenceById(id)ยิง SQL ตอนเรียก? ใช่ ( SELECT * FROM users WHERE id=?)ไม่ คืน lazy proxy เฉย ๆ ถ้า id ไม่มีจริง คืน Optional.empty()ทันทีexception โผล่ภายหลัง (ตอน access field หรือตอน flush) เหมาะกับ ต้องอ่าน/แสดงข้อมูลของ owner ใช้แค่เซ็ต FK เท่านั้น (เร็วกว่า 1 SQL) ความเสี่ยง ไม่มี LazyInitializationException ถ้า access field นอก transaction หรือ id ไม่มีจริง rule of thumb: ถ้าต้องการ "อ้าง" entity เพื่อเซ็ต FK และคุณมั่นใจว่า id ต้องมีจริง (เช่น มาจาก JWT ที่ filter ตรวจแล้ว) → ใช้
getReferenceById; ถ้าจะอ่าน field หรือไม่แน่ใจว่ามีจริง → ใช้findById(...).orElseThrow(...)
8. Controller
controller เป็นชั้นบางสุด — แค่ map HTTP request ไป service สังเกตการดึง ownerId จาก @AuthenticationPrincipal (ไม่รับจาก request) แล้วส่งต่อให้ service ทุกครั้ง นี่คือจุดที่ JWT กลายเป็น ownership enforcement และใช้ Pageable รับ pagination/sort จาก query param ให้อัตโนมัติ
💡
@AuthenticationPrincipal CustomUserDetails userมาจากไหน?CustomUserDetailsคือ class ที่เราเขียนเองใน บทที่ 2 §5 (Spring Security UserDetails) implementUserDetailsของ Spring Security เก็บ id + email + roles ของ user ที่ login Spring Security จะใส่ instance นี้ในSecurityContextตอน JWT filter ตรวจ token เสร็จ และ@AuthenticationPrincipalดึงออกมาให้ controller ใช้ตรง ๆ
java
@RestController
@RequestMapping("/api/tasks")
@RequiredArgsConstructor
public class TaskController {
private final TaskService taskService;
@GetMapping
public Page<TaskResponse> list(
@AuthenticationPrincipal CustomUserDetails user,
@RequestParam(required = false) TaskStatus status,
@RequestParam(required = false) TaskPriority priority,
@RequestParam(required = false) String search,
@PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable
) {
// ⚠️ allow-list ฟิลด์ที่ใช้ sort ได้ — กัน user ส่ง ?sort=password,asc แล้ว leak
Pageable safePageable = sanitizeSort(pageable, Set.of("createdAt", "dueDate", "priority", "status"));
return taskService.list(user.getId(), status, priority, search, safePageable);
}
/** ตัด sort key ที่ไม่อยู่ใน allow-list ออก — fallback เป็น createdAt DESC ถ้าหมด */
private Pageable sanitizeSort(Pageable p, Set<String> allowed) {
Sort filtered = Sort.by(p.getSort().stream()
.filter(o -> allowed.contains(o.getProperty()))
.toList());
if (filtered.isUnsorted()) filtered = Sort.by(Sort.Direction.DESC, "createdAt");
return PageRequest.of(p.getPageNumber(), p.getPageSize(), filtered);
}
@GetMapping("/{id}")
public TaskResponse get(
@PathVariable Long id,
@AuthenticationPrincipal CustomUserDetails user
) {
return taskService.getById(id, user.getId());
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public TaskResponse create(
@RequestBody @Valid CreateTaskRequest req,
@AuthenticationPrincipal CustomUserDetails user
) {
return taskService.create(req, user.getId());
}
@PatchMapping("/{id}")
public TaskResponse update(
@PathVariable Long id,
@RequestBody @Valid UpdateTaskRequest req,
@AuthenticationPrincipal CustomUserDetails user
) {
return taskService.update(id, req, user.getId());
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(
@PathVariable Long id,
@AuthenticationPrincipal CustomUserDetails user
) {
taskService.delete(id, user.getId());
}
@PostMapping("/bulk-delete")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void bulkDelete(
@RequestBody @Valid BulkDeleteRequest req,
@AuthenticationPrincipal CustomUserDetails user
) {
taskService.bulkDelete(req.ids(), user.getId());
}
}🔍 Offset/limit pagination — เร็วพอที่ scale ใหญ่ไหม?
Pageableของ Spring แปลงเป็น SQLLIMIT ? OFFSET ?— ใช้งานง่ายแต่เมื่อ offset ใหญ่ (page 1000) DB ต้องอ่านและทิ้ง 10000 แถวก่อน → ช้าและตัวเลขเลื่อน (ถ้ามี insert แทรกตอน paginate) ที่ scale ใหญ่ใช้ keyset (cursor) pagination แทน — แทนที่จะOFFSET 10000ส่ง "cursor" ของแถวสุดท้ายของหน้าก่อนหน้า:sql-- หน้าแรก SELECT * FROM tasks WHERE owner_id = ? ORDER BY created_at DESC, id DESC LIMIT 10; -- หน้าถัดไป (ส่ง cursor = createdAt + id ของแถวสุดท้าย) SELECT * FROM tasks WHERE owner_id = ? AND (created_at, id) < (?, ?) ORDER BY created_at DESC, id DESC LIMIT 10;ใช้ index ได้เต็ม, performance คงที่ทุกหน้า, ผลไม่เลื่อนเวลามี insert ใหม่ Spring Data ≥ 3.1 มี
ScrollPositionรองรับ keyset built-in
Part 2: Frontend
9. Types + API
นิยาม TypeScript type ให้ตรงกับ DTO ของ backend (Task, input, filter, Page) แล้วสร้าง endpoint module เรียก API แบบ type-safe:
ts
// src/api/types.ts
export type TaskStatus = 'TODO' | 'IN_PROGRESS' | 'DONE';
export type TaskPriority = 'LOW' | 'MEDIUM' | 'HIGH';
export interface Task {
id: number;
title: string;
description: string | null;
status: TaskStatus;
priority: TaskPriority;
dueDate: string | null; // ISO date
createdAt: string;
updatedAt: string;
}
export interface CreateTaskInput {
title: string;
description?: string;
priority?: TaskPriority;
dueDate?: string;
}
export interface UpdateTaskInput {
title?: string;
description?: string;
status?: TaskStatus;
priority?: TaskPriority;
dueDate?: string;
}
export interface TaskFilter {
status?: TaskStatus;
priority?: TaskPriority;
search?: string;
page?: number;
size?: number;
sort?: string;
}
export interface Page<T> {
content: T[];
number: number; // หน้าปัจจุบัน (เริ่มนับจาก 0)
size: number;
totalElements: number;
totalPages: number;
}ts
// src/api/tasks.ts
import { http } from './client';
import type { Task, CreateTaskInput, UpdateTaskInput, TaskFilter, Page } from './types';
export const tasksApi = {
list: (filter: TaskFilter = {}) => {
const qs = new URLSearchParams();
if (filter.status) qs.set('status', filter.status);
if (filter.priority) qs.set('priority', filter.priority);
if (filter.search) qs.set('search', filter.search);
if (filter.page !== undefined) qs.set('page', String(filter.page));
if (filter.size !== undefined) qs.set('size', String(filter.size));
if (filter.sort) qs.set('sort', filter.sort);
const query = qs.toString() ? `?${qs}` : '';
return http.get<Page<Task>>(`/tasks${query}`);
},
get: (id: number) => http.get<Task>(`/tasks/${id}`),
create: (data: CreateTaskInput) => http.post<Task>('/tasks', data),
update: (id: number, data: UpdateTaskInput) => http.patch<Task>(`/tasks/${id}`, data),
delete: (id: number) => http.delete<void>(`/tasks/${id}`),
bulkDelete: (ids: number[]) => http.post<void>('/tasks/bulk-delete', { ids }),
};10. Query Hooks
ห่อ endpoint ด้วย TanStack Query hooks — useTasks (list พร้อม filter), useTask (detail), และ mutation hooks ที่ invalidate cache (สั่งให้ข้อมูลที่ cache ไว้ "หมดอายุ" → TanStack Query จะดึงใหม่ให้) ให้อัตโนมัติ สังเกต placeholderData ที่เก็บข้อมูลเก่าไว้ระหว่างโหลดหน้าใหม่ (ไม่กระพริบ) และ query key pattern ที่ทำให้ invalidate ตรงจุด
💡 React Query v5 — สิ่งที่ต้องรู้
queryKeyต้องเป็น array เสมอ (v4 ยอมรับ string) — เราใช้ factory patterntaskKeys.list(filter)ที่คืน array- Optimistic update pattern:
onMutate(snapshot + update cache ทันที) →onError(rollback ด้วย snapshot) →onSettled(invalidate เพื่อ sync กับ server)structuralSharing= default v5 จะเทียบ object ลึก ถ้า field ไม่เปลี่ยน reference เก่าจะถูกใช้ต่อ → React component ที่ memoize จะไม่ re-render โดยไม่จำเป็นplaceholderData: (prev) => prev= แทนkeepPreviousData: trueของ v4
🚀 โซนขั้นสูง — ข้ามได้ เมธอด
useUpdateTask/useDeleteTaskข้างล่างทำ optimistic update (อัปเดต UI ทันทีก่อนเซิร์ฟเวอร์ตอบ ถ้า fail ค่อย rollback ย้อนคืน) ซึ่งมีหลายขั้น (onMutate→ เซฟค่าเก่า + อัปเดต cache,onError→ ย้อนคืน,onSettled→ ดึงใหม่) — รอบแรกถ้ายังไม่ไหว ทำแบบธรรมดา (mutate แล้วinvalidateQueriesเฉย ๆ เหมือนuseCreateTask) ไปก่อนได้ แล้วค่อยกลับมาทำ optimistic ทีหลัง ดูเวอร์ชันง่ายใน §10.1 ก่อนได้
ts
// src/features/tasks/hooks.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { tasksApi } from '@/api/tasks';
import type { Task, TaskFilter, UpdateTaskInput, Page } from '@/api/types';
export const taskKeys = {
all: ['tasks'] as const,
lists: () => [...taskKeys.all, 'list'] as const,
list: (filter: TaskFilter) => [...taskKeys.lists(), filter] as const,
details: () => [...taskKeys.all, 'detail'] as const,
detail: (id: number) => [...taskKeys.details(), id] as const,
};
export function useTasks(filter: TaskFilter = {}) {
return useQuery({
queryKey: taskKeys.list(filter),
queryFn: () => tasksApi.list(filter),
placeholderData: (prev) => prev, // ⭐ คงข้อมูลหน้าเดิมไว้ระหว่างโหลดหน้าใหม่ (ไม่กระพริบ)
});
}
export function useTask(id: number) {
return useQuery({
queryKey: taskKeys.detail(id),
queryFn: () => tasksApi.get(id),
enabled: !!id,
});
}
export function useCreateTask() {
const qc = useQueryClient();
return useMutation({
mutationFn: tasksApi.create,
onSuccess: () => {
qc.invalidateQueries({ queryKey: taskKeys.lists() });
},
});
}
// ⭐ Optimistic Update (อัปเดต UI ทันที แล้วย้อนคืนถ้า fail)
export function useUpdateTask() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: number; data: UpdateTaskInput }) =>
tasksApi.update(id, data),
onMutate: async ({ id, data }) => {
await qc.cancelQueries({ queryKey: taskKeys.lists() });
await qc.cancelQueries({ queryKey: taskKeys.detail(id) });
const previousLists = qc.getQueriesData<Page<Task>>({ queryKey: taskKeys.lists() });
const previousDetail = qc.getQueryData<Task>(taskKeys.detail(id));
// อัปเดต cache ทันที (optimistic) — ฝั่ง list
// ⚠️ caveat: setQueriesData แมตช์ทุก list query — แต่ list query แต่ละตัวมี filter ต่างกัน (เช่น status=TODO vs status=DONE)
// ถ้า user toggle จาก TODO → DONE: cache ของ "list status=TODO" ยังโชว์ task นั้นอยู่ (phantom) จนกว่า onSettled invalidate
// ทางแก้: filter ตรงนี้ตาม queryKey ที่ 3rd element (filter object) แล้วเอาออกถ้าไม่แมตช์
qc.setQueriesData<Page<Task>>({ queryKey: taskKeys.lists() }, (old, query) => {
if (!old) return old;
// อ่าน filter จาก queryKey: ['tasks', 'list', { status, priority, ... }]
const filter = (query.queryKey[2] ?? {}) as { status?: string; priority?: string };
return {
...old,
content: old.content
.map(t => t.id === id ? { ...t, ...data } : t)
// ตัดออกถ้า task หลังอัปเดตไม่ match filter ของ list นี้แล้ว
.filter(t => {
if (t.id !== id) return true;
if (filter.status && t.status !== filter.status) return false;
if (filter.priority && t.priority !== filter.priority) return false;
return true;
}),
};
});
// อัปเดต cache ทันที (optimistic) — ฝั่ง detail
if (previousDetail) {
qc.setQueryData<Task>(taskKeys.detail(id), { ...previousDetail, ...data });
}
return { previousLists, previousDetail };
},
onError: (err, vars, ctx) => {
ctx?.previousLists?.forEach(([key, data]) => qc.setQueryData(key, data));
if (ctx?.previousDetail) {
qc.setQueryData(taskKeys.detail(vars.id), ctx.previousDetail);
}
},
onSettled: (_, __, vars) => {
qc.invalidateQueries({ queryKey: taskKeys.lists() });
qc.invalidateQueries({ queryKey: taskKeys.detail(vars.id) });
},
});
}
export function useDeleteTask() {
const qc = useQueryClient();
return useMutation({
mutationFn: tasksApi.delete,
onMutate: async (id) => {
await qc.cancelQueries({ queryKey: taskKeys.lists() });
const previousLists = qc.getQueriesData<Page<Task>>({ queryKey: taskKeys.lists() });
qc.setQueriesData<Page<Task>>({ queryKey: taskKeys.lists() }, (old) => {
if (!old) return old;
return {
...old,
content: old.content.filter(t => t.id !== id),
totalElements: old.totalElements - 1,
};
});
return { previousLists };
},
onError: (err, id, ctx) => {
ctx?.previousLists?.forEach(([key, data]) => qc.setQueryData(key, data));
},
onSettled: () => {
qc.invalidateQueries({ queryKey: taskKeys.lists() });
},
});
}
export function useBulkDeleteTasks() {
const qc = useQueryClient();
return useMutation({
mutationFn: tasksApi.bulkDelete,
onSuccess: () => {
qc.invalidateQueries({ queryKey: taskKeys.lists() });
},
});
}📝 edge case ที่ optimistic update อาจหลอกตา เราเอา
dataใหม่ merge ทับ...tตรง ๆ — แต่ถ้า client ส่งdueDate: undefinedแล้ว server resolve เป็นnull(หรือกลับกัน) ค่าที่ user เห็นจะต่างจาก server ชั่วครู่ ไม่เป็นไรเพราะonSettledจะinvalidateQueriesดึงค่าจริงจาก server มาทับให้ — แต่ระหว่างนั้น UI จะแสดง optimistic value (อาจไม่ตรงกับ server เป๊ะ)
10.1 Simple version (no optimistic)
ถ้ายังไม่พร้อม optimistic update — ใช้ pattern นี้ก่อน (เหมือน useCreateTask คือ invalidate cache หลัง mutate สำเร็จ):
ts
export function useUpdateTaskSimple() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: number; data: UpdateTaskInput }) =>
tasksApi.update(id, data),
onSuccess: (_, vars) => {
qc.invalidateQueries({ queryKey: taskKeys.lists() });
qc.invalidateQueries({ queryKey: taskKeys.detail(vars.id) });
},
});
}ข้อต่าง: UI จะ "รอ" 200-500ms ระหว่าง server ตอบ → ดูช้ากว่า optimistic version (ที่เปลี่ยน UI ทันที 0ms) แต่โค้ดเรียบง่ายและไม่มี edge case รื้อ rollback
11. TaskList Page
ประกอบเป็นหน้า list จริง — รวม filter, search ที่ debounce, pagination และ bulk action เข้าด้วยกัน สังเกตว่า component แค่ประสาน hook กับ UI ไม่มี data logic เพราะยกไป hook layer หมดแล้ว
💡 ศัพท์ในหัวข้อนี้:
- debounce = หน่วงเวลาก่อนยิง API — รอให้ผู้ใช้หยุดพิมพ์สักครู่ค่อยยิงครั้งเดียว แทนที่จะยิงทุกตัวอักษร
- bulk action = ทำหลายรายการพร้อมกัน เช่น ลบทีละหลายอัน
Set<number>ใน React state = ใช้ JavaScriptSet(โครงสร้างเก็บค่าไม่ซ้ำ) เก็บ id ของ row ที่ผู้ใช้ติ๊ก ❗ React ต้องการ state ใหม่ทุกครั้งที่อัปเดต —Setไม่ใช่ immutable เลยต้องสร้างnew Set(prev)ทุกครั้งที่ add/delete (ดูในtoggleSelect)
📌 โค้ดข้างล่าง
import { useDebounce } from '@/hooks/useDebounce'— hook ตัวนี้เราเขียนเอง(ไม่ใช่ของ library) หน้าตาแบบนี้ ให้สร้างไฟล์src/hooks/useDebounce.ts:ts// src/hooks/useDebounce.ts import { useState, useEffect } from 'react'; // คืนค่า value แบบหน่วงเวลา — จะอัปเดตหลังหยุดเปลี่ยนค่าครบ delay มิลลิวินาที export function useDebounce<T>(value: T, delay: number): T { const [debounced, setDebounced] = useState(value); useEffect(() => { const timer = setTimeout(() => setDebounced(value), delay); // ตั้งเวลา return () => clearTimeout(timer); // ถ้า value เปลี่ยนก่อนครบเวลา → ยกเลิกตัวเก่า }, [value, delay]); return debounced; }
tsx
// src/pages/TaskList.tsx
import { useState } from 'react';
import { useTasks, useDeleteTask, useUpdateTask, useBulkDeleteTasks } from '@/features/tasks/hooks';
import type { TaskFilter, TaskStatus, TaskPriority } from '@/api/types';
import { TaskItem } from './components/TaskItem';
import { TaskCreateModal } from './components/TaskCreateModal';
import { useDebounce } from '@/hooks/useDebounce';
export function TaskList() {
const [filter, setFilter] = useState<TaskFilter>({ page: 0, size: 10 });
const [search, setSearch] = useState('');
const debouncedSearch = useDebounce(search, 300);
const [selected, setSelected] = useState<Set<number>>(new Set());
const [createOpen, setCreateOpen] = useState(false);
const { data, isLoading, isFetching } = useTasks({ ...filter, search: debouncedSearch });
const deleteTask = useDeleteTask();
const updateTask = useUpdateTask();
const bulkDelete = useBulkDeleteTasks();
const toggleSelect = (id: number) => {
setSelected(prev => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
};
const handleBulkDelete = () => {
if (selected.size === 0) return;
// 📝 ใช้ native confirm() เพื่อความสั้น/MVP — production ควรใช้ modal component (เช่นใช้ Radix Dialog / shadcn AlertDialog) ที่:
// - ไม่ block UI thread
// - styling ตามธีมแอป
// - keyboard a11y ดีกว่า
if (!confirm(`Delete ${selected.size} tasks?`)) return;
bulkDelete.mutate([...selected], {
onSuccess: () => setSelected(new Set())
});
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">My Tasks</h1>
<button
onClick={() => setCreateOpen(true)}
className="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded"
>
+ New Task
</button>
</div>
{/* Filters */}
<div className="flex gap-2 items-center">
<input
placeholder="Search..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="border rounded px-3 py-1"
/>
<select
value={filter.status ?? ''}
onChange={(e) => {
const v = e.target.value;
setFilter(f => ({ ...f, status: v === '' ? undefined : (v as TaskStatus) }));
}}
className="border rounded px-3 py-1"
>
<option value="">All status</option>
<option value="TODO">Todo</option>
<option value="IN_PROGRESS">In Progress</option>
<option value="DONE">Done</option>
</select>
<select
value={filter.priority ?? ''}
onChange={(e) => {
const v = e.target.value;
setFilter(f => ({ ...f, priority: v === '' ? undefined : (v as TaskPriority) }));
}}
className="border rounded px-3 py-1"
>
<option value="">All priority</option>
<option value="LOW">Low</option>
<option value="MEDIUM">Medium</option>
<option value="HIGH">High</option>
</select>
{isFetching && <span className="text-sm text-gray-500">Updating...</span>}
</div>
{/* Bulk actions */}
{selected.size > 0 && (
<div className="flex items-center gap-2 bg-blue-50 border border-blue-200 rounded p-2">
<span>{selected.size} selected</span>
<button
onClick={handleBulkDelete}
disabled={bulkDelete.isPending}
className="text-red-600 hover:underline ml-auto"
>
Delete selected
</button>
<button onClick={() => setSelected(new Set())} className="text-gray-600">
Clear
</button>
</div>
)}
{/* List */}
{isLoading ? (
<div className="space-y-2">
{[...Array(5)].map((_, i) => (
<div key={i} className="h-16 bg-gray-200 animate-pulse rounded" />
))}
</div>
) : !data || data.content.length === 0 ? (
<p className="text-gray-500 text-center py-8">No tasks yet</p>
) : (
<ul className="space-y-2">
{data.content.map(task => (
<TaskItem
key={task.id}
task={task}
selected={selected.has(task.id)}
onSelect={() => toggleSelect(task.id)}
onToggleStatus={() => {
const next = task.status === 'DONE' ? 'TODO' : 'DONE';
updateTask.mutate({ id: task.id, data: { status: next } });
}}
onDelete={() => {
if (confirm('Delete this task?')) deleteTask.mutate(task.id);
}}
/>
))}
</ul>
)}
{/* Pagination — แสดงเมื่อมีอย่างน้อย 2 หน้า; กัน off-by-one ตอน totalPages = 0 (empty) */}
{data && data.totalPages > 1 && (
<div className="flex items-center justify-between">
<button
disabled={(filter.page ?? 0) === 0}
onClick={() => setFilter(f => ({ ...f, page: Math.max(0, (f.page ?? 0) - 1) }))}
className="px-3 py-1 border rounded disabled:opacity-50"
>
Previous
</button>
<span>Page {data.number + 1} of {data.totalPages}</span>
<button
disabled={data.number >= data.totalPages - 1}
onClick={() => setFilter(f => ({ ...f, page: (f.page ?? 0) + 1 }))}
className="px-3 py-1 border rounded disabled:opacity-50"
>
Next
</button>
</div>
)}
<TaskCreateModal open={createOpen} onClose={() => setCreateOpen(false)} />
</div>
);
}12. TaskItem (Optimistic Toggle)
TaskItem คือ component แสดง task แต่ละรายการพร้อมปุ่ม toggle status — จุดเด่นคือ optimistic update: กดปุ่มแล้ว UI เปลี่ยนทันที (ไม่รอ server) ทำให้รู้สึก responsive ถ้า API fail ค่อย rollback
📝 เรื่อง emoji
☑/☐/🗑ตัวอย่างนี้ใช้ emoji เพื่อความสั้น render ได้บนเบราว์เซอร์โมเดิร์น (ใช้ system font / emoji font) — แต่บนระบบเก่าหรือ font ที่ไม่ครอบคลุมอาจขึ้นเป็น□(tofu) แนะนำ production:
- ใช้ icon library เช่น
lucide-react(<Check />,<Trash2 />) หรือreact-icons- หรือ inline SVG (control style/size/color ผ่าน CSS ได้)
- หรือใส่
font-family: ..., "Segoe UI Emoji", "Apple Color Emoji";เป็น fallback
tsx
// src/pages/components/TaskItem.tsx
import type { Task } from '@/api/types';
import { Link } from 'react-router-dom';
interface Props {
task: Task;
selected: boolean;
onSelect: () => void;
onToggleStatus: () => void;
onDelete: () => void;
}
const priorityColors = {
LOW: 'bg-gray-100 text-gray-700',
MEDIUM: 'bg-yellow-100 text-yellow-700',
HIGH: 'bg-red-100 text-red-700',
};
const statusColors = {
TODO: 'bg-gray-100 text-gray-700',
IN_PROGRESS: 'bg-blue-100 text-blue-700',
DONE: 'bg-green-100 text-green-700',
};
export function TaskItem({ task, selected, onSelect, onToggleStatus, onDelete }: Props) {
return (
<li className={`flex items-center gap-3 p-3 border rounded bg-white ${selected ? 'border-blue-400 bg-blue-50' : ''}`}>
<input
type="checkbox"
checked={selected}
onChange={onSelect}
/>
<button onClick={onToggleStatus} className="text-2xl">
{task.status === 'DONE' ? '☑' : '☐'}
</button>
<div className="flex-1 min-w-0">
<Link to={`/tasks/${task.id}`} className="block">
<h3 className={`font-medium truncate ${task.status === 'DONE' ? 'line-through text-gray-400' : ''}`}>
{task.title}
</h3>
{task.description && (
<p className="text-sm text-gray-500 truncate">{task.description}</p>
)}
</Link>
</div>
<span className={`text-xs px-2 py-0.5 rounded ${priorityColors[task.priority]}`}>
{task.priority}
</span>
<span className={`text-xs px-2 py-0.5 rounded ${statusColors[task.status]}`}>
{task.status}
</span>
{task.dueDate && (
<span className="text-xs text-gray-500">
{new Date(task.dueDate).toLocaleDateString()}
</span>
)}
<button onClick={onDelete} className="text-red-500 hover:text-red-700">
🗑
</button>
</li>
);
}13. TaskCreateModal (RHF + Zod)
ปิดท้าย CRUD ด้วย modal (หน้าต่างเด้งซ้อนบนหน้าเดิม) สร้าง task — ใช้ React Hook Form (RHF, ไลบรารีจัดการฟอร์ม) + Zod (ไลบรารี validate) แบบ type-safe เหมือน form ใน บทที่ 2 §8 เมื่อ submit สำเร็จก็ปิด modal + invalidate list ให้ task ใหม่โผล่ทันที:
tsx
// src/pages/components/TaskCreateModal.tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useCreateTask } from '@/features/tasks/hooks';
import { useEffect } from 'react';
const schema = z.object({
title: z.string().min(1, 'Title is required').max(255),
description: z.string().max(5000).optional(),
priority: z.enum(['LOW', 'MEDIUM', 'HIGH']),
dueDate: z.string().optional(),
});
type FormData = z.infer<typeof schema>;
interface Props {
open: boolean;
onClose: () => void;
}
export function TaskCreateModal({ open, onClose }: Props) {
const form = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: { priority: 'MEDIUM' },
});
const createTask = useCreateTask();
useEffect(() => {
if (!open) form.reset();
}, [open]);
if (!open) return null;
const onSubmit = form.handleSubmit(async (data) => {
await createTask.mutateAsync(data);
onClose();
});
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={onClose}>
<div className="bg-white rounded-lg p-6 w-96 max-w-full" onClick={(e) => e.stopPropagation()}>
<h2 className="text-xl font-bold mb-4">New Task</h2>
<form onSubmit={onSubmit} className="space-y-3">
<div>
<label className="block text-sm mb-1">Title *</label>
<input {...form.register('title')} className="w-full border rounded px-2 py-1" autoFocus />
{form.formState.errors.title && (
<p className="text-red-600 text-xs mt-1">{form.formState.errors.title.message}</p>
)}
</div>
<div>
<label className="block text-sm mb-1">Description</label>
<textarea {...form.register('description')} rows={3} className="w-full border rounded px-2 py-1" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm mb-1">Priority</label>
<select {...form.register('priority')} className="w-full border rounded px-2 py-1">
<option value="LOW">Low</option>
<option value="MEDIUM">Medium</option>
<option value="HIGH">High</option>
</select>
</div>
<div>
<label className="block text-sm mb-1">Due Date</label>
<input type="date" {...form.register('dueDate')} className="w-full border rounded px-2 py-1" />
</div>
</div>
<div className="flex gap-2 justify-end pt-2">
<button type="button" onClick={onClose} className="px-3 py-1 border rounded">Cancel</button>
<button
type="submit"
disabled={createTask.isPending}
className="px-3 py-1 bg-blue-500 text-white rounded disabled:opacity-50"
>
{createTask.isPending ? 'Creating...' : 'Create'}
</button>
</div>
</form>
</div>
</div>
);
}Part 3: ทดสอบ Optimistic Behavior
14. ทำไม Optimistic UI สำคัญ
User กด toggle status → เห็น UI เปลี่ยนทันที (0ms)
ไม่ใช่รอ 200-500ms server respond แล้วค่อยเปลี่ยน
15. ทดสอบ Rollback
Disable network → กด toggle → จะเห็น:
- UI เปลี่ยนทันที (optimistic)
- หลัง timeout → error toast
- UI rollback กลับ
16. ⚠️ Common Pitfalls
| Pitfall | แก้ |
|---|---|
| Optimistic update ที่ list filter | setQueriesData ทุก list query |
Forget await qc.cancelQueries | onMutate ต้อง cancel pending fetch |
| Mutate ตรง object ใน cache | ใช้ { ...prev, ...changes } (immutable) |
| ลืม rollback ใน onError | save snapshot ใน onMutate, restore ใน onError |
| Pagination ลืม invalidate page เก่า | invalidate taskKeys.lists() ทั้งหมด |
placeholderData undefined ตอน filter เปลี่ยน | ใช้ placeholderData: (prev) => prev |
17. Checkpoint
🛠️ Checkpoint 3.1 — Build Task App
ทำตัวอย่างในบทนี้ — ทดสอบ:
- Create + see in list
- Toggle status — เห็นเปลี่ยนทันที (no flash)
- Delete — disappear ทันที + rollback ถ้า disable network
- Filter + search — debounce + keep previous data while loading
- Pagination — next/prev page
🛠️ Checkpoint 3.2 — Bulk Operations
- Select multiple
- Bulk delete
- Bulk mark as DONE
🛠️ Checkpoint 3.3 — Optimistic Add
ทำให้ "Create task" เป็น optimistic เหมือนกัน:
- Submit → ใส่ task ใหม่ในลิสต์ทันที (id = temp)
- หลัง server respond → replace ด้วย task จริง (มี real id)
- Fail → remove แล้ว toast error
18. Glossary — คำศัพท์ทั้งบท (อ้างอิงเร็ว)
| คำ | คำอ่าน | ความหมาย |
|---|---|---|
| entity | เอน-ทิ-ตี้ | คลาส Java ที่ map กับตาราง DB |
| enum | อี-นัม | enumeration ชุดค่าที่กำหนดล่วงหน้า |
| DTO | ดี-ที-โอ | Data Transfer Object — object สำหรับรับ-ส่งข้อมูลผ่าน API |
| over-posting | โอ-เวอร์-โพส-ติ้ง | user ยัด field เกินที่อนุญาตเข้า request |
| Specification | สเปก-ซิ-ฟิ-เค-ชั่น | pattern Spring Data สร้าง where-clause แบบ dynamic |
| Criteria API | คริ-ที-เรีย-เอ-พี-ไอ | API ของ JPA สำหรับสร้าง query ด้วยโค้ด (root, query, cb) |
| dirty checking | เดอร์-ตี้ เช็ค-คิ้ง | JPA ตรวจ entity ว่าถูกแก้ใน transaction → auto-save ตอน commit |
| transaction | ทรานแซก-ชั่น | กลุ่ม SQL ที่ commit/rollback เป็นชุดเดียว |
| BOLA | โบ-ล่า | Broken Object Level Authorization — ช่องโหว่ที่เปลี่ยน id แล้วเข้าถึงของคนอื่น |
| N+1 problem | เอ็น-บวก-วัน | ดึง parent 1 query + child loop N query แก้ด้วย @EntityGraph / JOIN FETCH |
| LazyInitializationException | เลซี่-อิน-นิ-เชี่ยล-ไล-เซ-ชั่น | error ตอน access lazy field นอก transaction |
| optimistic update | ออป-ติ-มิส-ติก | UI เปลี่ยนก่อน server ตอบ rollback ถ้า fail |
| debounce | ดี-เบาน์ซ์ | หน่วงเวลาก่อนยิง event |
| bulk action | บัลค์ แอค-ชั่น | ทำหลายรายการพร้อมกัน |
| keyset pagination | คีย์-เซ็ต | pagination ที่ใช้ cursor (ของแถวสุดท้าย) แทน offset |
19. สรุปบท
✅ Backend: Entity + Repository + Specification (dynamic filter) + Service + Controller
✅ ใช้ @AuthenticationPrincipal ดึง user ปัจจุบัน → query ที่ scoped
✅ Frontend: API layer + TanStack Query v5 hooks + page component
✅ Optimistic UI: onMutate → snapshot + update cache + onError → rollback + onSettled → invalidate
✅ Pagination + filter + search ที่ทำงานข้าม stack
✅ Debounce search input ลด API call
✅ placeholderData: (prev) => prev keep UI ระหว่าง loading next page
✅ Security: LIKE wildcard escape, sort field allow-list, @Transactional rollback rule
✅ Performance: N+1 (EntityGraph), offset vs keyset pagination