How to build a React app using Remix (Part 2 of 3)

Summary
Note: This is Part 2 of 3 of our tutorial series about Remix Run. Read Part 1 here: How to build a React app using Remix

Reading time: 13 minutes
Date published
July 4, 2022

Introduction

Note: This is Part 2 of 3 of our tutorial series about Remix Run. Read Part 1 here: How to build a React app using Remix

Building on the foundation of our Remix knowledge from Part 1, this blog post aims to give you a better understanding of routes, forms, and errors in Remix.

In this post, we will cover:

  • Creating the logic to load a specific item using Remix Loader
  • Creating a Remix Form to POST data to the server
  • Creating Catch and Error Boundary for our Routes

In Part 1 of this series, we covered the following items:

  • What is Remix?
  • How to Set-up a new project using Remix
  • How to organize the root file
  • How to create routes
  • How to set up Prisma using Remix
  • How to load data using Remix

Access the final code from Part 1: https://github.com/ricardohsilva/remix-tutorial-1

Access the code for Part 2: https://github.com/ricardohsilva/remix-tutorial-1/tree/part-2

Creating a new file named $toyId.tsx inside routes folder.

Prefixing a file with $ will make your route dynamic. In that case, if we navigate to localhost:3000/1 or localhost:3000/2 (etc.) Remix is going to find the page and it will flag it as a valid path. The param name in this example will be toyId (The name of the file).

After creating this file, let’s make a simple test by adding a template that shows the text message as “Hello World” followed by the toyId.

// app/routes/$toyId.tsx
import { useParams } from "remix";

export default function ToyId() {
    const params = useParams();
    const toyId = params.toyId;
    return (
        <div style={{ marginTop: '2rem' }}>
            Hello World - {toyId}
        </div>
    );
}

And it’s working as expected! 😀

Creating the logic to load toy details

Our App is now accepting the ID of our toy. However, it’s not showing anything yet. We want to see the details of the product when we hit this new page. So, the first thing to do is to add a logic to load the right toy for the specific toyId.

To begin, let’s add a Remix function called loader.

The loader function is invoked by Remix on the server prior to rendering the selected route to allow it to load any data it needs to render its portion of the UI.  The loader function must return or throw a Response object. In Part 1 of this blog series, there is a better explanation and a sample on how to implement this loader function into your app. You can also use Remix Documentation as a reference.

The logic inside of this function is going to be very simple. We are going to get the param “toyId” to find the right toy in our database, and we are going to fetch the specific toy inside the loader function.

To get the results from this loader function, we are going to use a method called useLoaderData() from Remix.

Just a reminder: The param in this case  is“toyId”, the name of our file.
//app/routes/$toyId.tsx
import { json, LoaderFunction } from "remix";
import { db } from "~/db";

export let loader: LoaderFunction = async ({ params }) => {
    // It will find the toy that matches params.toyId.
    const toy = await db.toy.findUnique({
        where: {
            id: Number.isInteger(Number(params.toyId)) ? Number(params.toyId) : 0
        },
        include: {
            images: true,
            comments: true
        }
    });
    // It will return the toy details
    return json(toy);
};
...
...
...

export default function ToyId() {
    const toy = useLoaderData();
    return (
      <div style={{ marginTop: '2rem' }}>
            {toy.name} - ${toy.price}
			</div>
    );
}

Creating Catch Boundary for the route $toyId

Let’s say that we navigate to a page where the toyId is not valid. In that case, we need to catch this error and solve it, and also show a message to the user to explain what happened.

Remix has a nice feature called CatchBoundary. This CatchBoundary is a React component that renders whenever an action or loader throws a Response.

To register it is pretty simple. All you need to do is to add the following function inside of our page $toyId.

//app/routes/$toyId.tsx
import { CatchData } from "@remix-run/react/transition";
import { useCatch } from "remix";
export function CatchBoundary() {
    const {status, statusText, data}: CatchData = useCatch();
    return (
        {/* Add Any customization if you want. */}
				<div style={{ marginTop: '2rem' }}>
          {status} - {statusText} - {data.message}
	      </div>  
    );
}

useCatch data will return the following items:

  • data - Any Data that you have passed in your error
  • status - The Status code
  • statusText - The Status message

Inside of your loader, we can add logic to throw an error if the toyId does not exist.

//app/routes/$toyId.tsx
export let loader: LoaderFunction = async ({ params }) => {
    // It will find the toy that matches params.toyId.
    const toy = await db.toy.findUnique({
        where: {
            id: Number.isInteger(Number(params.toyId)) ? Number(params.toyId) : 0
        },
        include: {
            images: true,
            comments: true
        }
    });
    // It will return a message for the CatchBoundary if the toy does not exist.
    if (!toy) {
        throw json(
            { message: 'Something went wrong :(' },
            {
                status: 404
            }
        )
    }
    // It will return the toy details
    return json(toy);
};

