Skip to content

บทที่ 3 — CRUD + Optimistic UI

← บทที่ 2 | สารบัญ | บทที่ 4 →

หลังจาก 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 หรือ JPQL JOIN 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.id
  • ON 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 CASCADE vs RESTRICT: ตัวอย่างนี้ใช้ 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
    CREATE EXTENSION IF NOT EXISTS pg_trgm;
    CREATE INDEX idx_tasks_title_trgm ON tasks USING gin(title gin_trgm_ops);
    หรือใช้ full-text search (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 แปลชื่อ method findByIdAndOwnerId(Long id, Long ownerId) → JPQL → SQL ประมาณนี้:

sql
SELECT 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:

java
cb.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() ในหัวข้อถัดไป):

java
Specification<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() ยาว ๆ:

java
Specification<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 ทำงานเร็วขึ้น
  • @Transactional default 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);
    }
}

🔍 getReferenceById vs findById — ต่างกันยังไง?

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) implement UserDetails ของ 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 แปลงเป็น SQL LIMIT ? 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 pattern taskKeys.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 = ใช้ JavaScript Set (โครงสร้างเก็บค่าไม่ซ้ำ) เก็บ 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 → จะเห็น:

  1. UI เปลี่ยนทันที (optimistic)
  2. หลัง timeout → error toast
  3. UI rollback กลับ

16. ⚠️ Common Pitfalls

Pitfallแก้
Optimistic update ที่ list filtersetQueriesData ทุก list query
Forget await qc.cancelQueriesonMutate ต้อง cancel pending fetch
Mutate ตรง object ใน cacheใช้ { ...prev, ...changes } (immutable)
ลืม rollback ใน onErrorsave 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


← บทที่ 2 | บทที่ 4 → File Upload