DEV Community

Arsalan Ahmed Yaldram
Arsalan Ahmed Yaldram

Posted on

Building a full stack app with Remix & Drizzle ORM: Folder structure

Introduction

In this tutorial series, we'll explore building a full stack application using Remix and Drizzle ORM. In this tutorial, we will focus on creating the folder structure and React components that will be utilized in our future tutorials. Following the atomic design methodology, we will categorize our components into atoms (e.g., buttons and inputs), molecules (e.g., cards composed of atoms), and templates (e.g., users panel list, recent kudos list). By adhering to this structured approach, we can ensure modularity and reusability in our component architecture. Let's dive in and establish a solid foundation for building our application!

Credit for inspiring this tutorial series goes Sabin Adams, whose insightful tutorial series served as a valuable source of inspiration for this project.

Overview

Please note that this tutorial assumes a certain level of familiarity with React.js, Node.js, and working with ORMs. In this tutorial we will be -

  • Setting up atoms: Creating reusable components such as buttons, inputs, selects and avatar.
  • Building molecules: Creating composite components like cards, composed of atoms, to represent specific UI elements.
  • Designing templates: Developing template components such as users panel lists and recent kudos lists to structure larger sections of the application.
  • Implementing service files: Creating service files to encapsulate database queries for users and kudos.

All the code for this tutorial can be found here

Step 1: Setting up Atoms

register-page
Now, let's focus on our signup page, which consists of inputs and buttons. These atomic components are highly versatile, as they can be utilized independently and across various sections of our application. Create a new folder components under the app folder, under components create a new folder called atoms. Within the atoms folder, create three files: -"Button.tsx", "InputField.tsx", "SelectField.tsx" and "Avatar.tsx" Additionally, create an "index.ts" file as an entry point in the atoms folder to export all these components. This structure allows for simplified imports across other files using import { Button, InputField, ... } from "~/components/atoms".
Under components/atoms/Button.tsx paste -

type ButtonProps = React.ComponentPropsWithoutRef<"button">;