Let’s take a look to see the result.

Note: If your route does not have a CatchBoundary registered, Remix will invoke the nearest parent CatchBoundary to handle the error by displaying the UI returned by the CatchHandler.

Styling the page Toy Details

Until now, our toy page is only going to show the product name and the price.

However, we would like to show a little bit more content. For it, we are going to add a few styles to the page.

//app/routes.$toyId.tsx
...
...
...

const StyledSpacer = styled.div`
    margin: 2rem;
`;

const StyledToyDetailsContainer = styled.div`
    display: flex;
    justify-content: space-between;
    height: 85vh;
`;

const StyledToyDetailsWrapper = styled.div`
    flex: 3;
    display: inherit;
    flex-direction: column;
`;

const StyledImageWrapper = styled.div`
    display: inherit;
    justify-content: center;
    gap: 0.5rem;
    margin: 0.5rem 0;
`

const StyledImageDisplayContainer = styled.div`
    height: 160px;
    width: 160px;
    max-width: 25vw;
    max-height: 25vw;
    border-radius: 10px;
    padding: 0.5rem;
`;

const StyledImage = styled.div`
    background-size: contain;
    height: 100%;
    width: 100%;
    background-repeat: no-repeat;
    background-position: center;
    transition: 0.25s;
`;

const StyledContentContainer = styled.div`
    max-height: 600px;
    flex: 1;
    align-items: center;
    display: inherit;
    flex-direction: column;
    justify-content: start;
`;

const StyledDivider = styled.div`
    height: 1px;
    background-color: #eeeeee;
    width: 100%;
`;

export default function ToyId() {
    const toy = useLoaderData();
    const [selectedImage, setSelectedImage] = useState<string>();
    useEffect(() => {
        if (!selectedImage) {
            if (toy.images) {
                setSelectedImage(toy.images[0].imageSrc);
            }
        }
    }, [toy, selectedImage, setSelectedImage]);

    const getSelectedItem = (itemSrc: string): string => {
        if (itemSrc === selectedImage) {
            return '1px solid black';
        } else {
            return '0px';
        }
    }
    return (
        <>
            <StyledSpacer></StyledSpacer>
            <StyledToyDetailsContainer>
                <StyledToyDetailsWrapper>
                    <StyledImageWrapper>
                        {toy.images?.map((image: any, index: number) => {
                            return (
                                <StyledImageDisplayContainer onClick={() => setSelectedImage(image.imageSrc)} key={index} style={{
                                    border: getSelectedItem(image.imageSrc),
                                }}  >
                                    <StyledImage style={{
                                        backgroundImage: `url(${image.imageSrc})`,
                                    }}>
                                    </StyledImage>
                                </StyledImageDisplayContainer>
                            )
                        })}
                    </StyledImageWrapper>
                    <StyledImage className="toy-details--image" style={{
                        backgroundImage: `url(${selectedImage})`
                    }}></StyledImage>
                    <StyledContentContainer>
                        <h3>{toy.name}</h3>
                        <h3>${toy.price}.00</h3>
                    </StyledContentContainer>
									  <StyledDivider></StyledDivider>
										<div>
                        <h2>Comments</h2>
                        {toy.comments?.map((item: any, index: number) =>
                            <p key={index} style={{ textAlign: "center" }}>{item.comment}</p>
                        )}
                    </div>
                </StyledToyDetailsWrapper>
            </StyledToyDetailsContainer >
        </>
    );
}
...
...

After applying the above code to our $toyId page, the layout will look like this:

Note: In this tutorial, we are not going to cover how to style a page or how to use some React functions.

The next steps

Before we continue our discussion about routes, let’s learn how to create nested routes.

So basically, the flow that we want to implement will be:

  • In the toy details page, we are going to add an input to add a comment;
  • As soon as the user adds a comment, we are going to show a nested page with that comment

Knowing that, let’s start implementing Remix forms for the comment.

Submitting data from forms in Remix.

Remix forms are something that really caught my eye. Remix takes us back to web fundamentals by leveraging HTML forms for collecting and submitting user input to the server, and it is really amazing.

For this tutorial, I’m going to implement an example of the form as method POST.

However, I’m going to give you a brief explanation about both types of forms: Forms that use GET and forms that use POST.

Method for POST:

Here is an example of how to use Remix Form with POST as the HTTP method.

1) In your component, add a Remix Form and specify method=”post”.

import { Form } from "remix";
<Form method="post">
  <input name="input-name" placeholder="Your Input" />
  <button type="submit">Post</button>
</Form>

2) Next, in the same file, export a function named action. (Adding an action function to your route allows it to handle inbound POST requests — along with PUT, PATCH, and DELETE requests).

import {
  ActionFunction,
} from "remix";

