one.ilmsg.in.th

TodoList ด้วย React, TypeScript, Firestore และ DaisyUI

สวัสดีครับ นี่คือบทเรียนสอนสร้าง TodoList Application ด้วย React, TypeScript, Firestore และ DaisyUI อย่างละเอียด:

สิ่งที่เราจะสร้าง:

แอปพลิเคชัน TodoList แบบง่ายๆ ที่สามารถ:

  • เพิ่มรายการสิ่งที่ต้องทำ (Todo)
  • แสดงรายการ Todo ทั้งหมด
  • ทำเครื่องหมายว่า Todo เสร็จสิ้นแล้ว
  • ลบ Todo ออกจากรายการ
  • ข้อมูลจะถูกจัดเก็บและซิงค์แบบเรียลไทม์กับ Firestore (ฐานข้อมูล NoSQL ของ Firebase)
  • หน้าตาแอปพลิเคชันจะสวยงามด้วย DaisyUI (ส่วนเสริมของ Tailwind CSS)

เทคโนโลยีที่ใช้:

  • React: ไลบรารี JavaScript สำหรับสร้าง User Interface
  • TypeScript: ส่วนขยายของ JavaScript ที่เพิ่มการตรวจสอบ Type ทำให้โค้ดน่าเชื่อถือมากขึ้น
  • Vite: เครื่องมือ Build สมัยใหม่ที่รวดเร็วสำหรับการพัฒนาเว็บ
  • Firebase (Firestore): แพลตฟอร์มสำหรับสร้างแอปพลิเคชัน มีบริการฐานข้อมูลแบบเรียลไทม์ (Firestore)
  • Tailwind CSS: Utility-first CSS framework สำหรับสร้างดีไซน์ได้อย่างรวดเร็ว
  • DaisyUI: Plugin สำหรับ Tailwind CSS ที่มี Components สำเร็จรูปให้ใช้งาน

ขั้นตอนที่ 1: ตั้งค่าโปรเจกต์

  1. สร้างโปรเจกต์ React ด้วย Vite และ TypeScript: เปิด Terminal หรือ Command Prompt แล้วรันคำสั่ง:

    npm create vite@latest my-todo-app --template react-ts
    

    (แทนที่ my-todo-app ด้วยชื่อโปรเจกต์ที่คุณต้องการ) จากนั้นทำตามคำแนะนำบนหน้าจอ (เลือก React -> TypeScript) แล้วเข้าไปในโฟลเดอร์โปรเจกต์:

    cd my-todo-app
    
  2. ติดตั้ง Dependencies:

    npm install firebase react-icons tailwindcss postcss autoprefixer daisyui
    npm install -D @tailwindcss/vite # สำหรับ Vite 5+
    
    • firebase: สำหรับเชื่อมต่อกับ Firebase และ Firestore
    • react-icons: สำหรับไอคอนสวยๆ (เช่น ไอคอนถังขยะ)
    • tailwindcss, postcss, autoprefixer: สำหรับ Tailwind CSS
    • daisyui: สำหรับ Components สำเร็จรูป
    • @tailwindcss/vite: Plugin สำหรับใช้งาน Tailwind CSS กับ Vite

