Todo app with no client-side JavaScript using @lazarv/react-server
Viktor Lázár
Posted on May 27, 2024
Everyone starts with a simple Todo app when evaluating a new framework. So let’s do it this time using @lazarv/react-server, a new minimalist React meta-framework using Vite!
Our goal with this example is to use no client-side JavaScript and no React hydration. We only want to use React Server Components and Server Actions. Is this possible? Absolutely!
Project setup
Let’s create a new project, by creating a new folder, initializing pnpm and installing all the required dependencies.
mkdir todo
cd todo
pnpm init
pnpm config set auto-install-peers true --location project
pnpm add @lazarv/react-server better-sqlite3 zod
pnpm add -D @types/better-sqlite3 @types/react @types/react-dom autoprefixer postcss tailwindcss typescript
pnpx tailwindcss init -p
To store our Todo items, we will use a local Sqlite database. For validation, we will use Zod and for styling we will use Tailwind CSS. To include all our source code as Tailwind content, change the tailwind.config.js to this:
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./src/**/*.tsx"],
theme: {
extend: {},
},
plugins: [],
};
As we will not do any exciting Tailwind styling, put the usual 3-liner Tailwind setup into src/index.css:
@tailwind base;
@tailwind components;
@tailwind utilities;
Hello World!
Well it’s nothing better than a good old “Hello World!” app, so let’s create an entrypoint for our Todo app! Put the following code into src/index.tsx:
export default function Index() {
return (
<h1>Hello World!</h1>
);
}
To run this micro-app, you can just use pnpm exec react-server ./src/index.tsx
. To make our life easier, let’s add some npm scripts to package.json:
"scripts": {
"dev": "react-server ./src/index.tsx",
"build": "react-server build ./src/index.tsx",
"start": "react-server start"
},
After doing this, use pnpm dev
to start the development server. After our development server is running, open http://localhost:3000 and be proud of our Hello World! app. You can also use pnpm dev --open
to do so.
Layout
Our Todo app needs a layout, so let’s create src/Layout.tsx:
export default function Layout({ children }: React.PropsWithChildren) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Todo</title>
</head>
<body>
<div className="p-4">
<h1 className="text-4xl font-bold mb-4">
<a href="/">Todo</a>
</h1>
{children}
</div>
</body>
</html>
);
}
Nothing fancy here, just a usual HTML document template. We will use this Layout component as a wrapper for our app.
Page
Our main page will be the Todo app, where we will use all of the building blocks to create our app. Let’s say goodbye to Hello World! and change the component to this:
import "./index.css";
import { allTodos } from "./actions";
import AddTodo from "./AddTodo";
import Item from "./Item";
import Layout from "./Layout";
export default function Index() {
const todos = allTodos();
return (
<Layout>
<AddTodo />
{todos.length === 0 && <p className="text-gray-500">No todos yet!</p>}
{todos.map((todo) => (
<Item key={todo.id} title={todo.title} id={todo.id} />
))}
</Layout>
);
}
In this React Server Component, we collect all the stored Todo items by calling allTodos()
and use the result to render JSX. We use the Layout component to wrap our content into a HTML document.
Item
To render our items, let’s create an Item component in src/Item.tsx:
import { deleteTodo } from "./actions";
type Props = {
id: number;
title: string;
};
export default function Item({ id, title }: Props) {
return (
<div className="flex row items-center justify-between py-1 px-4 my-1 rounded-lg text-lg border bg-gray-100 text-gray-600 mb-2">
<p className="flex-1">{title}</p>
<form action={deleteTodo}>
<input type="hidden" name="id" value={id} />
<button className="font-medium">Delete</button>
</form>
</div>
);
}
The Item component will render our Todo item using an id
and title
prop. But what about that <form action={deleteTodo}>
? It’s a server action! When the user will submit the form by clicking on the “Delete” button, the browser will call our server action. This is possible without any JavaScript on the frontend, as React supports progressive enhancement for server actions and the initial form action will call the server action by including a hidden input field in the form:
<input type="hidden" name="$ACTION_ID_/Users/lazarv/Projects/tutorials/todo/src/actions.ts#deleteTodo">
The framework will resolve this $ACTION_ID_
prefixed path to the server action and it will call our server action function!
Server actions
We will use server actions to implement all functionality of our Todo app. This is the most complex part of the app, but don’t shy away, it’s still really very simple, let’s create src/actions.ts:
"use server";
import { redirect } from "@lazarv/react-server";
import Database from "better-sqlite3";
import * as zod from "zod";
const db = new Database("db.sqlite");
db.exec(
"CREATE TABLE IF NOT EXISTS todos (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT)"
);
type Todo = {
id: number;
title: string;
};
const addTodoSchema = zod.object({
title: zod
.string()
.min(3, "Title must be at least 3 characters")
.max(100, "Title must be at most 100 characters")
.refine((value) => value.length > 0, "Title is required")
.transform((value) => value.trim()),
});
const deleteTodoSchema = zod.object({
id: zod.string().transform((value) => parseInt(value.trim(), 10)),
});
export async function addTodo(formData: FormData) {
const result = addTodoSchema.safeParse(Object.fromEntries(formData));
if (!result.success) {
throw result.error.issues;
}
const { title } = result.data;
db.prepare("INSERT INTO todos(title) VALUES (?)").run(title);
redirect("/");
}
export function allTodos() {
return db.prepare("SELECT * FROM todos").all() as Todo[];
}
export async function deleteTodo(formData: FormData) {
const result = deleteTodoSchema.safeParse(Object.fromEntries(formData));
if (!result.success) {
throw result.error.issues;
}
const { id } = result.data;
db.prepare("DELETE FROM todos WHERE id = ?").run(id);
redirect("/");
}
In the first line of this file, we instrument the framework to treat this file as a server action module using the “use server”;
directive. All exported async functions will be available for us to use as server actions.
We initialize the Sqlite database on module import and create Zod schemas for item add and delete operations.
In all server actions, you will receive a FormData
instance, including all the fields we define in the forms. We safeParse
these after converting to JavaScript objects using Object.fromEntries
.
If Zod validation fails, we throw the validation issues as an error.
On success, we run a database command to INSERT or DELETE the Todo item.
At the end, we are using redirect
to navigate the user back from the server action call. This is needed as we don’t want the user to use a browser page refresh to create or delete the Todo item again, reusing the form submit.
We also implemented the allTodos
function here, to have all the storage related code in a single file.
Add new item
To implement the AddTodo component, create an src/AddTodo.tsx file with the following content:
import { useActionState } from "@lazarv/react-server/router";
import type { ZodIssue } from "zod";
import { addTodo } from "./actions";
export default function AddTodo() {
const { formData, error } = useActionState<
typeof addTodo,
string & Error & ZodIssue[]
>(addTodo);
return (
<form action={addTodo} className="mb-4">
<div className="mb-2">
<input
name="title"
type="text"
className="bg-gray-50 border border-gray-300 text-gray-900 rounded-lg p-2.5"
defaultValue={formData?.get?.("title") as string}
autoFocus
/>
</div>
<button
className="text-white bg-blue-700 hover:bg-blue-800 rounded-lg px-5 py-2 mb-2 text-center"
type="submit"
>
Submit
</button>
{error?.map?.(({ message }, i) => (
<p
key={i}
className="bg-red-50 border rounded-lg border-red-500 text-red-500 p-2.5 mb-2"
>
{message}
</p>
)) ??
(error && (
<p className="bg-red-50 border rounded-lg border-red-500 text-red-500 p-2.5">
{error}
</p>
))}
</form>
);
}
We already know, how to use server actions from a <form>
. But what about the result of our server action call? useActionState
to the rescue!
By passing the addTodo
server action function reference to useActionState
, we can get the result of the server action call when this specific server action was called, so we can collect the error
result. This will be the Zod error issues we thrown in the add server action. So we can iterate on all Zod validation issues here and render validation error messages on the server side.
Production build
During using the development server, you can notice that the page loaded some JavaScript modules in the browser. This is only used for Hot Module Replacement. In a production build, only the document and a CSS asset will be loaded in the browser.
To build for production, run pnpm build
and then you can start the production server using pnpm start
.
Final words
That’s it! We have a working small Todo example. You can also find the example on GitHub. By using the above approach, it will be easy for you to add a Todo item update feature, marking an item as completed. I hope you had fun and you will like the developer experience the @lazarv/react-server framework provides! There are a lot of other exciting features provided by React and the framework itself! We don’t used any client components here, that will be in another tutorial. The documentation site of @lazarv/react-server
was created using the framework and it’s build and deployment to Vercel takes about 5s! Because the framework uses Vite, the developer experience is blazing-fast!
Posted on May 27, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.