Use Toast Notifications with React Server Actions

October 31, 2024

Cover Image for Use Toast Notifications with React Server Actions

Toast notifications for server actions are kind of weird.

You ideally could trigger it directly in the async function, but due to the way the actions API works, it's not immediately obvious how to do it.

TLDR; Show me the code

Here's a gist with all the code

Problem Setup

Suppose I have a login from

// actions.ts
"use server"
export async function signInWithEmail(_: any, formData: FormData) {
// Implementation not important for this
}

// SignInWithEmail.tsx
"use client"
export function SignInWithEmail() {
const [state, submitAction, isPending] = useActionState(
signInWithEmail,
{
errors: null,
},
);

const { errors } = state;

return (
<Form action={submitAction}>
<div className="grid gap-2">
<div className="grid gap-1">
<Label className="sr-only" htmlFor="email">
Email
</Label>
<Input
id="email"
name="email"
placeholder="name@example.com"
type="email"
autoCapitalize="none"
autoComplete="username"
autoCorrect="off"
className="dark:text-white"
required
disabled={isPending}
/>
{errors?.email && (
<p className="px-1 text-xs text-red-600">{errors.email}</p>
)}
</div>
<Button type="submit" disabled={isPending}>
{isPending && <LoadingSpinner className="mr-2 h-4 w-4" />}
Sign In with Email
</Button>
</div>
</Form>
);
}

And suppose I am using sonner, which is a client component toast library.

The way you send toasts normally is like this

import { toast } from "sonner";

function App() {
return (
<button onClick={() => toast('My first toast')}>
Give me a toast
</button>
)
}

Or, more specifically, for a form submission, it would look like this if we were using react-hook-form

export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(userAuthSchema),
});
const [isLoading, setIsLoading] = React.useState<boolean>(false);

async function onSubmit(data: FormData) {
setIsLoading(true);

const signInResult = await signIn("email", {
email: data.email.toLowerCase(),
redirect: false,
});

setIsLoading(false);

if (
typeof signInResult === "undefined" ||
!signInResult.ok ||
signInResult.error
) {
console.error("Sign in error", signInResult);
// HERE
return toast.error("Something went wrong.", {
description: "Your sign in request failed. Please try again.",
});
}

// HERE
return toast.success("Check your email", {
description: "We sent you a login link. Be sure to check your spam too.",
});
}

return (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="grid gap-2">
<div className="grid gap-1">
<Label className="sr-only" htmlFor="email">
Email
</Label>
<Input
id="email"
placeholder="name@example.com"
type="email"
autoCapitalize="none"
autoComplete="username"
autoCorrect="off"
className="dark:text-white"
disabled={isLoading}
{...register("email")}
/>
{errors?.email && (
<p className="px-1 text-xs text-red-600">
{errors.email.message}
</p>
)}
</div>
<Button
type="submit"
disabled={isLoading}
>
{isLoading && <LoadingSpinner className="mr-2 h-4 w-4" />}
Sign In with Email
</Button>
</div>
</form>
);
}

Notice that since the form submission happens on the client, the toast sending is trivial and "inline".

Using server actions makes this a little tricky, but it is how I want to do it for this login form.

The Problem

The problem is that the react useActionState hook converts an async action into a syncronous submit handler.

const [state, submitAction, isPending] = useActionState(
signInWithEmail,
{
errors: null,
},
);

The type of signInWithEmail is

// The important bit is that this is async, it returns a Promise
function signInWithEmail(prev: any, formData: FormData): Promise<{
errors: {
callbackUrl?: string[] | undefined;
email?: string[] | undefined;
};
} | {
errors: null;
}>

However, the type of submitAction is

// Not a promise
const submitAction: (payload: FormData) => void

So you couldn't do something like this, even though it feels intuitive.

// Not allowed
<Form action={async (formData) => {
await submitAction(formData);
toast.success("Yay!")
}}>

The Solution

The useActionState hook takes in any async function, and the toast doesn't actually need to be inside the react component, so just create a new async function wrapper.

And point the useActionState at the new function

// SignInWithEmail.tsx
"use client"

async function submitSignInWithEmail(prev: any, formData: FormData) {
const res = await signInWithEmail(prev, formData);
toast.success("Check your email", {
description: "We sent you a login link. Be sure to check your spam too.",
});
return res;
}

export function SignInWithEmail() {
const [state, submitAction, isPending] = useActionState(
submitSignInWithEmail,
{
errors: null,
},
);
//...
}

And this works as expected now!

Here's a gist with all the code