Todo app with no client-side JavaScript using @lazarv/react-server

lazarv

Viktor Lázár

Posted on May 27, 2024

Todo app with no client-side JavaScript using @lazarv/react-server

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
Enter fullscreen mode Exit fullscreen mode

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: [],
};
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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"
},
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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">
Enter fullscreen mode Exit fullscreen mode

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("/");
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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!

💖 💪 🙅 🚩
lazarv
Viktor Lázár

Posted on May 27, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related