Build a Guestbook from the 2000s with React Server Components and Server Actions

scastiel

Sebastien Castiel

Posted on July 5, 2023

Build a Guestbook from the 2000s with React Server Components and Server Actions

React is living something these days. Although it was created as a client UI library, it can now be used to generate almost everything from the server. And we get a lot from this change, especially when coupled with Next.js.

In this short tutorial, we’ll use React Server Components and Server Actions to build something from the early '00s: a guestbook!

I bootstrapped a Next.js app with TypeScript and the app router. As I don’t want to care about the CSS, I used Water.css. And to store the messages, I went with Vercel KV (which may not be the right tool for this kind of use case, but is plenty enough for the purpose of this post). The complete source code of the demo project is available on GitHub.

Fetching the messages from a Server Component

Let’s start with displaying the existing messages. With a classic React component, we’d have to declare a local state, perform a fetch request in a useEffect, etc. With Next.js’ server-side rendering, we could declare a getServerSideProps function to fetch the messages first.

With server components, we can fetch the messages from the component itself:

export async function MessageList() {
  const messages = await fetchMessages()

  return (
    <>
      <h2>Last Messages</h2>
      {messages.map(({ id, name, content, date }) => (
        <blockquote key={id}>
          <div>{content}</div>
          <small>{`–${name}, ${formatDate(date)}`}</small>
        </blockquote>
      ))}
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

Looks odd to you? It should! The component is declared async, and fetches data from its body, without any useEffect?

This is what server components let us do. As a server component can be rendered only on the server, we can perform asynchronous operations such as querying a database, making HTTP requests, or even read from the filesystem.

As a consequence, notice how the component is easy to read, even by someone who’s not as familiar with React as you are. We fetch the data, and we display it. That’s it.

What if fetching the messages takes a few seconds? Does it mean that the page will take that much time to load? Fortunately, no. We can take advantage of React’s Suspense to display a nice loading message while the component is still fetching the data.

In the page displaying messages, we can wrap our MessageList component with Suspense, with a fallback message.

export default function MessagesPage() {
  return (
    <>
      {/* ... */}
      <Suspense fallback={<p>Loading messages…</p>}>
        <MessageList />
      </Suspense>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

For a deeper dive in Server Components and Suspense, have a look at my previous post Display a view counter on your blog with React Server Components.

So this is how we can read data from a server component, but is there something similar for writing data so we can post new messages as easily?

Submitting forms with Server Actions

Server components let us fetch data without having to set up a flow with useEffect. We can do something similar to submit data from a form, thanks to Server Actions.

Let’s start with this basic form:

export function MessageForm() {
  return (
    <form>
      <h2>New Message</h2>

      <label htmlFor="name">Your name:</label>
      <input type="text" id="name" name="name" required minLength={3} />

      <label htmlFor="content">Your message:</label>
      <textarea id="content" name="content" required minLength={3} />

      <button type="submit">Send</button>
    </form>
  )
}
Enter fullscreen mode Exit fullscreen mode

As you can see, there is nothing fancy here: two fields, a button, and some validation attributes.

To submit this form, we usually have two options:

  • use the action attribute to use an API endpoint handling the form data,
  • manually make a fetch request to call this API endpoint.

In the first scenario, the page would reload when the user submits the form, which is something we may want to avoid. The second scenario is the one we tend to prefer in React apps, but is a bit more complex to set up.

With server actions, we can get the best of both worlds. We can create a function that will be called on the server when the form is submitted:

async function submitMessageForm(formData: FormData) {
  'use server'

  await postMessage({
    content: formData.get('content'),
    name: formData.get('name'),
  })
}
Enter fullscreen mode Exit fullscreen mode

By adding 'use server' in the function body, we declare it as a server action. We can then associate it with the form by using the action attribute:

return (
  <form action={submitMessageForm}>
Enter fullscreen mode Exit fullscreen mode

Note: to use server actions in a Next.js project, you need to enable them in your next.config.js file:

experimental: { serverActions: true },
Enter fullscreen mode Exit fullscreen mode

What about form data validation? We declared some validation attributes on the form itself, but as always it doesn’t prevent us of validating the data on the server:

// Validation example using a Zod schema
const message = formDataSchema.parse({
  content: formData.get('content'),
  name: formData.get('name'),
})

await postMessage(message)
Enter fullscreen mode Exit fullscreen mode

Finally, we can make another nice improvement to our server action. By adding a call to Next.js’ revalidatePath function, we can tell the app that some resources need to be reloaded. Said differently, we can refresh the messages displayed on the page after we created the new one:

await postMessage(message)
revalidatePath('/') // assuming the messages are displayed at root
Enter fullscreen mode Exit fullscreen mode

Here is what our form looks like now:

We can see that the message is added to the list without needing to refresh the page. But the form is not reset after being submitted, which can be a little annoying.

Additionally, you probably noticed that the form validation is quite minimalist: we use the HTML validation attributes for the client validation, and a Zod schema for the server one. How nice would it be to use the same validation on both sides?

Good news: it is not because we use server actions that we can’t use cool client features to handle forms. Let’s see how.

Improving the form with client features

So far, our form doesn’t use any client feature. Actually, it is still a server component (that can be used with JavaScript disabled). To add some cool features to it, let’s start by making it a client component.

This means:

  • adding the 'use client' directive at the top of the file,
  • moving the server action to a new actions.ts file, as we can’t declare server actions in client files.

To improve our form, I will use the react-hook-form library. Here is how to use it on our form:

export function MessageForm() {
  const { register, handleSubmit } = useForm<MessageFormData>({
    shouldUseNativeValidation: true,
    resolver: zodResolver(formDataSchema),
  })

  return (
    <form onSubmit={handleSubmit((data) => submitMessageForm(data))}>
      <h2>New Message</h2>

      <label htmlFor="name">Your name:</label>
      <input type="text" id="name" {...register('name')} />

      <label htmlFor="content">Your message:</label>
      <textarea id="content" {...register('content')} />

      <button type="submit">Send</button>
    </form>
  )
}
Enter fullscreen mode Exit fullscreen mode

By using register on each field of the form, we let the library handle the form local state and validation. Note that:

  • we use the existing Zod schema to define the validation rules (thanks to the Zod resolver for react-hook-form),
  • by setting shouldUseNativeValidation: true, we rely on the browser API to display the validation errors in the form (a feature I love!).

We need to change a bit the server action to comply with the new way data is sent by react-hook-form (a plain object instead of a FormData object):

export async function submitMessageForm(formData: MessageFormData) {
  formDataSchema.parse(formData)
  await postMessage(formData)
  revalidatePath('/')
}
Enter fullscreen mode Exit fullscreen mode

Important note: we may think that because we call the function directly from the form, we don’t have to care about validating the form data (the parameter). We do need to care about it! Although Next.js handles it for us, there is still an HTTP request made to an API endpoint to call the server action. And it is very easy to make this call manually with invalid data.

This is why we still check that the data is conform with the Zod schema at the top of the function, even if TypeScript lets us think the data is already a MessageFormData. But at least, we can use the same validation logic both on the client and the server.

So now we handle the validation a bit better. We can still even improve the form.

First, let’s reset the form after it is submitted. react-hook-form provides a reset function that we can call right after calling the server action (which returns a promise):

export function MessageForm() {
  const {
    // ...
    reset,
  } = useForm<MessageFormData>(/* ... */)

  return (
    <form
      onSubmit={handleSubmit(async (data) => {
        await submitMessageForm(data)
        reset()
      })}
    >
Enter fullscreen mode Exit fullscreen mode

Final improvement: if the submission takes a couple of seconds, it is a good practice to tell the user the form is being submitted, and prevent them from clicking the submit button again. And again, react-hook-form provides the information we need: an isSubmitting flag:

export function MessageForm() {
  const {
    // ...
    formState: { isSubmitting },
  } = useForm<MessageFormData>(/* ... */)

  return (
    {/* ... */}
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Sending…' : 'Send'}
      </button>
    {/* ... */}
  )
}

Enter fullscreen mode Exit fullscreen mode

Now, thanks to client features, we handle validation the same way on the client and the server, we tell the user the form is being submitted, and reset the form when it is done. Tiny improvements but great for the user experience, as well as the developer experience.

Here is how our final guestbook looks like:

To be fair, these last improvements we made come with a cost: it isn’t possible anymore to use the form without having JavaScript enabled. I’ll let you decide whether it is worth it or not.


So, in the end, what do Server Actions offer us, and how do they compare to Server Components?

  • Server Components let us fetch data without creating an additional endpoint and calling it from the client,
  • Server Actions let us post data without creating an additional endpoint and calling it from the client.

What I love about server component and actions is that they hide some logic, without making anything magic, and make the code way easier to understand. To me, they even make it easier to learn React, a point of view I developed in my post A better learning path for React with server components.

It’s a bit early to say they will change the way we build web apps on the long term, but I would take that bet!

💖 💪 🙅 🚩
scastiel
Sebastien Castiel

Posted on July 5, 2023

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

Sign up to receive the latest update from our blog.

Related