9

I'm curious how we'd be able to go about accessing returned values from Next.js 13's alpha release of server actions. Here's the documentation from the Next team for reference.

Let's say I have the following example server action to perform input validation:

async function validation(formData: FormData) { // needs "formData" or else errors
    'use server';
    
    // Get the data from the submitted form
    const data = formData?.get("str");
    
    // Sanitize the data and put it into a "str" const
    const str = JSON.stringify(data)
        .replace(/['"]+/g, '')
    
    // Self explanatory
    if (!str) {
        return { error: "String is Required" }
    } 

    else if (!(/^[a-zA-Z0-9]{8}$/).test(str)) {
        return { error : "Invalid String Format" }
    }

    // Using next/navigation, redirect to another dynamic page
    // if the data is validated
    
    await redirect(`/scan-code=${str}`)
    return null;
    }

and that action is hooked to a simple form:

<form action={validation}>
    <label>
        {error && error}
    </label>
    <input 
        placeholder="Enter String Here"
        required 
        name="str"
    />
    <button type="submit">
        Submit
    </button>
</form>

In Remix, this would be a pretty straightforward case of using an action and the useFetcher() hook, and the action would essentially look the same, except in the component itself, you'd use something like

const fetcher = useFetcher()

and then

const error = fetcher.data?.error

and handle errors in the UI by having something like

<label style={{ color: error ? 'red' : 'black' }}>
{error && error}
</label>

However, instead of being able to retrieve the error values and render them based on whether or not they exist, I'm essentially just either returning an error or redirecting to the proper page, so there's no feedback for errors in the UI.

There are also type errors when trying to use the actions in the first place, and promises from the async functions don't match up with the expected type of the action prop on the element which is apparently a string.

(TS2322: Type '(formData: FormData) => Promise<{ error: string; } | null>' is not assignable to type 'string')

I'm curious what I'm doing wrong here, and what the alternatives to Remix's useFetcher() and useActionData() hooks would be for Next's server actions, as I feel like persisting errors in context, local storage, or a DB would be an unnecessary step.

Any and all help would be appreciated. Thank you!

ricarn1
  • 91
  • 3
  • I'm searching for the same answer. There's not much documentation on how to handle/display errors with server actions. Ideally the page would re-render, and like you said, I can show errors if they exist. – ravinggenius Jun 16 '23 at 02:09

2 Answers2

1

Based on the very terse responses on this GitHub discussion, you can only do this with a client-rendered form. In my case I have a server-rendered page. The page defines an inline server action, then passes that action to a client-rendered form. The client-rendered form wraps the server action with another async function, then passes the new wrapper function to the form['action'] prop.

// app/whatever/new/client-rendered-form.tsx

"use client";

import { useState } from "react";

export default ClientRenderedForm({
  serverAction
}: {
  serverAction: (data: FormData) => Promise<unknown>
}) {
  const [error, setError] = useState<unknown>();

  return (
    <form
      action={async (data: FormData) => {
        try {
          await serverAction(data);
        } catch (e: unknown) {
          console.error(e);

          setError(e);
        }
      }}
    >
      <label>
        <span>Name</span>
        <input name="name" />
      </label>

      <button type="submit">pull the lever!</button>
    </form>
  );
}
// app/whatever/new/page.tsx

import { redirect } from "next/navigation";

import ClientRenderedForm from "./client-rendered-form";

export default WhateverNewPage() {
  const serverAction = async (data: FormData) => {
    "use server";

    // i don't know why the 'use server' directive is required,
    // but it is. this file is already executed on the server
    //
    // this function runs on the server. you may process the data
    // however you'd like
    //
    // if this function throws, the client wrapper will catch
    //
    // when you are finished, you likely need to execute a
    // `redirect()`. i don't know if the `return` keyword
    // is required

    return redirect("/somewhere/else");
  };

  return (
    <div className="fancy-layout">
      <ClientRenderedForm {...{ serverAction }} />
    </div>
  );
}

I hope this helps! It's still early days for this new paradigm, so the documentation hasn't been filled out yet.

ravinggenius
  • 816
  • 1
  • 6
  • 14
  • 1
    I found another [discussion](https://github.com/vercel/next.js/discussions/49401) that seems to provide (essentially) the same answer I did. – ravinggenius Jun 16 '23 at 03:57
0

I have also faced this issue when trying to build a login form. When the user submits the data, I need to inform them about the server response if the "email" or "password" do not match.

I spent some time tackling this problem, and here is the solution.

this solution also works when the javascript is disabled in the browser.

You can create a class that manipulates the server response like this:

class AtomicState {
  constructor(public message: string | undefined = undefined) {}

  setMessage(message: string) {
    this.message = message;
  }

  getMessage() {
    const message = this.message;
    this.message = undefined;
    return message;
  }
}

This class is responsible for setting and getting response messages. The message property is initially set to undefined. The setMessage method sets the message property, and the getMessage method saves the current value of the message property in the message variable, sets it to undefined again, and returns the value of the message variable.

You can use this class as follows:

const state = new AtomicState();

and your server action should be looks like that:

async function validation(formData: FormData) { // needs "formData" or else errors
    'use server';
    
    // Get the data from the submitted form
    const data = formData?.get("str");
    
    // Sanitize the data and put it into a "str" const
    const str = JSON.stringify(data)
        .replace(/['"]+/g, '')
    
    // Self explanatory
    if (!str) {
        state.setMessage("String is Required")
        revalidatePath("CURRENT PATH");
    } 

    else if (!(/^[a-zA-Z0-9]{8}$/).test(str)) {
        state.setMessage("Invalid String Format")
        revalidatePath("CURRENT PATH");
    } else {
        // Using next/navigation, redirect to another dynamic page
        // if the data is validated
    
        await redirect(`/scan-code=${str}`)
    }
}

Using the revalidatePath function, NextJS will reload the page again with the new data.

According to the Official Page of NextJS, revalidatePath allows you to revalidate data associated with a specific path. This is useful for scenarios where you want to update your cached data without waiting for a revalidation period to expire.

You can access the data from the state and render it in the UI like this:

<form action={validation}>
    <label>
        {state.getMessage()}
    </label>
    <input 
        placeholder="Enter String Here"
        required 
        name="str"
    />
    <button type="submit">
        Submit
    </button>
</form>

Here is the whole file:

import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";

class AtomicState {
  constructor(public message: string | undefined = undefined) {}

  setMessage(message: string) {
    this.message = message;
  }

  getMessage() {
    const message = this.message;
    this.message = undefined;
    return message;
  }
}

const state = new AtomicState();

async function validation(formData: FormData) { // needs "formData" or else errors
    'use server';
    
    // Get the data from the submitted form
    const data = formData?.get("str");
    
    // Sanitize the data and put it into a "str" const
    const str = JSON.stringify(data)
        .replace(/['"]+/g, '')
    
    // Self explanatory
    if (!str) {
        state.setMessage("String is Required")
        revalidatePath("CURRENT PATH");
    } 

    else if (!(/^[a-zA-Z0-9]{8}$/).test(str)) {
        state.setMessage("Invalid String Format")
        revalidatePath("CURRENT PATH");
    } else {
        // Using next/navigation, redirect to another dynamic page
        // if the data is validated
    
        await redirect(`/scan-code=${str}`)
    }
}

export default function Page() {
  return (
    <form action={validation}>
      <label>{state.getMessage()}</label>
      <input placeholder="Enter String Here" required name="str" />
      <button type="submit">Submit</button>
    </form>
  );
}
  • 1
    Instead of defining a class with setters and getters, you could just have an 'error' string variable, set it from the server action when there's an error and just display it from jsx – Agu Dondo Aug 19 '23 at 14:47
  • Doesn't this share the state between requests, or even users? – Gokhan Sari Aug 31 '23 at 02:02