Zod Type-Safe Toast Notifications in Remix

Photo by Manki Kim on Unsplash

Zod Type-Safe Toast Notifications in Remix

Since the writing of this article I have created a utility library called remix-toast that does what is described in this article, you can find it here:

https://github.com/Code-Forge-Net/remix-toast

As development paradigms on the web are starting to shift from classic SPA's to the server/client model of Remix and Next app directory new challenges arise that weren't here before, one of those is handling toast notifications.

In this article I will go into detail on how you can use the platform to get server-side toast notifications in your Remix applications as well as how you can display them on the client.

I will assume you understand the basics of Remix and you have read through their philosophy section on the documentation website which you can find here:

https://remix.run/docs/en/1.19.3/pages/philosophy

Before we get to the part of actually showing your applications on the front end let's first set up everything you need on the backend to allow you to show these notifications on the client.

Flash session

If you didn't already know there is this concept in session handling called a flash session. What it is is an ethereal storage mechanism that you use to display some information only once and then destroy the session handling this information via headers. You can read a bit more about it in the article below, The article is for PHP but the concept is important:

https://memphis-cs.github.io/rails-tutorial-2019/deets-sessions/

Well if you haven't realized it yet, storage that is only consumed once and deleted is the perfect use case for toast notifications!

So to start create a file in your project, and place it wherever you want, I place it into app/utils but this is up to you called flash-session.server.ts and paste the following into it:

import { createCookieSessionStorage, redirect } from "@remix-run/node";
// Name of the session, can be whatever you want
const FLASH_SESSION = "flash";
// Pretty standard cookie session storage ( you can refer to the following for
// a more detailed explanation:
// https://remix.run/docs/en/1.19.3/utils/sessions#using-sessions
export const flashSessionStorage = createCookieSessionStorage({
  cookie: {
    // name of our session
    name: FLASH_SESSION,
    sameSite: "lax",
    path: "/",
    httpOnly: true,
    secrets: ["sEcR3t"],
    secure: process.env.NODE_ENV === "production"
  }
});

So far we haven't done anything toast-specific, we just create our flash session storage that we will use and consume for our needs, in this example we will use it for toasts but you can extend it for anything.

After this we set up utilities we need to store and fetch the session from the request by pasting in the following code:

/**
 * This helper method is used to retrieve the cookie from the request,
 * parse it, and then gets the session used to store flash information
 */
function getSessionFromRequest(request: Request) {
  // Gets the cookie header from the request
  const cookie = request.headers.get("Cookie"); 
  // Gets our session using the instance we defined above to get the session
  // information
  return flashSessionStorage.getSession(cookie);
}

/**
 * Helper method used to add flash session values to the session
 */
export async function flashMessage(flash: FlashSessionValues, headers?: ResponseInit["headers"]) {
  // Gets the session we defined above
  const session = await flashSessionStorage.getSession();
  // Stores flash information into it that we pass to this function
  session.flash(FLASH_SESSION, flash);
  // Creates a cookie header to store into the response
  const cookie = await flashSessionStorage.commitSession(session);
  // If you've already passed in some custom headers this will create headers
  // and allow us to append additional properties
  const newHeaders = new Headers(headers);
  // Unlike JS objects headers can have multiple properties of the same name
  // So if you already passed in a Set-Cookie it will work fine and it will
  // set both cookies at the same time
  newHeaders.append("Set-Cookie", cookie);
  // Returns the headers to be given to the response object
  return newHeaders;
}

Alright, so we have our basic setup up and running, so let's add our toast-specific stuff on top now! If you've pasted the code above you will notice that the FlashSessionValues type is missing, well, let's add it in the upcoming session!

Adding our toast notifications to flash

Alright so after we have set up the bare essentials let's add our toast notifications! First, install your favorite toast handling library, I will use react-toastify for this tutorial and your favorite validation library, I will use zod.

npm install zod react-toastify

Alright, now let's add our validation schema to validate our toasts in case the data gets tampered with or corrupted:

import { z } from "zod";
import type { TypeOptions } from "react-toastify"
// This is your schema to validate the toast type, you can add additional schemas
// If you want to use flash storage for different things
const toastMessageSchema = z.object({
  // Message to display to the user
  text: z.string(),
  // Type of notification
  type: z.custom<TypeOptions>()
});
// Infers the type for later usage and type safety
type ToastMessage = z.infer<typeof toastMessageSchema>

// Schema to validate the flash session storage
const flashSessionValuesSchema = z.object({ 
  // validation schema from above
  toast: toastMessageSchema.optional()
});
// Here we infer the type schema and we get a fully typed object wherever we use
// The values we get from the session!
type FlashSessionValues = z.infer<typeof flashSessionValuesSchema>;