ขั้นตอนที่ 2: กำหนดค่า Tailwind CSS และ DaisyUI

  1. สร้างไฟล์ Config ของ Tailwind:

    npx tailwindcss init -p
    

    คำสั่งนี้จะสร้างไฟล์ tailwind.config.js และ postcss.config.js.

  2. ตั้งค่า tailwind.config.js: เปิดไฟล์ tailwind.config.js และแก้ไขดังนี้:

    /** @type {import('tailwindcss').Config} */
    export default {
      content: [
        "./index.html",
        "./src/**/*.{js,ts,jsx,tsx}", // บอกให้ Tailwind รู้ว่าไฟล์ไหนใช้ class บ้าง
      ],
      theme: {
        extend: {},
      },
      plugins: [
        require('daisyui'), // เพิ่ม DaisyUI plugin
      ],
      // (Optional) กำหนดธีม DaisyUI ที่ต้องการ
      daisyui: {
        themes: ["light", "dark", "cupcake"], // เลือกธีมที่ต้องการ
      },
    }
    
  3. ตั้งค่า @tailwindcss/vite (สำหรับ Vite 5+): เปิดไฟล์ vite.config.ts และแก้ไขดังนี้:

    import { defineConfig } from 'vite'
    import react from '@vitejs/plugin-react'
    import tailwindcss from '@tailwindcss/vite' // Import plugin
    
    // https://vitejs.dev/config/
    export default defineConfig({
      plugins: [
        react(),
        tailwindcss(), // เพิ่ม plugin ที่นี่
      ],
    })
    

    (หากใช้ Vite เวอร์ชันเก่ากว่า อาจใช้วิธี import ใน CSS แทน)

  4. เพิ่ม Tailwind Directives ใน CSS: เปิดไฟล์ src/index.css ลบเนื้อหาเดิมทิ้งทั้งหมด แล้วเพิ่ม:

    @import "tailwindcss"; /* สำหรับ Vite 5+ */
    @plugin "daisyui";   /* สำหรับ Vite 5+ */
    
    /* หรือใช้ @tailwind directives หากใช้ Vite เวอร์ชันเก่า */
    /*
    @tailwind base;
    @tailwind components;
    @tailwind utilities;
    */
    
    /* (Optional) เพิ่ม CSS ที่ต้องการเอง */
    body {
      font-family: sans-serif;
    }
    
  5. ทดสอบ Tailwind & DaisyUI: เปิดไฟล์ src/App.tsx ลองเพิ่ม class ของ Tailwind และ DaisyUI เช่น:

    function App() {
      return (
        <div className="p-4">
          <h1 className="text-3xl font-bold text-primary mb-4">My Todo App</h1>
          <button className="btn btn-primary">Click Me</button>
        </div>
      )
    }
    export default App
    

    รัน development server:

    npm run dev
    

    เปิดเบราว์เซอร์ไปที่ http://localhost:5173 (หรือ port ที่ Vite แสดง) คุณควรเห็นปุ่มสีหลัก (primary) ของ DaisyUI


ขั้นตอนที่ 3: ตั้งค่า Firebase และ Firestore

  1. สร้าง Firebase Project:

    • ไปที่ Firebase Console
    • คลิก "Add project" หรือ "สร้างโปรเจกต์"
    • ทำตามขั้นตอนเพื่อสร้างโปรเจกต์ใหม่ (ตั้งชื่อ, เลือกภูมิภาค, etc.)
    • ไม่ต้องเปิดใช้งาน Google Analytics ก็ได้สำหรับโปรเจกต์นี้
  2. สร้าง Firestore Database:

    • ในหน้า Firebase project ของคุณ ไปที่เมนู Build > Firestore Database
    • คลิก "Create database" หรือ "สร้างฐานข้อมูล"
    • เลือกโหมด Test mode (โหมดทดสอบ) เพื่อให้ง่ายต่อการพัฒนา (สำคัญ: สำหรับ Production ต้องตั้งค่า Security Rules ให้รัดกุม!) โหมดทดสอบจะอนุญาตให้ทุกคนอ่านและเขียนข้อมูลได้ชั่วคราว
    • เลือกตำแหน่งที่ตั้งของ Cloud Firestore (เลือกที่ใกล้คุณที่สุด)
    • คลิก "Enable" หรือ "เปิดใช้งาน"
  3. รับ Firebase Configuration:

    • กลับไปที่หน้า Project Overview (ภาพรวมโปรเจกต์)
    • คลิกที่ไอคอนรูปเฟือง (⚙️) ข้าง "Project Overview" แล้วเลือก "Project settings" (การตั้งค่าโปรเจกต์)
    • เลื่อนลงมาที่ส่วน "Your apps" (แอปของคุณ)
    • คลิกที่ไอคอน Web (</>) เพื่อลงทะเบียนแอปเว็บใหม่
    • ตั้งชื่อเล่นให้แอป (เช่น "My Todo App Web") แล้วคลิก "Register app" (ลงทะเบียนแอป)
    • Firebase จะแสดง configuration object ให้คุณ คัดลอก object นี้ไว้ (ส่วน firebaseConfig)
  4. สร้างไฟล์ Firebase Config ในโปรเจกต์ React:

    • ในโฟลเดอร์ src สร้างไฟล์ใหม่ชื่อ firebase.ts
    • วางโค้ด firebaseConfig ที่คัดลอกมา และเพิ่มโค้ดสำหรับ initialize Firebase และ Firestore:
    // src/firebase.ts
    import { initializeApp } from "firebase/app";
    import { getFirestore } from "firebase/firestore";
    
    // Your web app's Firebase configuration
    // ใช้ Environment Variables เพื่อความปลอดภัยในแอปจริง
    const firebaseConfig = {
      apiKey: "YOUR_API_KEY", // แทนที่ด้วยค่าของคุณ
      authDomain: "YOUR_AUTH_DOMAIN",
      projectId: "YOUR_PROJECT_ID",
      storageBucket: "YOUR_STORAGE_BUCKET",
      messagingSenderId: "YOUR_MESSAGING_SENDER_ID",
      appId: "YOUR_APP_ID"
    };
    
    // Initialize Firebase
    const app = initializeApp(firebaseConfig);
    
    // Initialize Cloud Firestore and get a reference to the service
    const db = getFirestore(app);
    
    export { db }; // Export Firestore instance
    

    สำคัญ: ควรเก็บค่า Config เหล่านี้ไว้ใน Environment Variables (.env ไฟล์) เพื่อความปลอดภัย ไม่ควร hardcode ลงในโค้ดโดยตรง โดยเฉพาะ API Key. สำหรับ Vite ให้สร้างไฟล์ .env ที่ root ของโปรเจกต์ แล้วใส่ค่าดังนี้:

    VITE_FIREBASE_API_KEY=YOUR_API_KEY
    VITE_FIREBASE_AUTH_DOMAIN=YOUR_AUTH_DOMAIN
    VITE_FIREBASE_PROJECT_ID=YOUR_PROJECT_ID
    # ... อื่นๆ
    

    แล้วใน firebase.ts ให้เรียกใช้ผ่าน import.meta.env:

    const firebaseConfig = {
      apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
      authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
      projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
      // ... อื่นๆ
    };
    

