Skip to content

Let's Code

Having learned essential parts of Remix, including how to use Atlas, handle SSO, understand loaders, and create server files and routes, it's time to put all of this into practice.

To apply your learnings, here's an end-to-end exercise that will help you integrate these concepts into your work:

The goal is to add more trustworthy elements to the page by showing which companies are currently benefiting from Statista. Additionally, we want to make this list editable. For simplicity, we will assume that a logged-in user with a role "1" assigned have the right to modify the list of companies. Let's get started! πŸ™Œ

Preparations

Before you begin, you need to set up some storage since we don't have a database running. We'll create a file to store and retrieve the data:

  • Create a new folder in the root directory called /data.
  • Inside the /data folder, create a file called companies.json and place the following content inside:
Content of the file

/data/companies.json

[
  {
    "name": "Google",
    "id": "1"
  },
  {
    "name": "Samsung",
    "id": "2"
  },
  {
    "name": "Unilever",
    "id": "3"
  },
  {
    "name": "Apple",
    "id": "4"
  },
  {
    "name": "Volkswagen",
    "id": "5"
  }
]
  • Next, create a file in the /app/helpers folder called read-and-write-file.server.ts. In this file, you'll define the readFile and writeFile functions to read data from and write data to the file.
Content of the file

/app/helpers/read-and-write-file.server.ts

import fs from "fs/promises";
import path from "path";
import { z } from "zod";

const Schema = z.array(z.object({ name: z.string(), id: z.string() }));

export const readFile = async () => {
  const filePath = path.resolve("./data/companies.json");
  const data = await fs.readFile(filePath, "utf8");
  return Schema.parse(JSON.parse(data));
};

export const writeFile = async (jsonData: unknown) => {
  const filePath = path.resolve("./data/companies.json");
  await fs.writeFile(filePath, JSON.stringify(jsonData, null, 2), "utf8");
};

Next to fs (a Promise-based file system module) and path (a utility for handling file paths), which are part of Node.js, we're using a new library: Zod. Zod helps define a data schema and validate incoming data against it. If the data doesn't match the defined schema, Zod will throw an error. The benefit of Zod is that it provides full typing support for the parsed data, ensuring consistency and reducing potential bugs.

To install Zod, run:

pnpm add zod

If your editor complains about not recognizing the library, try restarting it.

πŸ“ Task: Display a List of Customers and Fetch the Data

Build the UI

The UI should consist of a headline, "Our Customers," and a list of customers displayed below it. You can check out the fully fleshed-out version of this on Statista's "Why Statista" page, but for now, we're going to focus on building a simple MVP that just displays a list of customers. πŸ˜‰

customers-list-mockup

Think about what we covered in the Atlas section and Thinking in React:

  • Try to break down the elements and identify components you can use from Atlas.
  • Add the headline and the list of customers below the header you created earlier.

Note: If you want to learn more about CSS Flexbox, check out this great guide from CSS-Tricks: A Guide to Flexbox

Solution
<Container className="py-10">
  <Headline renderAs="h2" size="lg" className="mb-4 text-center">
    Our Customers
  </Headline>
  <ul className="gap-4 flex justify-center">
    <li>
      <Card padding="md">
        <Paragraph className="text-xl">Apple</Paragraph>
      </Card>
    </li>
    <li>
      <Card padding="md">
        <Paragraph className="text-xl">Volkswagen</Paragraph>
      </Card>
    </li>
    {/* ... and more */}
  </ul>
</Container>

Fetch and Use the Data

Now, it's time to fetch the dataβ€”or read it from the file in our caseβ€”and enhance the UI so the customers are dynamically rendered in the list you just created.

  • Fetch the data in the loader and pass it down to the component.
  • Render the customers in the list using the component you built.
Solution

In the loader function, use the readFile() function to read the data from the file and include the companies data in the return statement:

const companies = await readFile();
return json({ givenName, companies });

Then, enhance the component by destructuring the companies object from useLoaderData and iterate through the array to render each customer in the list:

export default function Welcome() {
  const { givenName, companies } = useLoaderData<typeof loader>();

  // ...

  return (
    <>
      {/* The header component... */}
      <Container className="py-10">
        <Headline renderAs="h2" size="lg" className="mb-4 text-center">
          Our Customers
        </Headline>
        <ul className="gap-4 flex justify-center">
          {companies.map(({ id, name }) => {
            return (
              <li key={id}>
                <Card padding="md">
                  <Paragraph className="text-xl">{name}</Paragraph>
                </Card>
              </li>
            );
          })}
        </ul>
      </Container>
    </>
  );
}

That's it! You've successfully fetched data from the file and rendered the results. Pretty easy, right?