Alright so now that we are validating our session and converting it to the right type let us create redirect functions to be used throughout our code:

/**
 * Helper method used to redirect the user to a new page with flash session values
 * @param url Url to redirect to
 * @param flash Flash session values
 * @param init Response options
 * @returns Redirect response
 */
export async function redirectWithFlash(url: string, flash: FlashSessionValues, init?: ResponseInit) {
  return redirect(url, {
    ...init,
    // Remember the utility we implemented above? Well here it is!
    // It combines the headers you provided, stores the flash session info
    // and gives that back to the redirect which stores this in your web
    // browser into a cookie which will be sent with the next requests
    // automatically!
    headers: await flashMessage(flash, init?.headers)
  });
}

/**
 * Helper method used to redirect the user to a new page with a toast notification
 * If thrown it needs to be awaited
 * @param url Redirect URL
 * @param init Additional response options
 * @returns Returns a redirect response with toast stored in the session
 */
export function redirectWithToast(url: string, toast: ToastMessage, init?: ResponseInit) {
  // We provide a simple utility around redirectWithFlash to make it
  // less generic and toast specific, add more methods if you need them!
  return redirectWithFlash(url, { toast }, init);
}

/** Helper utility to redirect with an error toast shown to the user */
export function redirectWithErrorToast(redirectUrl: string, text: string, init?: ResponseInit) {
  return redirectWithToast(redirectUrl, { text, type: "error" }, init);
}
/** Helper utility to redirect with success toast shown to the user */
export function redirectWithSuccessToast(redirectUrl: string, text: string, init?: ResponseInit) {
  return redirectWithToast(redirectUrl, { text, type: "success" }, init);
}

Alright and there we have it, full support for toast messages on the server, now you might be wondering, how do I use this? Well let's assume you're logging your user in and you want to show a toast notification once logged in that it was successful, well all you have to do is the following:

export const action = () => {
   // You validate the user somehow
   return redirectWithSuccessToast("/dashboard", "Logged in!", {
     // You commit to your own user session that he is logged in,
     // this still works as expected with your headers
     headers: await commitSession(user)
   })
}

or if you want to show an error:

export const action = () => {
   // Something that you don't want happened
   return redirectWithErrorToast("/dashboard", "Whoops looks like something went wrong!" )
}

Or maybe a warning?

