DEV Community

Cover image for Enhance User Experience with Remix.js and Toast Notifications
Francisco Mendes
Francisco Mendes

Posted on

Enhance User Experience with Remix.js and Toast Notifications

Introduction

Many of the things that run in applications need to pass important information as a form of feedback to the user, so that they know the processing status. This can be in the form of information about updates, reminders, alerts, among other features.

And this is where the use of Toast notifications is important, because they are not an intrusive element on the screen and provide immediate feedback for a certain period of time, allowing the user to continue with their tasks.

This brings us to today's article, in which we are going to create an application where we insert data into a database and the expectation is that a notification will be shown when the promise is resolved or rejected.

final app

What will be covered

  • Drizzle ORM configuration
  • Definition of the table schema
  • Validation of forms
  • Implementation of toast management on the server and client side

Prerequisites

It is expected that you have prior knowledge of building applications using Remix.js and using ORM's. Another thing worth mentioning is that the Tailwind CSS framework will be used, however step-by-step instructions on how to configure it will not be shown, so I recommend following this guide.

Getting started

Let's start by generating the base template:

npx create-remix@latest toastmix
Enter fullscreen mode Exit fullscreen mode

Next, we will install the necessary dependencies:

npm install drizzle-orm better-sqlite3
npm install --dev drizzle-kit @types/better-sqlite3
Enter fullscreen mode Exit fullscreen mode

With the dependencies installed, we can move on to drizzle configuration so that the paths to the database schema can be defined and in which path the migrations should be generated.

// drizzle.config.ts
import type { Config } from "drizzle-kit";

export default {
  schema: "./app/db/schema.ts",
  out: "./migrations",
  driver: "better-sqlite",
  dbCredentials: {
    url: "./local.db",
  },
  verbose: true,
  strict: true,
} satisfies Config;
Enter fullscreen mode Exit fullscreen mode

The next step is related to defining the schema of the database tables and as you may have already noticed, we will use SQLite in this project and for this we will use the Drizzle primitives targeted at that dialect.

// @/app/db/schema.ts
import { sqliteTable, integer, text } from "drizzle-orm/sqlite-core";

export const users = sqliteTable("users", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  username: text("username").unique().notNull(),
});
Enter fullscreen mode Exit fullscreen mode

In the code snippet above we defined that the users table has two columns called id and username, ensuring that the value of the latter must be unique. Next we can move on to creating the client that will be used to interact with the database.

// @/app/db/index.ts
import {
  drizzle,
  type BetterSQLite3Database,
} from "drizzle-orm/better-sqlite3";
import Database from "better-sqlite3";

import * as schema from "./schema";

const sqlite = new Database("local.db");

export const db: BetterSQLite3Database<typeof schema> = drizzle(sqlite, {
  schema,
});
Enter fullscreen mode Exit fullscreen mode

With this we can close the topics related to the database and we can now redirect our efforts to dealing with the application forms. First, we will install the following dependencies:

npm install remix-validated-form zod @remix-validated-form/with-zod
Enter fullscreen mode Exit fullscreen mode

The dependencies that remain to be installed are related to the implementation of the toast/notification management strategy in the application. We will need to define them on the client and server side.

npm install remix-toast react-hot-toast
Enter fullscreen mode Exit fullscreen mode

In this article we will use the react-hot-toast package on the client side but it is worth highlighting that we can use any other library on the client side because the solution is agnostic. Therefore, you can use the package that you are most comfortable using.

Now that we have the dependencies we need, we can now move on to creating the components that will be reused in the application. Starting first with <Input />:

// @/app/components/Input.tsx
import type { FC } from "react";
import { useField } from "remix-validated-form";

interface InputProps {
  name: string;
  label: string;
}