πŸ“ Task: Add the Ability to Delete an Item

We want to allow users to delete a company from the list.

delete-customer-mockup

First, let's enhance the UI. Afterward, we'll focus on the functionality. The "delete" button can be built as a separate, reusable component. Let's make it reusable.

  • Use a button component and the "x" icon from Atlas.
  • Create a dedicated DeleteCompany component for this button.

Note: To add icons, include them in the icons.json file at the root of the project. A background task will generate the icon from this JSON file. To use the icon, import the Icon component from the icons folder.

Solution

The DeleteCompany component can be written as follows. Notice that the className is passed down to make the component reusable. The placement of the component should be determined externally, where it is being used.

const DeleteCompany = ({ className }: { className?: string }) => {
  return (
    <div className={className}>
      <Button variant="outline" size="unspecified">
        <Icon name="12-bold-cross-normal" />
      </Button>
    </div>
  );
};

Here's how to add the DeleteCompany component to the Card:

<Card padding="md" className="relative">
  <Paragraph className="text-xl">{name}</Paragraph>
  <DeleteCompany className="absolute -top-2 -right-2" />
</Card>

Now, let's add the business logic to actually delete a company from the list. In Remix, interactions like this are handled using HTML forms and action.

Here's the process:

  • When submitting a form (via a POST request), the action function handles the request, performs any necessary operations, and returns a response.

Let's enhance the DeleteCompany component to include a form. Yes, each "delete" button component will render a form. We will have one form for each "delete" action. And we need to pass the id of the company to identify which item is being deleted.

Note: Take a look at Remix's documentation for building form interactions: Remix Form Documentation.

  • Add a Form to the DeleteCompany component.
  • Add the id of the company to identify the item to be deleted.
Solution

Here's the enhanced DeleteCompany component with the Form:

const DeleteCompany = ({
  className,
  id,
}: {
  className?: string;
  id: string;
}) => {
  return (
    <div className={className}>
      <Form method="post">
        <input type="hidden" name="id" defaultValue={id} />
        <Button type="submit" variant="outline" size="unspecified">
          <Icon name="12-bold-cross-normal" />
        </Button>
      </Form>
    </div>
  );
};

When you press the button, you'll encounter a 405 Method Not Allowed error, which is expected. The action function to handle this request is still missing. Let's create that!

Adding the Action

By submitting the form, the input data is sent to the action, which will process it. Let's start by simply logging the id of the company to be deleted.

You can read more about handling form data here: FormData API.

  • Create and export an action function in the same file.
  • Retrieve the id sent through the form with the request and log it.
Solution
export const action = async ({ request }: ActionFunctionArgs) => {
  const formData = await request.formData();
  const id = formData.get("id");
  return json({ id });
};

You should see the id of the company to be deleted in your terminal, not in the browser. This operation is done on the server-side, not browser.

Now, let's delete the company from the list and update the companies.json file.

  • Read the data from the file.
  • Filter out the company to be deleted.
  • Write the updated list back to the file.
Solution
export const action = async ({ request }: ActionFunctionArgs) => {
  const formData = await request.formData();
  const id = formData.get("id");

  const data = await readFile();
  const companies = data.filter((company) => company.id !== id);

  await writeFile(companies);

  return json({ companies });
};

Now, when you click the delete button, the company will be removed from the list. Congratulations! πŸŽ‰ You've implemented a fully functional user interaction for deleting an item.

πŸ“ Task: Display the Delete Button Only for Users with role: "1"

The goal is to basically "secure" the delete functionality. It would be a pity if everyone could perform this action.

For this task, most of the work is already done. We previously provided access to the role of the logged-in user, so now we just need to check if the user has the role 1 and toggle the visibility of the delete button accordingly.

  • Enhance the loader to check if the user has role: "1" (Hint: the additional user information will provide this information).
  • Toggle the visibility of the delete button based on the user's role.
Solution

Here's how the loader function might look:

export const loader = ({ request }: LoaderFunctionArgs) => {
  return optionalAuth(request, async () => {
    const user = getUser();
    let givenName;
    let canEditCompanies = false;

    if (user) {
      const userInfo = await user.fetchUserInfo(request);
      givenName = userInfo?.user?.name?.givenName;
      canEditCompanies = userInfo?.user?.roles.includes("1") ?? false;
    }

    const companies = await readFile();
    return json({ givenName, companies, canEditCompanies });
  });
};

In this enhanced loader, we fetch the userInfo and check if the user has the role 1. We then pass the canEditCompanies variable down to the component to control the visibility of the delete button.

Now, enhance the component to conditionally render the delete button based on the canEditCompanies flag:

