สวัสดีครับ นี่คือบทเรียนสอนสร้าง TodoList Application ด้วย React, TypeScript, Firestore และ DaisyUI อย่างละเอียด:
สิ่งที่เราจะสร้าง:
แอปพลิเคชัน TodoList แบบง่ายๆ ที่สามารถ:
เทคโนโลยีที่ใช้:
ขั้นตอนที่ 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
ติดตั้ง Dependencies:
npm install firebase react-icons tailwindcss postcss autoprefixer daisyui
npm install -D @tailwindcss/vite # สำหรับ Vite 5+
firebase: สำหรับเชื่อมต่อกับ Firebase และ Firestorereact-icons: สำหรับไอคอนสวยๆ (เช่น ไอคอนถังขยะ)tailwindcss, postcss, autoprefixer: สำหรับ Tailwind CSSdaisyui: สำหรับ Components สำเร็จรูป@tailwindcss/vite: Plugin สำหรับใช้งาน Tailwind CSS กับ Viteขั้นตอนที่ 2: กำหนดค่า Tailwind CSS และ DaisyUI
สร้างไฟล์ Config ของ Tailwind:
npx tailwindcss init -p
คำสั่งนี้จะสร้างไฟล์ tailwind.config.js และ postcss.config.js.
ตั้งค่า 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"], // เลือกธีมที่ต้องการ
},
}
ตั้งค่า @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 แทน)
เพิ่ม 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;
}
ทดสอบ 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
สร้าง Firebase Project:
สร้าง Firestore Database:
รับ Firebase Configuration:
</>) เพื่อลงทะเบียนแอปเว็บใหม่firebaseConfig)สร้างไฟล์ Firebase Config ในโปรเจกต์ React:
src สร้างไฟล์ใหม่ชื่อ firebase.tsfirebaseConfig ที่คัดลอกมา และเพิ่มโค้ดสำหรับ 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 หลักๆ ดังนี้:
AddTodoForm.tsx: ฟอร์มสำหรับเพิ่ม Todo ใหม่TodoList.tsx: แสดงรายการ Todo ทั้งหมด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:
Todo, และ components ที่สร้างไว้todos: เก็บ array ของ Todo objects ที่ได้จาก Firestore.loading: ใช้แสดงสถานะขณะรอข้อมูลครั้งแรก.useEffect (Read/Fetch):
useEffect เพื่อดึงข้อมูลเมื่อ component โหลดเสร็จcollection(db, 'todos'): อ้างอิงถึง collection ชื่อ 'todos' ใน Firestorequery(..., orderBy('createdAt', 'desc')): สร้าง query เพื่อดึงข้อมูลและเรียงลำดับตามฟิลด์ createdAt จากล่าสุดไปเก่าสุดonSnapshot(q, ...): ติดตาม (listen) การเปลี่ยนแปลงใน query นั้นๆ แบบเรียลไทม์ ฟังก์ชัน callback จะทำงานทุกครั้งที่ข้อมูลใน collection 'todos' เปลี่ยนแปลง (เพิ่ม, ลบ, แก้ไข)forEach) ผ่าน querySnapshot.docs เพื่อสร้าง array todosData ใหม่ โดยรวมข้อมูล (doc.data()) และ id ของแต่ละเอกสารเข้าด้วยกัน แล้วอัปเดต state (setTodos)return () => unsubscribe();: ส่วนสำคัญของ useEffect คือ cleanup function ที่จะทำงานเมื่อ component ถูกทำลาย (unmount) เพื่อยกเลิกการติดตาม (unsubscribe) ป้องกัน memory leakshandleAddTodo (Create):
addDoc(collection(db, 'todos'), {...}) เพื่อเพิ่มเอกสารใหม่ลงใน collection 'todos'text, completed: false คือข้อมูลเริ่มต้นcreatedAt: serverTimestamp(): ใช้ timestamp จากฝั่งเซิร์ฟเวอร์ Firebase เพื่อให้เวลาถูกต้องตรงกัน ไม่ว่า client จะตั้งเวลาเครื่องไว้อย่างไรhandleToggleComplete (Update):
doc(db, 'todos', id): สร้าง reference ไปยังเอกสารที่ต้องการอัปเดตโดยใช้ idupdateDoc(todoRef, { completed: completed }): อัปเดตเฉพาะฟิลด์ completed ของเอกสารนั้นhandleDeleteTodo (Delete):
doc(db, 'todos', id): สร้าง reference ไปยังเอกสารที่ต้องการลบdeleteDoc(todoRef): ลบเอกสารนั้นออกจาก Firestorewindow.confirm เพื่อถามยืนยันก่อนลบAddTodoForm โดยส่ง handleAddTodo เป็น proploading เป็น trueTodoList หาก loading เป็น false โดยส่ง state todos และ functions handleToggleComplete, handleDeleteTodo เป็น propsขั้นตอนที่ 7: รันและทดสอบ
npm run dev
ข้อควรพิจารณาเพิ่มเติม:
บทเรียนนี้เป็นพื้นฐานในการสร้างแอปพลิเคชัน CRUD ด้วย React, TypeScript, Firestore และ DaisyUI คุณสามารถนำความรู้นี้ไปต่อยอดสร้างฟีเจอร์อื่นๆ เพิ่มเติมได้ครับ! หากมีคำถามเพิ่มเติม ถามได้เลยนะครับ!