export const Input: FC<InputProps> = ({ name, label }) => {
  const { error, getInputProps } = useField(name);

  return (
    <div className="h-20">
      <label
        className="relative block rounded-md border border-gray-200 shadow-sm focus-within:border-blue-600 focus-within:ring-1 focus-within:ring-blue-600"
        htmlFor={name}
      >
        <input
          className="peer border-none bg-transparent placeholder-transparent focus:border-transparent focus:outline-none focus:ring-0"
          {...getInputProps({ id: name })}
        />
        <span className="pointer-events-none absolute start-2.5 top-0 -translate-y-1/2 bg-white p-0.5 text-xs text-gray-700 transition-all peer-placeholder-shown:top-1/2 peer-placeholder-shown:text-sm peer-focus:top-0 peer-focus:text-xs">
          {label}
        </span>
      </label>
      {error && <span className="text-red-500 text-xs ml-1">{error}</span>}
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

Another component that needs to be created is related to the form submission event, we can now work on the <SubmitButton />:

// @/app/components/SubmitButton.tsx
import type { ButtonHTMLAttributes, FC } from "react";
import { useIsSubmitting } from "remix-validated-form";

export const SubmitButton: FC<ButtonHTMLAttributes<HTMLButtonElement>> = (
  props
) => {
  const isSubmitting = useIsSubmitting();

  return (
    <button
      {...props}
      type="submit"
      disabled={isSubmitting}
      className="inline-block rounded border border-indigo-600 bg-indigo-600 w-32 h-10 text-sm font-medium text-white hover:bg-transparent hover:text-indigo-600 focus:outline-none focus:ring active:text-indigo-500"
    >
      {isSubmitting ? "Submitting..." : "Submit"}
    </button>
  );
};
Enter fullscreen mode Exit fullscreen mode

With this we can close the topic of creating reusable components and we can move on to creating the application page in today's example. Starting by importing the necessary dependencies, along with the components and clients that we created in this article:

// @/app/routes/_index.tsx
import {
  json,
  type DataFunctionArgs,
  type LoaderFunctionArgs,
  type MetaFunction,
} from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { withZod } from "@remix-validated-form/with-zod";
import { useEffect } from "react";
import notify, { Toaster } from "react-hot-toast";
import { getToast, redirectWithError, redirectWithSuccess } from "remix-toast";
import { ValidatedForm, validationError } from "remix-validated-form";
import { z } from "zod";

import { Input } from "~/components/Input";
import { SubmitButton } from "~/components/SubmitButton";
import { db } from "~/db";
import { users } from "~/db/schema";

export const meta: MetaFunction = () => {
  return [
    { title: "List" },
    { name: "description", content: "List page content" },
  ];
};

// ...
Enter fullscreen mode Exit fullscreen mode

Next, we will create the validator that will be used to check the integrity of the form data, which will be used on the client and backend side. Another thing we will define is the loader, in which we will obtain the toast notification if this HTTP request is present and we will also obtain the complete list of users present in the database.

// @/app/routes/_index.tsx

// ...

export const validator = withZod(
  z.object({
    username: z.string().min(1, { message: "Required" }),
  })
);

export const loader = async ({ request }: LoaderFunctionArgs) => {
  const { toast, headers } = await getToast(request);
  const datums = await db.query.users.findMany();
  return json({ toast, datums }, { headers });
};

// ...

Enter fullscreen mode Exit fullscreen mode

The next step involves defining the action of the route, in which we will validate the form data and proceed to insert it into the database. If the promise is resolved successfully, we will use the primitive redirectWithSuccess() which takes two arguments, the route to which we want to redirect and what message should be sent in the toast notification. However, if the promise is rejected we will use the primitive redirectWithError() which also receives two arguments similar to the previous primitive.

// @/app/routes/_index.tsx

// ...

export const action = async ({ request }: DataFunctionArgs) => {
  try {
    const result = await validator.validate(await request.formData());
    if (result.error) return validationError(result.error);
    await db.insert(users).values({ username: result.data.username });
    return redirectWithSuccess("/", "Successful operation");
  } catch {
    return redirectWithError("/", "An error occurred");
  }
};

// ...

Enter fullscreen mode Exit fullscreen mode

Last but not least, in the page component we will take advantage of the useLoaderData hook to obtain the toast and the database data that is returned from the loader. And we will use a useEffect to issue the toast notification if it is present and depending on its type.

// @/app/routes/_index.tsx

// ...

export default function Index() {
  const { toast, datums } = useLoaderData<typeof loader>();

  useEffect(() => {
    switch (toast?.type) {
      case "success":
        notify.success(toast.message);
        return;
      case "error":
        notify.error(toast.message);
        return;
      default:
        return;
    }
  }, [toast]);

  return (
    <div className="h-screen w-full flex justify-center items-center">
      <Toaster />

      <div className="w-2/4 space-y-4">
        <ValidatedForm validator={validator} method="post">
          <div className="flex space-x-4">
            <div className="w-full">
              <Input name="username" label="Username" />
            </div>
            <SubmitButton />
          </div>
        </ValidatedForm>

        <div className="overflow-x-auto rounded-lg border border-gray-200">
          <table className="min-w-full divide-y-2 divide-gray-200 bg-white text-sm">
            <thead className="text-left">
              <tr>
                <th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900">
                  Name
                </th>
              </tr>
            </thead>

            <tbody className="divide-y divide-gray-200">
              {datums.map((datum) => (
                <tr key={datum.id}>
                  <td className="whitespace-nowrap px-4 py-2 font-medium text-gray-900">
                    {datum.username}
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

I hope you found this article helpful, whether you're using the information in an existing project or just giving it a try for fun.

Please let me know if you notice any mistakes in the article by leaving a comment. And, if you'd like to see the source code for this article, you can find it on the github repository linked below.

Github Repo

Top comments (0)