export function Button({ type, className, ...rest }: ButtonProps) {
  return (
    <button
      type={type}
      className={`rounded-xl mt-2 bg-yellow-300 disabled:opacity-50 disabled:cursor-not-allowed px-3 py-2 text-blue-600 font-semibold transition duration-300 ease-in-out hover:bg-yellow-400 hover:-translate-y-1 ${className}`}
      {...rest}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

Under components/atoms/InputField.tsx paste -

type InputFieldProps = {
  label: string;
  error?: string;
  errorId?: string;
} & React.ComponentPropsWithoutRef<"input">;

export function InputField({
  name,
  id,
  label,
  type = "text",
  value,
  error = "",
  errorId,
  ...rest
}: InputFieldProps) {
  return (
    <>
      <label htmlFor={id} className="text-blue-600 font-semibold">
        {label}
      </label>
      <input
        type={type}
        id={id}
        name={name}
        className="w-full p-2 rounded-xl my-2"
        value={value}
        {...rest}
      />
      <div
        id={errorId}
        className="text-xs font-semibold text-center tracking-wide text-red-500 w-full"
      >
        {error}
      </div>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Under components/atoms/SelectField.tsx paste -

type SelectFieldProps = {
  label: string;
  containerClassName?: string;
  options: {
    name: string;
    value: string;
  }[];
  error?: string;
  errorId?: string;
} & React.ComponentPropsWithoutRef<"select">;

export function SelectField({
  id,
  label,
  options,
  containerClassName,
  className,
  name,
  error,
  errorId,
  ...delegated
}: SelectFieldProps) {
  return (
    <div>
      <label htmlFor={id} className="text-blue-600 font-semibold">
        {label}
      </label>
      <div className={`flex items-center ${containerClassName} my-2`}>
        <select
          className={`${className} appearance-none`}
          id={id}
          name={name}
          {...delegated}
        >
          {options.map((option, key) => (
            <option key={key} value={option.value}>
              {option.name}
            </option>
          ))}
        </select>
        <svg
          className="w-4 h-4 fill-current text-gray-400 -ml-7 mt-1 pointer-events-none"
          viewBox="0 0 140 140"
          xmlns="http://www.w3.org/2000/svg"
        >
          <g>
            <path d="m121.3,34.6c-1.6-1.6-4.2-1.6-5.8,0l-51,51.1-51.1-51.1c-1.6-1.6-4.2-1.6-5.8,0-1.6,1.6-1.6,4.2 0,5.8l53.9,53.9c0.8,0.8 1.8,1.2 2.9,1.2 1,0 2.1-0.4 2.9-1.2l53.9-53.9c1.7-1.6 1.7-4.2 0.1-5.8z" />
          </g>
        </svg>
      </div>
      <div
        id={errorId}
        className="text-xs font-semibold text-center tracking-wide text-red-500 w-full"
      >
        {error}
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Under components/atoms/Avatar.tsx paste -

import React from "react";

import type { UserProfile } from "~/drizzle/schemas/users.db.server";

type AvatarProps = {
  userProfile: UserProfile;
} & React.ComponentPropsWithoutRef<"div">;

export function Avatar(props: AvatarProps) {
  return (
    <div
      className={`${props.className} cursor-pointer bg-gray-400 rounded-full flex justify-center items-center`}
      onClick={props.onClick}
      style={{
        backgroundSize: "cover",
        ...(props.userProfile.profileUrl
          ? { backgroundImage: `url(${props.userProfile.profileUrl})` }
          : {}),
      }}
    >
      {!props.userProfile.profileUrl && (
        <h2>
          {props.userProfile.firstName.charAt(0).toUpperCase()}
          {props.userProfile.lastName.charAt(0).toUpperCase()}
        </h2>
      )}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Finally, export all the atoms from components/atoms/index.ts -

export * from "./Avatar";
export * from "./Button";
export * from "./InputField";
export * from "./SelectField";
Enter fullscreen mode Exit fullscreen mode

Step 2: Building Molecules

Molecules are composite components composed of atoms. They represent specific UI elements that are more complex and reusable. You can create molecule components like cards, which can include multiple atoms such as buttons, avatars or texts.
home-screen
For our molecules, we will focus on three components related to the main page -

  • First, we have the KudosCard component, which represents a card displaying kudos.
  • When the user avatar is clicked, it triggers a modal component. Hence, the second molecule we will build is the Modal component.
  • To implement the modal, we will utilize portals, ensuring it renders outside of its parent component's DOM hierarchy. Under components/molecules/KudoCard.tsx paste -
import type { Kudo } from "~/drizzle/schemas/kudos.db.server";
import type { UserProfile } from "~/drizzle/schemas/users.db.server";
import { backgroundColorMap, textColorMap, emojiMap } from "~/utils/constants";
import { Avatar } from "../atoms";

type KudoProps = {
  kudo: Pick<Kudo, "message" | "style">;
  userProfile: UserProfile;
};

export function KudoCard({ kudo, userProfile }: KudoProps) {
  return (
    <div
      className={`flex ${
        backgroundColorMap[kudo.style.backgroundColor]
      } p-4 rounded-xl w-full gap-x-2 relative`}
    >
      <div>
        <Avatar userProfile={userProfile} className="h-16 w-16" />
      </div>
      <div className="flex flex-col">
        <p
          className={`${
            textColorMap[kudo.style.textColor]
          } font-bold text-lg whitespace-pre-wrap break-all`}
        >
          {userProfile.firstName} {userProfile.lastName}
        </p>
        <p
          className={`${
            textColorMap[kudo.style.textColor]
          } whitespace-pre-wrap break-all`}
        >
          {kudo.message}
        </p>
      </div>
      <div className="absolute bottom-4 right-4 bg-white rounded-full h-10 w-10 flex items-center justify-center text-2xl">
        {emojiMap[kudo.style.emoji]}
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Under atoms/molecules/Portal.tsx paste -

import { createPortal } from "react-dom";
import { useState, useEffect } from "react";

type PortalProps = {
  children: React.ReactNode;
  wrapperId: string;
};

const createWrapper = (wrapperId: string) => {
  const wrapper = document.createElement("div");
  wrapper.setAttribute("id", wrapperId);
  document.body.appendChild(wrapper);
  return wrapper;
};

export function Portal({ children, wrapperId }: PortalProps) {
  const [wrapper, setWrapper] = useState<HTMLElement | null>(null);

  useEffect(() => {
    let element = document.getElementById(wrapperId);
    let created = false;

    if (!element) {
      created = true;
      element = createWrapper(wrapperId);
    }

    setWrapper(element);

    return () => {
      if (created && element?.parentNode) {
        element.parentNode.removeChild(element);
      }
    };
  }, [wrapperId]);

  if (wrapper === null) return null;

  return createPortal(children, wrapper);
}
Enter fullscreen mode Exit fullscreen mode

Under atoms/molecules/Modal.tsx paste -

import { Portal } from "./Portal";

type ModalProps = {
  children: React.ReactNode;
  isOpen: boolean;
  ariaLabel?: string;
  className?: string;
  onOutsideClick: () => void;
};

export function Modal({
  children,
  isOpen,
  ariaLabel,
  onOutsideClick,
  className,
}: ModalProps) {
  if (!isOpen) return null;

  return (
    <Portal wrapperId="modal">
      <div
        className="fixed inset-0 overflow-y-auto bg-gray-600 bg-opacity-80"
        aria-labelledby={ariaLabel ?? "modal-title"}
        role="dialog"
        aria-modal="true"
        onClick={() => onOutsideClick()}
      ></div>
      <div className="fixed inset-0 pointer-events-none flex justify-center items-center max-h-screen overflow-scroll">
        <div
          className={`${className} p-4 bg-gray-200 pointer-events-auto max-h-screen md:rounded-xl`}
        >
          {children}
        </div>
      </div>
    </Portal>
  );
}
Enter fullscreen mode Exit fullscreen mode

Finally, export all molecules from components/molecules/index.ts

export * from "./KudoCard";
export * from "./Modal";
export * from "./Portal";
Enter fullscreen mode Exit fullscreen mode

Step 3: Setup Templates

Templates are higher-level components that structure larger sections of the application. They provide a layout and organization for the components within them.
home-screen
Within our app, we have three templates arranged from left to right: -

  • Firstly, the UsersPanel template showcases a list of users with their avatars.
  • Next, we have the SearchPanel template at the top displaying the logged in user profile to the right.
  • Lastly, on the right-hand side, we have the RecentKudosPanel template, featuring a list of recent kudos along with user avatars and kudo emojis. Under components/templates/UsersPanel.tsx paste -
import type { User } from "~/drizzle/schemas/users.db.server";
import { getUserProfile } from "~/utils/helpers";
import { Button, Avatar } from "../atoms";

type UsersPanelProps = {
  users: User[];
};

export function UsersPanel(props: UsersPanelProps) {
  return (
    <>
      <div className="text-center bg-gray-300 h-20 flex items-center justify-center">
        <h2 className="text-xl text-blue-600 font-semibold">My Team</h2>
      </div>
      <div className="flex-1 overflow-y-scroll py-4 flex flex-col gap-y-10">
        {props.users.map((user) => (
          <Avatar
            key={user.id}
            userProfile={getUserProfile(user)}
            className="h-24 w-24 mx-auto flex-shrink-0"
          />
        ))}
      </div>
      <div className="text-center p-6 bg-gray-300">
        <form action="/logout" method="post">
          <Button type="submit">Logout</Button>
        </form>
      </div>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Don't worry about the logout button we will implement the functionality later. Under utils/helpers.ts create getUserProfile function -

import type { User } from "~/drizzle/schemas/users.server";

export function getUserProfile(user: User) {
  return {
    firstName: user.firstName,
    lastName: user.lastName,
    profileUrl: user.profileUrl,
  };
}
Enter fullscreen mode Exit fullscreen mode

Now under components/templates/RecentKudosPanel.tsx paste -

import { getUserProfile } from "~/utils/helpers";
import { emojiMap } from "~/utils/constants";
import type { User } from "~/drizzle/schemas/users.db.server";
import { Avatar } from "../atoms";

type RecentBarProps = {
  records: any;
};

export function RecentKudosPanel({ records }: RecentBarProps) {
  return (
    <div className="w-1/5 border-l-4 border-l-yellow-300 flex flex-col items-center">
      <h2 className="text-xl text-yellow-300 font-semibold my-6">
        Recent Kudos
      </h2>
      <div className="h-full flex flex-col gap-y-10 mt-10">
        {records.map(({ kudos, users }: any) => (
          <div className="h-24 w-24 relative" key={kudos.id}>
            <Avatar
              userProfile={getUserProfile(users as User)}
              className="w-20 h-20"
            />
            <div className="h-8 w-8 text-3xl bottom-2 right-4 rounded-full absolute flex justify-center items-center">
              {emojiMap[kudos.style.emoji]}
            </div>
          </div>
        ))}
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Currently, the props for our components have the type "any." We will update and assign proper types to these props later when we add the respective functionality.
Under components/templates/SearchPanel.tsx paste -

import type { User } from "~/drizzle/schemas/users.db.server";
import { getUserProfile } from "~/utils/helpers";
import { Avatar } from "../atoms";

type SearchBarProps = {
  user: User;
};

export function SearchBar(props: SearchBarProps) {
  return (
    <div className="w-full px-6 items-center gap-x-4 border-b-4 border-b-yellow-300 h-20 flex justify-end p-2">
      <Avatar
        className="h-14 w-14 transition duration-300 ease-in-out hover:scale-110 hover:border-2 hover:border-yellow-300"
        userProfile={getUserProfile(props.user)}
      />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Finally under components/templates/index.ts paste -

export * from "./SearchPanel";
export * from "./UsersPanel";
export * from "./RecentKudosPanel";
Enter fullscreen mode Exit fullscreen mode

Great job! With the completion of all our components, we are now ready to utilize them in the upcoming tutorials.

Step 4: Create service files

In our application, we have two tables, kudos and users, which require database queries. To maintain a clean and organized structure, we will create separate service files for each table under the services folder. Additionally, we will create a sessions.server.ts file to handle authentication sessions using Remix. Create a services folder under app folder and create 3 files namely - kudos.server.ts, users.server.ts & sessions.server.ts.

It's important to note that we should add the .server extension to our service files. By doing so, Remix will exclude these files from the client bundle, ensuring they are only included on the server. We will delve deeper into this topic and its significance in the later tutorials.

Conclusion

In this tutorial, we established a well-structured folder hierarchy, developed essential components following the atomic design methodology, and added service files for future db queries. In the next tutorial, we will focus on user registration and login functionality, building upon the foundation we have laid. All the code for this tutorial can be found here. Until next time PEACE!

Top comments (0)