ขั้นตอนที่ 4: กำหนดโครงสร้างข้อมูล (TypeScript Interface)

สร้างไฟล์ src/types.ts เพื่อกำหนด interface สำหรับ Todo item:

// src/types.ts
import { Timestamp } from 'firebase/firestore';

export interface Todo {
  id: string;         // ID ของเอกสารใน Firestore
  text: string;       // ข้อความของ Todo
  completed: boolean; // สถานะเสร็จสิ้น
  createdAt: Timestamp; // เวลาที่สร้าง (ใช้ Timestamp ของ Firestore)
}

ขั้นตอนที่ 5: สร้าง Components

เราจะสร้าง Components หลักๆ ดังนี้:

  1. AddTodoForm.tsx: ฟอร์มสำหรับเพิ่ม Todo ใหม่
  2. TodoList.tsx: แสดงรายการ Todo ทั้งหมด
  3. TodoItem.tsx: แสดง Todo แต่ละรายการ

สร้างโฟลเดอร์ src/components และสร้างไฟล์ต่อไปนี้:

src/components/AddTodoForm.tsx

import React, { useState } from 'react';

interface AddTodoFormProps {
  onAddTodo: (text: string) => void; // Function ที่รับมาจาก App.tsx
}

const AddTodoForm: React.FC<AddTodoFormProps> = ({ onAddTodo }) => {
  const [inputText, setInputText] = useState('');

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (inputText.trim() === '') return; // ไม่เพิ่มถ้า input ว่าง
    onAddTodo(inputText);
    setInputText(''); // ล้าง input หลังเพิ่ม
  };

  return (
    <form onSubmit={handleSubmit} className="flex gap-2 mb-4">
      <input
        type="text"
        placeholder="เพิ่มรายการใหม่..."
        className="input input-bordered flex-grow" // ใช้ class ของ DaisyUI
        value={inputText}
        onChange={(e) => setInputText(e.target.value)}
      />
      <button type="submit" className="btn btn-primary">
        เพิ่ม
      </button>
    </form>
  );
};

export default AddTodoForm;

src/components/TodoItem.tsx

