Trong bài viết này, chúng ta sẽ viết một ứng dụng TODO đơn giản sử dụng Remix và Redis Serverless (Upstash).
Remix là một framework web full-stack cho phép bạn tập trung vào giao diện người dùng và làm việc ngược lại qua các nguyên tắc cơ bản của web để cung cấp trải nghiệm người dùng nhanh, mượt mà và bền bỉ.
Tạo dự án Remix
Chạy lệnh sau:
npx create-remix@latest

Dự án đã sẵn sàng. Cài đặt phụ thuộc và chạy:
npm install
npm run dev

Giao diện người dùng
Chúng ta sẽ xây dựng một form đơn giản và danh sách cho các mục todo:
// app/routes/index.tsx
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 () => {
// dữ liệu mẫu
return [
{ id: 1, text: "Task 1", status: false },
{ id: 2, text: "Task 2", status: true },
];
};
export const action: ActionFunction = async ({ request }) => {
// sẽ dùng cho tạo, cập nhật, xóa
};
export default function Index() {
const transition = useTransition();
const todos: Todo[] = useLoaderData();
const isCreating = transition.submission?.method === "POST";
const isAdding = transition.state === "submitting" && isCreating;
// tách task hoàn thành và chưa hoàn thành
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(() => {
// reset form sau khi tạo
if (isAdding) return;
formRef.current?.reset();
inputRef.current?.focus();
}, [isAdding]);
return (
<main className="container">
{/* form tạo mới */}
<Form ref={formRef} method="post">
<input
ref={inputRef}
type="text"
name="text"
autoComplete="off"
className="input"
placeholder="Cần làm gì?"
disabled={isCreating}
/>
</Form>
{/* task chưa hoàn thành */}
<div className="todos">
{uncheckedTodos.map((todo) => (
<TodoItem key={todo.id} {...todo} />
))}
</div>
{/* task đã hoàn thành */}
{checkedTodos.length > 0 && (
<div className="todos todos-done">
{checkedTodos.map((todo) => (
<TodoItem key={todo.id} {...todo} />
))}
</div>
)}
</main>
);
}
Đây là component TODO:
// 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 ẩn lưu dữ liệu todo */}
<input type="hidden" name="todo" defaultValue={JSON.stringify({ id, text, status })} />
{/* Remix forms giống form web truyền thống. Tôi thích điều này. */}
<button type="submit" className="checkbox">
{status && "✓"}
</button>
</Form>
<span className="text">{text}</span>
</div>
);
}
Thêm file 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, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
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 trong 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 = () => {
return { title: "Remix Todo App with Redis" };
}
export default function App() {
// ...
}
Kết quả:

Chuẩn bị cơ sở dữ liệu
Lưu trữ dữ liệu trong Upstash Redis. Tạo cơ sở dữ liệu Upstash. Sử dụng client HTTP. Cài đặt:
npm install @upstash/redis
:::note Upstash tương thích với Redis API, bạn có thể dùng bất kỳ client Redis nào nhưng cần sửa code. :::
Thêm TODO mới bằng cách submit form. Lưu vào Redis Hash.
Sao chép
UPSTASH_REDIS_REST_URLvàUPSTASH_REDIS_REST_TOKENtừ Upstash console.
// app/routes/index.tsx
// ...
import { Redis } from "@upstash/redis";
const redis = new Redis({
url: "UPSTASH_REDIS_REST_URL",
token: "UPSTASH_REDIS_REST_TOKEN",
});
export const action: ActionFunction = async ({ request }) => {
const form = await request.formData();
if (request.method === "POST") {
const text = form.get("text");
if (!text) return redirect("/");
await redis.hset("remix-todo-example", {
[Date.now().toString()]: { text, status: false },
});
}
// fetch lại danh sách sau mỗi thao tác
return redirect("/");
};
Liệt kê các mục:
// app/routes/index.tsx
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,
}));
// sắp xếp theo ngày (id=timestamp)
return todos.sort((a, b) => parseInt(b.id) - parseInt(a.id));
};
Đã có chức năng 'tạo' và 'liệt kê'. Triển khai đánh dấu hoàn thành:
// app/routes/index.tsx
export const action: ActionFunction = async ({ request }) => {
const form = await request.formData();
// tạo
if (request.method === "POST") { /* ... */ }
// cập nhật
if (request.method === "PUT") {
const todo = form.get("todo");
const { id, text, status } = JSON.parse(todo as string);
await redis.hset("remix-todo-example", {
[id]: { text, status: !status },
});
}
return redirect("/");
};
Hoàn tất! Tôi dự định triển khai cùng ứng dụng với Next.js và SvelteKit để so sánh.
Hãy theo dõi chúng tôi trên Twitter và Discord.
Mã nguồn dự án
https://github.com/upstash/redis-examples/tree/master/remix-todo-app-with-redis
Trang Demo dự án
https://remix-todo-app-with-redis.vercel.app/