tutorialsEdit this page

Using server actions

Our goal in this tutorial 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!

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 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:

./tailwind.config.js
/** @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:

./src/index.css
@tailwind base; @tailwind components; @tailwind utilities;

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:

./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:

./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.

Our Todo app needs a layout, so let’s create src/Layout.tsx:

./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.

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:

./src/index.tsx
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.

To render our items, let’s create an Item component in src/Item.tsx:

./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:

./src/Item.tsx
<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:

./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.

To implement the AddTodo component, create an src/AddTodo.tsx file with the following content:

./src/AddTodo.tsx
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.

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 .

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. We don’t used any client components here, that will be in another tutorial.