import React from 'react';
import { Todo } from '../types'; // Import interface
import { FaTrash } from 'react-icons/fa'; // Import icon ถังขยะ

interface TodoItemProps {
  todo: Todo;
  onToggleComplete: (id: string, completed: boolean) => void;
  onDeleteTodo: (id: string) => void;
}

const TodoItem: React.FC<TodoItemProps> = ({ todo, onToggleComplete, onDeleteTodo }) => {
  return (
    <div className={`flex items-center justify-between p-3 mb-2 rounded-lg shadow ${todo.completed ? 'bg-base-200 opacity-60' : 'bg-base-100'}`}>
      <div className="flex items-center gap-3">
        <input
          type="checkbox"
          checked={todo.completed}
          onChange={() => onToggleComplete(todo.id, !todo.completed)}
          className="checkbox checkbox-primary" // ใช้ class ของ DaisyUI
        />
        <span className={`text-lg ${todo.completed ? 'line-through' : ''}`}>
          {todo.text}
        </span>
      </div>
      <button
        onClick={() => onDeleteTodo(todo.id)}
        className="btn btn-ghost btn-sm text-error hover:bg-error hover:text-base-100" // ใช้ class ของ DaisyUI
      >
        <FaTrash /> {/* แสดงไอคอน */}
      </button>
    </div>
  );
};

export default TodoItem;

src/components/TodoList.tsx

import React from 'react';
import { Todo } from '../types';
import TodoItem from './TodoItem';

interface TodoListProps {
  todos: Todo[];
  onToggleComplete: (id: string, completed: boolean) => void;
  onDeleteTodo: (id: string) => void;
}

const TodoList: React.FC<TodoListProps> = ({ todos, onToggleComplete, onDeleteTodo }) => {
  if (todos.length === 0) {
    return <p className="text-center text-gray-500">ยังไม่มีรายการ...</p>;
  }

  return (
    <div>
      {todos.map((todo) => (
        <TodoItem
          key={todo.id}
          todo={todo}
          onToggleComplete={onToggleComplete}
          onDeleteTodo={onDeleteTodo}
        />
      ))}
    </div>
  );
};

export default TodoList;

ขั้นตอนที่ 6: เชื่อมต่อกับ Firestore และจัดการ State ใน App.tsx

ตอนนี้เราจะรวม Components ทั้งหมดเข้าด้วยกันใน App.tsx และเขียน Logic สำหรับการเพิ่ม, อ่าน, อัปเดต, และลบข้อมูลใน Firestore

เปิดไฟล์ src/App.tsx และแก้ไขดังนี้:

import React, { useState, useEffect } from 'react';
import { db } from './firebase'; // Import Firestore instance
import {
  collection,
  addDoc,
  onSnapshot, // สำหรับ real-time updates
  query,
  orderBy,
  serverTimestamp, // สำหรับ timestamp ของ server
  doc,
  updateDoc,
  deleteDoc,
  Timestamp, // Import Timestamp type
} from 'firebase/firestore';
import { Todo } from './types'; // Import interface

// Import Components
import AddTodoForm from './components/AddTodoForm';
import TodoList from './components/TodoList';

