Return to the homepageRemix.run Cookbook

Photo by Kevin Bhagat on Unsplash How to keep your Remix routes tidy - Cover

How to keep your Remix routes tidy
3 likes

By Fabio Vedovelli - GitHub.com/vedovelli

Created: January 26, 2022
Last update: January 26, 2022
⏳   2 min to read, not taking into consideration the code examples.

Remix is a server-side framework which makes use of React as the view layer. Newcomers could face some well-known challenges while building server-side applications: the route gets bloated with too much code and responsibility.

In a Remix application, routes are the point of entry for your features. They receive the request and decide on how to proceed. They are powerful and allow you to do many things: read the request and anything passed in the URL, make a request to different services, pass information down to the React component, mutate data and so on.

In an MVC application, the Controller (the C in MVC) performs this role and for years developers have struggled with putting too much pressure on it, making them hard to read and almost impossible to test.

A traditional mantra says: “- Your controllers should be thin. Your models should be thick”.

A rule of thumb is: keep your custom code as far away as possible from the framework.

But how far is enough? You can create services, APIs, types & interfaces, custom React components, all within the same project structure. Or you can get super modern and spread your code across multiple projects in a Monorepo.

The fact is: Remix should do only what Remix specializes in.

Let’s take a look at a concrete example.

Say we have a route that renders products. A first approach would be:

// /app/routes/products.tsx

import { json, LoaderFunction, useLoaderData } from "remix";
import { db, Product } from "~/util/db.server";

interface LoaderData {
  products: Product[];
}

export const loader: LoaderFunction = async () => {
  try {
    // 🚫 this route is accessing the persistence layer directly
    const products = await db.product.findMany(22);

    return json<LoaderData>({
      products,
    });
  } catch (error) {
    throw new Response("Could not load products at this time. Sorry.", {
      status: 500,
    });
  }
};

export default function () {
  const { products } = useLoaderData<LoaderData>();

  return (
    // 🚫 This route is full of JSX
    <div className="max-w-2xl mx-auto py-16 px-4 sm:py-24 sm:px-6 lg:max-w-7xl lg:px-8">
      <h2 className="sr-only">Products222</h2>
      <div className="grid grid-cols-1 gap-y-10 sm:grid-cols-2 gap-x-6 lg:grid-cols-3 xl:grid-cols-4 xl:gap-x-8">
        {products.map((product) => (
          <a key={product.id} href={`#`} className="group">
            <div className="w-full aspect-w-1 aspect-h-1 bg-gray-200 rounded-lg overflow-hidden xl:aspect-w-7 xl:aspect-h-8">
              <img
                src={product.image}
                alt={product.name}
                className="w-full h-full object-center object-cover group-hover:opacity-75"
              />
            </div>
            <h3 className="mt-4 text-sm text-gray-700">{product.name}</h3>
            <p className="mt-1 text-lg font-medium text-gray-900">
              ${product.price}
            </p>
          </a>
        ))}
      </div>
    </div>
  );
}

// 🚫 This component cannot be shared across routes
export const ErrorBoundary = () => <h3>Whoops!</h3>;

// 🚫 This one also can't be shared
export const CatchBoundary = () => <h3>Not found!</h3>;

Which in turn renders the list of products:

There are a couple of issues in this approach

1. The route is accessing the persistence layer directly;

2. The React component returned by the route is rendering too much JSX;

3. Error and catch boundaries are delivering pure JSX, which is prone to code duplication.

Now let’s look at a streamlined implementation:

// /app/routes/products.tsx

import { json, LoaderFunction, useLoaderData } from "remix";

// ⚠️ This file doesn't exist in this project. This is just an example
import { StoreApi, Products } from "~/features/Store";

// ⚠️ This file doesn't exist in this project. This is just an example
import { GeneralErrorBoundary, GeneralCatchBoundary } from "~/components";

interface LoaderData {
  // ✅ Abstract types and interfaces
  products: StoreApi.Types.Product[];
}

export const loader: LoaderFunction = async () => {
  try {
    // ✅ Abstract access to the persistence layer
    const products = await StoreApi.getProducts(22); 

    return json<LoaderData>({
      products,
    });
  } catch (error) {
    throw new Response("Could not load products at this time. Sorry.", {
      status: 500,
    });
  }
};

export default function () {
  const { products } = useLoaderData<LoaderData>();

  // ✅ Only your custom components in the route
  return <Products products={products} />; 
}

// ✅ Only your custom components in the route
export const ErrorBoundary = () => <GeneralErrorBoundary />; 

// ✅ Only your custom components in the route
export const CatchBoundary = () => <GeneralCatchBoundary />; 

We usually don’t test our routes directly. Of course, we should test the application in an integrated fashion (with Cypress, for instance) but not unit testing it: there is too much framework thingy going on in the routes.

When you abstract the code away from the framework, it gets way easier to unit test the smaller pieces. Not to mention, you can share code with several parts of the application, reducing the surface for bugs to appear.

Conclusion

It might seem an obvious topic, but for a newcomer to the server-side rabbit hole, these details are not immediately clear. We hope you find it useful.

Source-code: https://github.com/remix-cookbook/how-to-keep-your-remix-routes-tidy