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
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}
/>
);
}
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>
</>
);
}
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>
);
}
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>
);
}
Finally, export all the atoms from components/atoms/index.ts
-
export * from "./Avatar";
export * from "./Button";
export * from "./InputField";
export * from "./SelectField";
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.
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>
);
}
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);
}
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>
);
}
Finally, export all molecules from components/molecules/index.ts
export * from "./KudoCard";
export * from "./Modal";
export * from "./Portal";
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.
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. Undercomponents/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>
</>
);
}
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,
};
}
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>
);
}
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>
);
}
Finally under components/templates/index.ts
paste -
export * from "./SearchPanel";
export * from "./UsersPanel";
export * from "./RecentKudosPanel";
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)