function App() {
  const [todos, setTodos] = useState<Todo[]>([]); // State สำหรับเก็บรายการ Todo
  const [loading, setLoading] = useState(true); // State สำหรับ Loading

  // --- Firestore Operations ---

  // 1. Read (Fetch Todos with Real-time Updates)
  useEffect(() => {
    // สร้าง query เพื่อดึงข้อมูลจาก collection 'todos' และเรียงตาม createdAt
    const q = query(collection(db, 'todos'), orderBy('createdAt', 'desc')); // เรียงล่าสุดขึ้นก่อน

    // onSnapshot จะทำงานทุกครั้งที่มีการเปลี่ยนแปลงใน collection
    const unsubscribe = onSnapshot(q, (querySnapshot) => {
      const todosData: Todo[] = [];
      querySnapshot.forEach((doc) => {
        // เพิ่ม id ของ document เข้าไปใน object
        todosData.push({ ...doc.data(), id: doc.id } as Todo);
      });
      setTodos(todosData); // อัปเดต state
      setLoading(false); // หยุด loading
    }, (error) => {
      // จัดการ Error (เช่น แสดงข้อความ)
      console.error("Error fetching todos: ", error);
      setLoading(false);
    });

    // Cleanup function: Unsubscribe เมื่อ component unmount
    return () => unsubscribe();
  }, []); // useEffect นี้จะทำงานครั้งเดียวตอน component mount

  // 2. Create (Add Todo)
  const handleAddTodo = async (text: string) => {
    try {
      await addDoc(collection(db, 'todos'), {
        text: text,
        completed: false,
        createdAt: serverTimestamp(), // ใช้ timestamp จาก server
      });
      // ไม่ต้อง setTodos ที่นี่ เพราะ onSnapshot จะจัดการอัปเดต state ให้เอง
    } catch (error) {
      console.error("Error adding todo: ", error);
      // ควรมี feedback ให้ user ทราบว่าเกิดข้อผิดพลาด
    }
  };

  // 3. Update (Toggle Complete Status)
  const handleToggleComplete = async (id: string, completed: boolean) => {
    try {
      const todoRef = doc(db, 'todos', id); // อ้างอิง document ด้วย id
      await updateDoc(todoRef, {
        completed: completed, // อัปเดต field 'completed'
      });
    } catch (error) {
      console.error("Error updating todo: ", error);
    }
  };

  // 4. Delete (Remove Todo)
  const handleDeleteTodo = async (id: string) => {
    if (window.confirm("ต้องการลบรายการนี้ใช่หรือไม่?")) { // ยืนยันก่อนลบ
        try {
            const todoRef = doc(db, 'todos', id);
            await deleteDoc(todoRef);
        } catch (error) {
            console.error("Error deleting todo: ", error);
        }
    }
  };

  // --- Render UI ---
  return (
    <div className="container mx-auto p-4 max-w-lg">
      <div className="card bg-base-100 shadow-xl">
        <div className="card-body">
          <h1 className="card-title text-3xl justify-center mb-4">
             Todo List (React + TS + Firestore + DaisyUI)
          </h1>

          {/* Form สำหรับเพิ่ม Todo */}
          <AddTodoForm onAddTodo={handleAddTodo} />

          {/* แสดง Loading หรือ รายการ Todo */}
          {loading ? (
             <div className="text-center">
                <span className="loading loading-spinner loading-lg text-primary"></span>
                <p>กำลังโหลดข้อมูล...</p>
             </div>
          ) : (
            <TodoList
              todos={todos}
              onToggleComplete={handleToggleComplete}
              onDeleteTodo={handleDeleteTodo}
            />
          )}
        </div>
      </div>
      {/* (Optional) Footer หรือส่วนอื่นๆ */}
      <footer className="text-center mt-4 text-sm text-gray-500">
         สร้างด้วยความรัก ❤️ โดย [ชื่อของคุณ]
      </footer>
    </div>
  );
}

export default App;

