ในบทความนี้ เราจะสร้างแอปพลิเคชัน TODO แบบเต็มรูปแบบโดยใช้ Remix ร่วมกับ Serverless Redis (Upstash) เพื่อสาธิตความสามารถของ Full Stack Framework ที่ทันสมัย
Remix เป็นเฟรมเวิร์ก Full Stack ที่เน้น User Interface และ Web Fundamentals ช่วยให้สร้างประสบการณ์ผู้ใช้ที่รวดเร็ว ลื่นไหล และทนทานได้
1. สร้างโปรเจกต์ Remix
npx create-remix@latest
เลือกการตั้งค่าตามต้องการ (Deployment target: Vercel/Netlify/Cloudflare Pages, TypeScript, ESLint) จากนั้น:
npm install
npm run dev
2. สร้าง User Interface
Remix ใช้ Loader สำหรับดึงข้อมูล และ Action สำหรับจัดการ Form Submission - ทำงานคล้ายเว็บแบบดั้งเดิมแต่ทันสมัยกว่า
ไฟล์ app/routes/index.tsx (Route หลัก)
import type { ActionFunction, LoaderFunction } from "remix";
import { Form, useLoaderData, useTransition, redirect } from "remix";
import { useEffect, useRef } from "react";
import type { Todo } from "~/components/todo-item";
import TodoItem from "~/components/todo-item";
export const loader: LoaderFunction = async () => {
// ตัวอย่างข้อมูล - จะเปลี่ยนเป็นดึงจาก Redis ในขั้นตอนต่อไป
return [
{ id: "1", text: "Task 1", status: false },
{ id: "2", text: "Task 2", status: true },
];
};
export const action: ActionFunction = async ({ request }) => {
// จัดการ Create, Update, Delete - จะ implement ต่อไป
};
export default function Index() {
const transition = useTransition();
const todos: Todo[] = useLoaderData();
const isCreating = transition.submission?.method === "POST";
const isAdding = transition.state === "submitting" && isCreating;
const uncheckedTodos = todos.filter((todo) => !todo.status);
const checkedTodos = todos.filter((todo) => todo.status);
const formRef = useRef<HTMLFormElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isAdding) return;
formRef.current?.reset();
inputRef.current?.focus();
}, [isAdding]);
return (
<main className="container">
{/* ฟอร์มเพิ่ม TODO */}
<Form ref={formRef} method="post">
<input
ref={inputRef}
type="text"
name="text"
autoComplete="off"
className="input"
placeholder="What needs to be done?"
disabled={isCreating}
/>
</Form>
{/* งานที่ยังไม่เสร็จ */}
<div className="todos">
{uncheckedTodos.map((todo) => (
<TodoItem key={todo.id} {...todo} />
))}
</div>
{/* งานที่เสร็จแล้ว */}
{checkedTodos.length > 0 && (
<div className="todos todos-done">
{checkedTodos.map((todo) => (
<TodoItem key={todo.id} {...todo} />
))}
</div>
)}
</main>
);
}
คอมโพเนนต์ TODO Item (app/components/todo-item.tsx)
import { Form } from "remix";
export type Todo = { id: string; text: string; status: boolean };
export default function TodoItem({ id, text, status }: Todo) {
return (
<div className="todo">
<Form method="put">
<input
type="hidden"
name="todo"
defaultValue={JSON.stringify({ id, text, status })}
/>
<button type="submit" className="checkbox">
{status && "✓"}
</button>
</Form>
<span className="text">{text}</span>
<Form method="delete">
<input type="hidden" name="id" value={id} />
<button type="submit" className="delete">✕</button>
</Form>
</div>
);
}
เพิ่ม CSS (app/styles/app.css)
:root {
--rounded: 0.25rem;
--rounded-md: 0.375rem;
--gray-50: rgb(249, 250, 251);
--gray-100: rgb(243, 244, 246);
--gray-200: rgb(229, 231, 235);
--gray-300: rgb(209, 213, 219);
--gray-400: rgb(156, 163, 175);
--gray-500: rgb(107, 114, 128);
--gray-600: rgb(75, 85, 99);
--gray-700: rgb(55, 65, 81);
--gray-800: rgb(31, 41, 55);
--gray-900: rgb(17, 24, 39);
}
*, ::before, ::after {
box-sizing: border-box;
border: 0;
padding: 0;
}
button, input, optgroup, select, textarea {
font-family: inherit;
font-size: 100%;
line-height: inherit;
color: inherit;
margin: 0;
padding: 0;
}
button { cursor: pointer; background-color: white; }
html {
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color: var(--gray-800);
}
.container {
padding: 8rem 1rem 0;
margin: 0 auto;
max-width: 28rem;
}
.input {
width: 100%;
padding: 0.75rem 1rem;
background-color: var(--gray-100);
border-radius: var(--rounded-md);
}
.input::placeholder { color: var(--gray-400); }
.input:disabled { color: var(--gray-600); background-color: var(--gray-200); }
.todos { margin-top: 1.5rem; }
.todos.todos-done {
background-color: var(--gray-100);
color: var(--gray-500);
border-radius: var(--rounded-md);
}
.todo {
display: flex;
align-items: center;
padding: 0.75rem;
border-radius: var(--rounded-md);
}
.todo + .todo { border-top: 1px solid var(--gray-100); }
.todo .checkbox {
display: flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
border-radius: var(--rounded);
border: 1px solid var(--gray-300);
box-shadow: 0 1px 1px 0 rgb(0 0 0 / 10%);
}
.todo .text { margin-left: 0.75rem; }
Import CSS ใน app/root.tsx
import { Links, LiveReload, Meta, Outlet, Scripts, ScrollRestoration } from "remix";
import type { MetaFunction } from "remix";
import styles from "./styles/app.css";
export function links() {
return [{ rel: "stylesheet", href: styles }];
}
export const meta: MetaFunction = () => ({
title: "Remix Todo App with Redis",
});
export default function App() {
return (
<html lang="en">
<head>
<Meta />
<Links />
</head>
<body>
<Outlet />
<ScrollRestoration />
<Scripts />
<LiveReload />
</body>
</html>
);
}
3. เชื่อมต่อ Upstash Redis
npm install @upstash/redis
สร้างฐานข้อมูลที่ Upstash Console และคัดลอก Credentials มาใส่ใน .env:
UPSTASH_REDIS_REST_URL=https://your-db.upstash.io
UPSTASH_REDIS_REST_TOKEN=your-token
อัปเดต app/routes/index.tsx ให้ใช้ Redis จริง
// เพิ่ม import
import { Redis } from "@upstash/redis";
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});
const DATABASE_KEY = "remix-todo-example";
// Loader: ดึงข้อมูล TODO ทั้งหมด
export const loader: LoaderFunction = async () => {
const res = await redis.hgetall<Record<string, object>>(DATABASE_KEY);
const todos = Object.entries(res ?? {}).map(([key, value]) => ({
id: key,
...value,
}));
return todos.sort((a, b) => parseInt(b.id) - parseInt(a.id));
};
// Action: จัดการ Create, Update, Delete
export const action: ActionFunction = async ({ request }) => {
const form = await request.formData();
// CREATE
if (request.method === "POST") {
const text = form.get("text");
if (!text) return redirect("/");
await redis.hset(DATABASE_KEY, {
[Date.now().toString()]: { text, status: false },
});
}
// UPDATE (toggle status)
if (request.method === "PUT") {
const todo = form.get("todo");
const { id, text, status } = JSON.parse(todo as string);
await redis.hset(DATABASE_KEY, {
[id]: { text, status: !status },
});
}
// DELETE
if (request.method === "DELETE") {
const id = form.get("id");
await redis.hdel(DATABASE_KEY, id as string);
}
return redirect("/");
};
4. สรุป
แอป TODO ด้วย Remix + Upstash Redis เสร็จสมบูรณ์แล้ว! จุดเด่นของ Remix คือ:
- Loader/Action Pattern - แยกการอ่าน/เขียนข้อมูลชัดเจน
- Web Standards - ใช้ Form และ HTTP Method แบบ Native
- Progressive Enhancement - ทำงานได้แม้ JavaScript จะปิด
- Edge Ready - Deploy ไป Cloudflare Workers/Vercel Edge ได้เลย