export const action = () => {
   return redirectWithToast("/dashboard", { type: "warning", text: "I'm warning you!")
}

Showing the actual toast to the user

Alright, so our server side is almost good to go! We need to add one more thing! A way to consume this toast message and give it to the user, well, let's add this to our existing utility file first:

Alright, we are fully done with our util file, now let's consume this!

Go to your root.tsx and add the following code inside your loader:

export const loader = async ({ request }: LoaderArgs) => {
  // Your root loader code
  const { flash, headers } = await getFlashSession(request);
  return json({ /** your data here */, flash }, { headers });
}

And inside your default exported component:

import { toast, ToastContainer } from "react-toastify";

export default function App() {
    const { /** your stuff */, flash } = useLoaderData<typeof loader>();
    // This will show your toast every time you redirect the user
    useEffect(() => {
      if (flash.toast) {
        toast(flash.toast.type, flash.toast.text);
      }
    }, [flash.toast]);

    return (
      <html>
        <body>
           <!-- your stuff -->
           <ToastContainer theme="colored" position="bottom-right" />
        </body>
      </html>
    );
}

And there you have it! Fully functional toast notifications that are created server-side, and consumed client-side!

How does this work exactly?

Well, when you redirect the user using one of the methods from above it will store a session cookie into your browser by being attached as a header on the response.

After the browser gets the response it will auto-append the cookie to future requests to your loaders/actions.

When you get a request from the client it will contain the cookie which will be extracted, parsed, validated and then consumed in the root.tsx loader.

The loader will create a header that will remove the cookie which is the headers part we pass back to the loader response, and the data you retrieved from the flash session will be passed to your client via the loader function, the next time the loader gets a request the flash session won't be there anymore because we removed it via our initial response and no data will be available so nothing is passed.

So basically we just used the concept of flash storage to show our toasts to our users!

Final file

import { createCookieSessionStorage, redirect } from "@remix-run/node";
import { z } from "zod";
import type { TypeOptions } from "react-toastify"

// This is your schema to validate the toast type, you can add additional schemas
// If you want to use flash storage for different things
const toastMessageSchema = z.object({
  // Message to display to the user
  text: z.string(),
  // Type of notification
  type: z.custom<TypeOptions>()
});
// Infers the type for later usage and type safety
type ToastMessage = z.infer<typeof toastMessageSchema>

// Schema to validate the flash session storage
const flashSessionValuesSchema = z.object({ 
  // validation schema from above
  toast: toastMessageSchema.optional()
});
// Here we infer the type schema and we get a fully typed object wherever we use
// The values we get from the session!
type FlashSessionValues = z.infer<typeof flashSessionValuesSchema>;

// Name of the session, can be whatever you want
const FLASH_SESSION = "flash";
// Pretty standard cookie session storage ( you can refer to the following for
// a more detailed explanation:
// https://remix.run/docs/en/1.19.3/utils/sessions#using-sessions
export const flashSessionStorage = createCookieSessionStorage({
  cookie: {
    // name of our session
    name: FLASH_SESSION,
    sameSite: "lax",
    path: "/",
    httpOnly: true,
    secrets: ["sEcR3t"],
    secure: process.env.NODE_ENV === "production"
  }
});

/**
 * This helper method is used to retrieve the cookie from the request,
 * parse it, and then gets the session used to store flash information
 */
function getSessionFromRequest(request: Request) {
  // Gets the cookie header from the request
  const cookie = request.headers.get("Cookie"); 
  // Gets our session using the instance we defined above to get the session
  // information
  return flashSessionStorage.getSession(cookie);
}

/**
 * Helper method used to get the flash session data from the session and show it to the user
 * @param request Request object
 * @returns Returns the the flash session data from the session and headers to purge the flash storage
 */
export async function getFlashSession(request: Request) { 
  // We retrieve the session by using the current requests cookie.
  const session = await getSessionFromRequest(request);
  // We validate that our data is correct via zod
  const result = flashSessionValuesSchema.safeParse(session.get(FLASH_SESSION));
  // If it isn't we set it to undefined
  const flash = result.success ? result.data : undefined;
  // We create the headers to purge the message from the cookie storage
  const headers = new Headers({
    "Set-Cookie": await flashSessionStorage.commitSession(session)
  });
  // Headers need to be returned to purge the flash storage
  return { flash, headers };
}

/**
 * Helper method used to add flash session values to the session
 */
export async function flashMessage(flash: FlashSessionValues, headers?: ResponseInit["headers"]) {
  // Gets the session we defined above
  const session = await flashSessionStorage.getSession();
  // Stores flash information into it that we pass to this function
  session.flash(FLASH_SESSION, flash);
  // Creates a cookie header to store into the response
  const cookie = await flashSessionStorage.commitSession(session);
  // If you've already passed in some custom headers this will create headers
  // and allow us to append additional properties
  const newHeaders = new Headers(headers);
  // Unlike JS objects headers can have multiple properties of the same name
  // So if you already passed in a Set-Cookie it will work fine and it will
  // set both cookies at the same time
  newHeaders.append("Set-Cookie", cookie);
  // Returns the headers to be given to the response object
  return newHeaders;
}

/**
 * Helper method used to redirect the user to a new page with flash session values
 * @param url Url to redirect to
 * @param flash Flash session values
 * @param init Response options
 * @returns Redirect response
 */
export async function redirectWithFlash(url: string, flash: FlashSessionValues, init?: ResponseInit) {
  return redirect(url, {
    ...init,
    // Remember the utility we implemented above? Well here it is!
    // It combines the headers you provided, stores the flash session info
    // and gives that back to the redirect which stores this in your web
    // browser into a cookie which will be sent with the next requests
    // automatically!
    headers: await flashMessage(flash, init?.headers)
  });
}

/**
 * Helper method used to redirect the user to a new page with a toast notification
 * If thrown it needs to be awaited
 * @param url Redirect URL
 * @param init Additional response options
 * @returns Returns a redirect response with toast stored in the session
 */
export function redirectWithToast(url: string, toast: ToastMessage, init?: ResponseInit) {
  // We provide a simple utility around redirectWithFlash to make it
  // less generic and toast specific, add more methods if you need them!
  return redirectWithFlash(url, { toast }, init);
}

/** Helper utility to redirect with an error toast shown to the user */
export function redirectWithErrorToast(redirectUrl: string, text: string, init?: ResponseInit) {
  return redirectWithToast(redirectUrl, { text, type: "error" }, init);
}
/** Helper utility to redirect with success toast shown to the user */
export function redirectWithSuccessToast(redirectUrl: string, text: string, init?: ResponseInit) {
  return redirectWithToast(redirectUrl, { text, type: "success" }, init);
}

Thank you!

If you've reached the end you're a champ! I hope you liked my article.

If you wish to support me follow me on Twitter here:

https://twitter.com/AlemTuzlak59192

or if you want to follow my work you can do so on GitHub:

https://github.com/AlemTuzlak

And you can also sign up for the newsletter to get notified whenever I publish something new!