คำอธิบายโค้ด App.tsx:

  1. Imports: นำเข้า function ที่จำเป็นจาก Firebase SDK, interface Todo, และ components ที่สร้างไว้
  2. State:
    • todos: เก็บ array ของ Todo objects ที่ได้จาก Firestore.
    • loading: ใช้แสดงสถานะขณะรอข้อมูลครั้งแรก.
  3. useEffect (Read/Fetch):
    • ใช้ useEffect เพื่อดึงข้อมูลเมื่อ component โหลดเสร็จ
    • collection(db, 'todos'): อ้างอิงถึง collection ชื่อ 'todos' ใน Firestore
    • query(..., orderBy('createdAt', 'desc')): สร้าง query เพื่อดึงข้อมูลและเรียงลำดับตามฟิลด์ createdAt จากล่าสุดไปเก่าสุด
    • onSnapshot(q, ...): ติดตาม (listen) การเปลี่ยนแปลงใน query นั้นๆ แบบเรียลไทม์ ฟังก์ชัน callback จะทำงานทุกครั้งที่ข้อมูลใน collection 'todos' เปลี่ยนแปลง (เพิ่ม, ลบ, แก้ไข)
    • ภายใน callback: เราวน loop (forEach) ผ่าน querySnapshot.docs เพื่อสร้าง array todosData ใหม่ โดยรวมข้อมูล (doc.data()) และ id ของแต่ละเอกสารเข้าด้วยกัน แล้วอัปเดต state (setTodos)
    • return () => unsubscribe();: ส่วนสำคัญของ useEffect คือ cleanup function ที่จะทำงานเมื่อ component ถูกทำลาย (unmount) เพื่อยกเลิกการติดตาม (unsubscribe) ป้องกัน memory leaks
  4. handleAddTodo (Create):
    • ใช้ addDoc(collection(db, 'todos'), {...}) เพื่อเพิ่มเอกสารใหม่ลงใน collection 'todos'
    • text, completed: false คือข้อมูลเริ่มต้น
    • createdAt: serverTimestamp(): ใช้ timestamp จากฝั่งเซิร์ฟเวอร์ Firebase เพื่อให้เวลาถูกต้องตรงกัน ไม่ว่า client จะตั้งเวลาเครื่องไว้อย่างไร
  5. handleToggleComplete (Update):
    • doc(db, 'todos', id): สร้าง reference ไปยังเอกสารที่ต้องการอัปเดตโดยใช้ id
    • updateDoc(todoRef, { completed: completed }): อัปเดตเฉพาะฟิลด์ completed ของเอกสารนั้น
  6. handleDeleteTodo (Delete):
    • doc(db, 'todos', id): สร้าง reference ไปยังเอกสารที่ต้องการลบ
    • deleteDoc(todoRef): ลบเอกสารนั้นออกจาก Firestore
    • มีการใช้ window.confirm เพื่อถามยืนยันก่อนลบ
  7. Render:
    • แสดง AddTodoForm โดยส่ง handleAddTodo เป็น prop
    • แสดง Loading spinner หาก loading เป็น true
    • แสดง TodoList หาก loading เป็น false โดยส่ง state todos และ functions handleToggleComplete, handleDeleteTodo เป็น props

ขั้นตอนที่ 7: รันและทดสอบ

  1. ตรวจสอบว่า Firebase Emulator หรือ Development Server ของคุณทำงานอยู่:
    npm run dev
    
  2. เปิดแอปในเบราว์เซอร์
  3. ลองเพิ่มรายการ Todo ใหม่, ทำเครื่องหมายว่าเสร็จแล้ว, และลบรายการ
  4. เปิด Firestore console ในเว็บ Firebase เพื่อดูข้อมูลที่ถูกสร้าง, อัปเดต, และลบแบบเรียลไทม์

ข้อควรพิจารณาเพิ่มเติม:

  • Error Handling: เพิ่มการแสดงข้อผิดพลาดให้ผู้ใช้ทราบเมื่อเกิดปัญหาในการติดต่อ Firestore
  • Loading States: อาจเพิ่ม loading indicator แยกสำหรับการเพิ่ม, อัปเดต, หรือลบแต่ละรายการ เพื่อให้ผู้ใช้รู้ว่ากำลังมีการทำงานเบื้องหลัง
  • Security Rules: สำคัญมากสำหรับ Production! ต้องตั้งค่า Firestore Security Rules ให้เหมาะสม เพื่อป้องกันการเข้าถึงข้อมูลโดยไม่ได้รับอนุญาต ควรใช้ Firebase Authentication เพื่อระบุตัวตนผู้ใช้และอนุญาตให้ผู้ใช้เข้าถึงเฉพาะข้อมูลของตนเองเท่านั้น
  • Styling: ปรับแต่งสไตล์เพิ่มเติมด้วย Tailwind/DaisyUI classes ตามต้องการ
  • การจัดการ State ที่ซับซ้อน: สำหรับแอปที่ใหญ่ขึ้น อาจพิจารณาใช้ State Management Library เช่น Zustand, Redux Toolkit, หรือ Context API ของ React เอง

บทเรียนนี้เป็นพื้นฐานในการสร้างแอปพลิเคชัน CRUD ด้วย React, TypeScript, Firestore และ DaisyUI คุณสามารถนำความรู้นี้ไปต่อยอดสร้างฟีเจอร์อื่นๆ เพิ่มเติมได้ครับ! หากมีคำถามเพิ่มเติม ถามได้เลยนะครับ!