export const action: ActionFunction = async ({ request, params }) => {
	// Get the requested form.
  const form = await request.formData();

	// Get the value of our input named as "input-name"
	const inputItem = form.get("input-name");

	// handle the logic to save, change and etc...
	...
}

3) Inside of your function Action, add logic to save the new comment to the database.

Method for GET:

The method GET is a little bit different, but not that much.

Remix is always listening for any changes in your URL. So, every time that you change anything in your URL, the function loader will be called.

Here is one example of GET.

1) In your template, register Remix Form as GET.

import { Form } from "remix";
<Form method="get">
	<label>Search <input name="search" type="text" /></label>
	<button type="submit">Search</button>
</Form>

2) As soon as you hit the submit button, the address bar will be updated with the submitted parameters from your form in the query string of the URL. Consequently, your loader function will be called again to fetch the data based on your search.

Now that we got how Remix forms work, we are now able to create a form to add a new comment inside of our $toyId page.

Adding comments in the Toy Details

To create the comments, the first thing to do is to add a Form as POST inside of our page $toyId .

So, our template would be something like this:

//app/routes/$toyId.tsx
import { Form } from "remix";
...
...
return (
        <>
           ...
           ...
           ...
           <div>  
              <h2>Comments</h2>
                {toy.comments?.map((item: any, index: number) =>
                    <p key={index} style={{ textAlign: "center" }}>
                       {item.comment}
                    </p>
                )}
           </div>
           <StyledDivider></StyledDivider>
                <Form method="post">
                    <h2>Create a Comment to get a Deal</h2>
                    <input name="comment" placeholder="Comment..." />
                    <button type="submit">Post</button>
                </Form>
                </StyledToyDetailsWrapper>
            </StyledToyDetailsContainer >
        </>
    );

Right now, we have the Form implemented. However, it is still missing the logic to add a new comment.

To add this logic, let’s implement a Remix function named action.

// app/routes/$toyId
import { ActionFunction, redirect } from "remix";

export const action: ActionFunction = async ({ request, params }) => {
    // Get the form
		const form = await request.formData();

		// Get the value of the Input named as Comment
    const comment = form.get("comment");

		// Register the new comment in our Prisma Database
    if (comment) {
        const data = {
            comment: comment.toString(),
            toyId: Number(params.toyId),
            createdAt: new Date(),
            updatedAt: new Date(),
        }

        await db.comment.create({ data: data })
    }

		// Redirect to refresh the page with the new toy.
    return redirect(`/${params.toyId}`);
}

With this done, we should now be able to add a new comment to our page.

Remix Nested Routes inside Toy Details Page

Now that we have implemented the comments form inside of our Toy Details, we are now able to add the final piece of this post. Here’s where we get to use nested pages.

Imagine the following example:

There are two pages:

Page 1) /toyId:

This page is going to show the details of the product.

Page 2) /toyId/promo:

This second page will still show the details of the product, but we also want to show if the product has a discount.

To see what I mean, take a look at the image below:

To summarize:

  • The Baby Yoda images and comments are the page /toyId .
  • The extra image that appears under the comments is going to be our new page with the URL as /toyId/promo .

Organizing project files to support Nested Routes

To be able to make the nested routes work, we will need to create a new folder inside of our routes directory with the same name as our parent route.

In this case:

/$toyId/promo

  • $toyId is going to be the parent of our nested route.
  • promo is going to be our nested route in this case.

It’s necessary to create this folder with the same name as the parent route to tell Remix that all the routes inside of this folder in linked with $toyId.

In this case, we will need to create a new folder named $toyId.

After that, let’s add a new file inside the folder $toyId. The file name will be promo.tsx.

Finally, let’s add some simple code insidepromo.tsx saying ‘Hello Nested Page’.

//app/routes/$toyId/promo.tsx
export default function Promo() {
    return (
        <>
            Hello Nested Page
        </>
    );
}

After all of these steps, let’s see what happens if we navigate to $toyId/promo.

Both pages are exactly the same.

But where is our code saying ‘Hello Nested Page’?

At this point, Remix does not know where to place the code saying ‘Hello Nested Page’. So it’s not going to be rendered.

It is necessary to tell Remix where we should show this nested page inside of our parent page. And for that, Remix has a tool named <Outlet /> that will help.

Go back to the file $toyId.tsx and let’s add this Outlet above the comments Form.

// app/routes/$toyId.tsx
import { Outlet } from "remix";
...
...
...

<StyledDivider></StyledDivider>
<Outlet />
<Form method="post">
    <h2>Create a Comment to get a Deal</h2>
    <input name="comment" placeholder="Comment..." />
    <button type="submit">Post</button>
</Form>

In that way, the message ‘Hello Nested Page’ will be placed above the form.

Let’s have another try.

As you can see, when we navigate to the page $toyId/promo , Remix is rendering our nested page.