export default function Welcome() {
  const { givenName, companies, canEditCompanies } =
    useLoaderData<typeof loader>();
  // ...

  return (
    // ...
    <Card padding="md" className="relative">
      <Paragraph className="text-xl">{name}</Paragraph>
      {canEditCompanies ? (
        <DeleteCompany id={id} className="absolute -top-2 -right-2" />
      ) : null}
    </Card>
    // ...
  );
}

If canEditCompanies is true, the DeleteCompany button will be displayed; otherwise, it won't.

Congratulations! You've successfully implemented role-based visibility for the delete button, all managed server-side. Now, only users with the correct role (role: "1") can see and use the delete functionality. Great work! πŸŽ‰

πŸ“ Optional Task: Add more companies

  • Add UI to add a company (simply an text input).
  • Handle the input with a Form and action and store the new company in the json file.
  • Think about who should have access to add companies.

Note: No help, just you πŸ’ͺ

Let's Build an API with Remix

While handling loading and deleting companies using loader and action is the correct approach, let's demonstrate how to build an API endpoint for the "delete action" instead. This will show you how similar patterns can be applied when building API endpoints with Remix.

Note: When considering building an API like this, be mindful of potential side effects like losing strong typing or creating unnecessary HTTP requests. Internally within an app, it often doesn't make sense to build a dedicated API, but we'll build one here for demonstration purposes.

To create a separate API endpoint, you'll need to create a new route file. We also want to provide an endpoint to explicitly delete one item. For example, the route could be something like /remix-workshop/api/companies/1/delete. Check the Flat Routes Docs to see how this could be done.

  • Create a new route, e.g., /remix-workshop/api/companies/1/delete.
  • Create an action to handle the DELETE method.
  • Handle the deletion of the item in the API.
  • Adapt the form accordingly to use this API.
  • Delete the action from the welcome route.
Solution

The filename for the route should be api.companies.$id.delete.

Here's the content for the file:

import { json, type ActionFunctionArgs } from "@pit-shared/remix-esm";
import { readFile, writeFile } from "../helpers/read-and-write-file.server.js";

export const action = async ({ params }: ActionFunctionArgs) => {
  const { id } = params;
  const data = await readFile();
  const companies = data.filter((company) => company.id !== id);
  await writeFile(companies);
  return json({ companies: companies });
};

By moving the id of the item to be deleted into the URL, you can eliminate the need for a hidden input field in the form.

Now, modify the DeleteCompany component to use the new DELETE method in the form and specify the action URL:

const DeleteCompany = ({
  className,
  id,
}: {
  className?: string;
  id: string;
}) => {
  return (
    <div className={className}>
      <Form
        method="DELETE"
        action={`/remix-workshop/api/companies/${id}/delete`}
        navigate={false}
      >
        <Button type="submit" variant="outline" size="unspecified">
          <Icon name="12-bold-cross-normal" />
        </Button>
      </Form>
    </div>
  );
};

With this API in place, anyone knowing this API endpoint is able to delete a company item. However, we need to secure this action by applying the same business logic we used before: Only users with the role "1" should be able to delete items. If a user does not have this role, the API should return an error.

  • Secure the delete endpoint by check for the role including "1"
  • Respond with an error in case the user is not eligable to delete a company item
Solution

Here's the enhanced API with role-based security:

import { json, type ActionFunctionArgs } from "@pit-shared/remix-esm";
import { readFile, writeFile } from "../helpers/read-and-write-file.server.js";
import { getUser, optionalAuth } from "../services/sso.server.js";

export const action = async ({ request, params }: ActionFunctionArgs) => {
  return optionalAuth(request, async () => {
    const user = getUser();

    const userInfo = await user?.fetchUserInfo(request);
    const canDelete = userInfo?.user?.roles.includes("1") ?? false;

    if (canDelete) {
      const { id } = params;
      const data = await readFile();
      const companies = data.filter((company) => company.id !== id);
      await writeFile(companies);
      return json({ companies: companies });
    }

    return new Response("Unauthorized", { status: 401 });
  });
};

This version ensures that only users with role: "1" can delete a company. If a user without this role tries to delete an item, the API responds with a 401 Unauthorized error.

In summary, similar patterns can be applied when building an API with Remix. The main difference is that you'll use specific routes, loaders and actions for handling API requests. However, security is always an important consideration. In most cases, building a dedicated API is unnecessary; actions and loaders are perfectly fine for internal app operations.

Well done! You've successfully created an API endpoint to delete a company and secured it based on user roles. Congratulations! πŸŽ‰

πŸ“ Optional Task: Create an API endpoint to provide the array of companies

  • Create an API endpoint to be able to fetch (GET) companies.
  • Use the endpoint to actually fetch the data in the loader of the welcome page.

Note: No help, just you πŸ’ͺ