Adding some styles to our Nested Page

In this section, we will be adding some styles to our Nested Page.

Instead of showing ‘Hello Nested Page’, let’s add an Image and a percentage discount for the specific toy.

The first step is just adding some HTML and styles.

// app/routes/$toyId/promo
import styled from "@emotion/styled";

const StyledPromo = styled.div`
    min-height: 12rem;
    margin-top: 1rem;
    width: 100%;
    position:relative;
`;

const StyledPromoImage = styled.div`
    background-position: center;
    background-size: contain;
    background-repeat: no-repeat;
    width: inherit;
    height: 100%;
    display: flex;
    flex-direction: column;
    justify-content: space-between;
    background-image: url(https://images.unsplash.com/photo-1551323879-b2f626997e22?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80)
`;

const StyledPromoText = styled.div`
    position: absolute;
    bottom: -40px;
    color: #000;
    background-color: white;
    height: 80px;
    width: 80px;
    border-radius: 50%;
    display: flex;
    align-self:center;
    align-items: center;
    justify-content: center;
    box-shadow: 0 0.313em 0.938em rgb(0 0 0 / 50%);
`;

export default function Promo() {
    return (
        <StyledPromo>
            <StyledPromoImage>
                <StyledPromoText>
                    <p>15% Off!!</p>
                </StyledPromoText>
            </StyledPromoImage>
        </StyledPromo>
    );
}

As soon as you have applied the styles, our nested page should be something similar to:

That's it! In the next section, we will navigate to our page /promo as soon as the user creates a new comment.

To navigate to our Nested page after a comment has been created is simple.

All we have to do is add our Action function (at app/routes/$toyId.tsx) to the redirect method to /${params.toyId}/promo .

Basically, the new code will be something like this:

// app/routes/$toyId.tsx
export const action: ActionFunction = async ({ request, params }) => {
    const form = await request.formData();
		...
    ...
    
    return redirect(`/${params.toyId}/promo`);  // => Just this change here
}

After that, as soon as you create a new comment, you will be redirected to the new Page named as /promo . Consequently, the image with the discount will appear.

Adding Error Boundary to our Nested Page

Error Boundary is a nice feature from Remix, and it is really handy to control errors in your nested page.

By the creators:

Remix will automatically catch errors and render the nearest error boundary for errors thrown while: rendering in the browser, rendering on the server, in a loader during the initial server rendered document request, in an action during the initial server rendered document request, in a loader during a client-side transition in the browser (Remix serializes the error and sends it over the network to the browser), and in an action during a client-side transition in the browser.

Imagine that when you create a new comment, some error occurs in our nested page that’s not related to a Response. However, it is related to some coding error or an unexpected situation that makes your app crash.

In that case, we would like to show the user what happened… But we don’t want to crash the whole page because of it.

So basically what we want is:

  • When the page is working fine, show the deal
  • When the page catches any error, show a message saying ‘Something Went Wrong’

For this, Remix has a function named ErrorBoundary that will work in a very similar way to the CatchBoundary. However, it will be listening for any error on your page.

In our promo.tsx , let's add the following Remix function:

// app/routes/$toyId/promo.tsx

...
...
...
export function ErrorBoundary() {
    return (
        <StyledPromo>
            <div style={{backgroundColor:'red'}}>
                <p>Something Went Wrong :(</p>
            </div>
        </StyledPromo>
    );
}

For now, this Error is not going to work because our app is pretty simple and it’s not going to crash.

However, let’s make a logic that simulates an error.

This simulation is going to live inside of the loader function in our promo.tsx file.

// app/routes/$toyId/promo.tsx
export let loader: LoaderFunction = async ({ params }) => {
    const number = Math.random();
    if (number < 0.5) {
        throw new Error();
    }
    return {};
};

Basically, if the number is fewer than 0.5, we are going to throw a new error message. Otherwise, our nested page is going to show the deal.

Really cool, right?

In our example, we made it simple. However, you are able to create a new whole page when an error occurs.

Just for curiosity’s sake, let’s make this same simulation without the ErrorBoundary function.

As you can see, without the ErrorBoundary, the whole app is going to crash.

Conclusion

In this blog post, we covered some extra features from Remix, focusing on adding a new page that will display the details of the toy.

This new page also has a Nested route to display the discount that the user can receive for the specific toy. To add this logic, we had to reorganize our route files and folder, implementing the <Outlet /> the method from Remix to determine where the nested route should render.

We also took a quick view into how Remix handles Forms and how to create Catch and Error Boundaries.

Remix is by far my favourite framework for React because it’s really simple to use and easy to learn.

In the next post in this series, we’ll go even deeper to take a look at how to configure a cache for each page of our app.

For now, enjoy Remix and I’ll see you in the next post!

Tags

See what Ranglers are writing about on our blog