@sudocode_

How to think in Remix

June 27, 2023 / 19 min read

Last Updated: June 27, 2023

When developers are starting a new project, we often think about what libraries/tools we need to use to simplify our development process. We might think about what state management library we want to use, what styling library we want to use, what testing library we want to use, etc. We might even think about what framework we want to use. This is a great way to start a project, but it's not the only way.

Most developers have this Mental Model of how they think about building web applications. State management, working with form, caching data, etc are tools/libraries we often think about whiles building an application/fullstack application.

When I first delved into the world of Remix, I brought along my existing mental model of building web applications. However, Remix challenged my preconceptions and pushed me to reconsider my approach. In this article, I'll share my evolving perspective on web application development with Remix and how it has influenced my thinking.

Initially, I had numerous questions swirling in my mind. How would React Query and React Form Hook fit into the Remix ecosystem? How can I seamlessly incorporate loading spinners and other UI components? The Remix community proved invaluable in addressing these queries, providing valuable insights and guidance along the way.

As I immersed myself deeper into Remix, I realized the need to unlearn some of my established practices and embrace new ways of thinking. Remix introduced me to a paradigm shift in web application development that challenged traditional patterns. It emphasized the importance of server-side rendering, modularity, and code reusability.

During the development of a recent project for a client, I had the opportunity to deeply explore Remix. I was particularly drawn to Remix because of its simplicity and its foundation built on top of React.

As I embarked on the project, I found Remix to be an excellent fit for my needs. Its seamless integration with React allowed me to leverage my existing knowledge and skills while benefiting from Remix's unique features and capabilities.

The worry

Creating Event

At one point in the development journey, I contemplated using the React Context API for creating a wizard form and transmitting the saved data to the server at the conclusion of each step. However, during my thought process, I stumbled upon a fascinating discussion on GitHub that completely shifted my perspective. The discussion centered around the Remix way of handling such scenarios, prompting me to reconsider my approach and align with the principles and practices of Remix.

What's the Remix way?

Saving all the data in session storage is the Remix way without reaching out for a state or React Context API.

wizard-session.server.ts

1
import { createCookieSessionStorage, redirect } from "@remix-run/node";
2
3
export type WizardType = "event" | "coupon";
4
5
type WizardSessionBase<Type extends WizardType> = {
6
type: Type;
7
};
8
9
export type EventWizardSession = WizardSessionBase<"event"> & {
10
id?: string;
11
slug?: string;
12
name: string;
13
date: string;
14
time: string;
15
price: string;
16
- // other fields
17
}

I created a WizardType which is a union type of two string literal types: event and coupon. WizardSessionBase which has a single property type of type Type. The Type parameter is constrained to be a subtype of WizardType. This means that Type can only be event or coupon.

The type EventWizardSession which extends WizardSessionBase<"event">. This means that EventWizardSession has a type property of event. It also adds additional properties specific to an event wizard session, such as id, slug, name, date, time, and price.

By using a generic type WizardSessionBase, it allows for code reuse and consistency across different types of wizard sessions. By constraining the Type parameter to be a subtype of WizardType, it ensures that only valid wizard types can be used.

By extending WizardSessionBase<"event">, EventWizardSession inherits the type property with a value of event. This ensures that all event wizard sessions have a type property with a value of event, which can be used to differentiate between different types of wizard sessions.

wizard-session.server.ts

1
export type CouponWizardSession = WizardSessionBase<"coupon"> & {
2
amount: number | null;
3
event: {
4
name: string;
5
date: string;
6
time: string;
7
location: string;
8
photos: string[];
9
};
10
};
11
12
type WizardSession = EventWizardSession | CouponWizardSession;
13
14
const SESSION_KEY = "eventSession";

CouponWizardSession is a type that extends WizardSessionBase<"coupon">. This means that CouponWizardSession has a type property of coupon. It also adds additional properties specific to a coupon wizard session, such as amount and event. The event property is an object that contains properties such as name, date, time, location, and photos.

WizardSession is a union type of EventWizardSession and CouponWizardSession. This means that a variable of type WizardSession can be either an EventWizardSession or a CouponWizardSession.

The last line defines a constant SESSION_KEY with a value of eventSession. This constant is likely used to store the wizard session in some sort of storage, such as local storage or a cookie.

wizard-session.server.ts

1
+ const sessionStorage = createCookieSessionStorage({
2
+ cookie: {
3
+ name: "__eventSession",
4
+ httpOnly: true,
5
+ path: "/events",
6
+ sameSite: "lax",
7
+ secrets: ["s3cr3t"],
8
+ secure: process.env.NODE_ENV === "production",
9
+ },
10
+ });

This code defines a constant sessionStorage that is assigned the result of calling a function createCookieSessionStorage with an object argument that contains various properties for configuring a cookie-based session storage.

The cookie property is an object that contains several properties:

  • ArrowAn icon representing an arrow
    name: the name of the cookie, which is set to __eventSession
  • ArrowAn icon representing an arrow
    httpOnly: a boolean value that determines whether the cookie is accessible only through HTTP(S) requests, which is set to true
  • ArrowAn icon representing an arrow
    path: a string that specifies the path for which the cookie is valid, which is set to /events
  • ArrowAn icon representing an arrow
    sameSite: a string that specifies the SameSite attribute for the cookie, which is set to "lax"
  • ArrowAn icon representing an arrow
    secrets: an array of strings that are used to sign the cookie, which is set to ["s3cr3t"]
  • ArrowAn icon representing an arrow
    secure: a boolean value that determines whether the cookie is only sent over HTTPS, which is set to true if the NODE_ENV environment variable is set to "production", and false otherwise.

The createCookieSessionStorage function likely returns an object that provides methods for storing and retrieving session data using the cookie-based storage mechanism.

wizard-session.server.ts

1
async function getSession(request: Request) {
2
const cookie = request.headers.get("Cookie");
3
return sessionStorage.getSession(cookie);
4
}
5
6
export async function getWizardSession<T>(request: Request) {
7
const session = await getSession(request);
8
return session.get(SESSION_KEY) as T;
9
}
10
11
export async function getMaybeWizardSession<T>(request: Request) {
12
const wizardSession = await getWizardSession<T>(request);
13
return wizardSession || null;
14
}
15
16
export async function commitWizardSession(
17
request: Request,
18
wizardSession: Partial<WizardSession> | null
19
) {
20
const session = await getSession(request);
21
22
// merge the existing session with the new data
23
if (wizardSession) {
24
session.set(SESSION_KEY, {
25
...(session.get(SESSION_KEY) || {}),
26
...wizardSession,
27
});
28
} else {
29
session.set(SESSION_KEY, null);
30
}
31
32
return sessionStorage.commitSession(session);
33
}
34
35
export async function destroyWizardSession(request: Request) {
36
const session = await getSession(request);
37
38
return redirect("/", {
39
headers: {
40
"Set-Cookie": await sessionStorage.destroySession(session),
41
},
42
});
43
}

The getSession function takes a Request object and returns a Promise that resolves to a session object retrieved from a session storage mechanism. The session object is retrieved using a cookie value from the Cookie header of the request.

The getWizardSession function takes a Request object and returns a Promise that resolves to a wizard session object of type T. The wizard session object is retrieved from the session object using a key SESSION_KEY. The SESSION_KEY constant is likely defined elsewhere in the codebase.

The getMaybeWizardSession function is similar to getWizardSession, but it returns null if the wizard session object is not found in the session object.

The commitWizardSession function takes a Request object and a partial wizard session object, and updates the session object with the new data. If the partial wizard session object is null, then the session object is set to null. The updated session object is then committed to the session storage mechanism.

The destroyWizardSession function takes a Request object and destroys the session object in the session storage mechanism. It then redirects the user to the root URL of the application.

Then I created a hook that utilizes the wizard session

use-outlet-handle.ts

1
import { useMatches } from "@remix-run/react";
2
import type { WizardType } from "~/sessions/wizard-session.server";
3
4
export type WizardHandle<T extends WizardType> = {
5
key: T;
6
};
7
8
export function useOutletHandle<T extends WizardHandle<WizardType>>(
9
key: T["key"]
10
) {
11
const handles = useMatches()
12
.filter((match) => match.handle && match.handle.key === key)
13
.map((match) => match.handle);
14
15
if (handles.length === 0) {
16
throw new Error(`This route should export a handle with key ${key}`);
17
}
18
19
return handles as T[];
20
}

WizardHandle which has a single property key of type T. The T parameter is constrained to be a subtype of WizardType. WizardType is likely defined elsewhere in the codebase as a union type of two string literal types: event and coupon.

The useOutletHandle function is a React hook that takes a key parameter of type T["key"]. It uses the useMatches hook from the @remix-run/react package to get an array of matches for the current route. It filters the matches to only include those that have a handle property with a key property that matches the key parameter. It then maps the matches to an array of handles.

If there are no handles in the array, the function throws an error with a message that includes the key parameter. Otherwise, it returns the array of handles as type T[].

This code is likely used in a Remix Run application to get a handle for a specific wizard type. The key parameter is likely a string literal type of event or coupon. The useMatches hook is likely used to get the current route and its associated handle. The useOutletHandle function is likely used to get the handle for a specific wizard type, which can then be used to render the appropriate wizard component.

Using the hook with Remix handle API.

events.new.tsx

1
export type EventWizardHandle = WizardHandle<"event"> & {
2
title: string;
3
step: string;
4
submitButton: React.ReactElement;
5
};

EventWizardHandle which extends WizardHandle<"event">. This means that EventWizardHandle has a type property of event. It also adds additional properties specific to an event wizard handle, such as title, step, and submitButton.

The WizardHandle type is likely defined elsewhere in the codebase. It is a generic type that takes a type parameter Type which is constrained to be a subtype of WizardType. WizardType is likely defined elsewhere in the codebase as well, and is a union type of two string literal types: event and coupon.

By using a generic type WizardHandle, it allows for code reuse and consistency across different types of wizard handles. By constraining the Type parameter to be a subtype of WizardType, it ensures that only valid wizard types can be used.

Before we continue, let's take a look at routes structure for the event pages.

Routes structure

1
|-- routes
2
| |-- events
3
| |-- events.new
4
| |-- events.new.basic-info
5
| |-- events.new.category
6
| |-- events.new.locations
7
| |-- events.new.misc
8
| |-- events.new.photos
9
| |-- events.new.status
10
| |-- events.new.tickets
11
| |-- events.new.preview

The file path is relative to the root directory of the project. It shows a directory structure with a routes directory, which likely contains the routes for the web application. Within the routes directory, there is an events directory, which likely contains the routes for events-related functionality.

The remaining directories within the events directory are likely sub-routes for creating a new event. The events.new directory is likely the main route for creating a new event, and the remaining directories are likely child-routes for different steps in the event creation process, such as basic-info, category, locations, misc, photos, status, tickets, and preview.

events.new.basic-info.tsx

1
// routes/events.tsx
2
import { Outlet } from "@remix-run/react";
3
4
export default function EventsScreen() {
5
return <Outlet />;
6
}

The Outlet component is used in Remix Run applications to render the appropriate component for the current route. It is typically used as the root component for a page or screen.

In this code, the EventsScreen component is likely used as the root component for the events-related routes in the application. When a user navigates to an events-related route, the Outlet component will render the appropriate component for that route.

events.new.basic-info.tsx

1
// routes/events.new.tsx
2
import type { WizardHandle } from "~/hooks/use-outlet-handle";
3
import { useOutletHandle } from "~/hooks/use-outlet-handle";
4
5
export type EventWizardHandle = WizardHandle<"event"> & {
6
title: string;
7
step: string;
8
submitButton: React.ReactElement;
9
};
10
11
const EventsNew = () => {
12
const { title, step, submitButton } =
13
useOutletHandle<EventWizardHandle>("event")[0];
14
15
return (
16
<div>
17
<div>{title}</div>
18
<Outlet />
19
{step === "basic-info" && <BasicInfo />}
20
<div>{submitButton}</div>
21
</div>
22
);
23
}

The useOutletHandle hook is used to get the handle for the current event wizard. The [0] index is used to get the first handle in the array, which is likely the only handle for the current route.

The title, step, and submitButton properties are destructured from the handle and used to render the appropriate UI for the event wizard. The title property is the title of the current step in the wizard. The step property is the current step in the wizard, and is used to conditionally render the appropriate component. The submitButton property is the submit button in the wizard.

The Outlet component is used to render the appropriate component for the current route. It is likely used to render the appropriate component for each step in the event wizard.

Now that we have a better understanding of the codebase, let's take a look at the code for the event wizard. Let's start with form validation.

events.new.basic-info.tsx

1
import { z } from "zod";
2
import { withZod } from "@remix-validated-form/with-zod";
3
4
export const validator = withZod(
5
z.object({
6
nextStep: z.string().nonempty("Next step is required"),
7
name: z.string().nonempty("Event name is required"),
8
date: z.string().nonempty("Event date is required"),
9
time: z.string().nonempty("Event time is required"),
10
price: z
11
.string()
12
.nonempty("Event price is required")
13
.min(0, "Price must be greater than 0"),
14
description: z.string().nonempty("Event description is required"),
15
})
16
);

The z function is used to define a schema for validating data. It is a function that takes an object representing the schema and returns a ZodSchema object that can be used to validate data.

The withZod function is used to create a higher-order component that wraps a form component and provides validation using the schema defined with z. It is a function that takes a ZodSchema object and returns a function that takes a form component and returns a new component that wraps the form component with validation.

The validator constant is used to export the schema and validation function for use in other parts of the application. It is defined as the result of calling withZod with an object representing the schema for validating event data. The schema includes properties for nextStep, name, date, time, price, and description, each of which is defined using the z.string() function with additional validation rules.

Next, let's look into how useful the handle API is for creating a wizard.

events.new.basic-info.tsx

1
import type { EventWizardHandle } from "~/routes/events.new";
2
import Button from "~/components/Button";
3
4
export const handle: EventWizardHandle = {
5
key: "event",
6
title: "Basic Info",
7
step: "basic-info",
8
submitButton: <Button type="submit" form="basic-info" title="Next" />,
9
};

The handle is defined as an object with properties for key, title, step, and submitButton. The key property is a unique identifier for the step. The title property is the title of the step. The step property is the name of the step, which is used to determine which component to render. The submitButton property is the submit button for the step, and is rendered as a Button component with a type of "submit", a form ID of "basic-info", and a title of "Next".

To achieve our goal of displaying previously saved data in a form when visiting a specific route, we need to implement a data retrieval mechanism in our application. This ensures that the relevant data is fetched and seamlessly populated within the form.

events.new.basic-info.tsx

1
import type { LoaderArgs } from "@remix-run/server-runtime";
2
import { json } from "@remix-run/node";
3
import type { EventWizardSession } from "~/sessions/wizard-session.server";
4
import {
5
getMaybeWizardSession,
6
} from "~/sessions/wizard-session.server";
7
8
export async function loader({ request }: LoaderArgs) {
9
const eventWizardSession = await getMaybeWizardSession<EventWizardSession>(
10
request
11
);
12
13
return json(eventWizardSession);
14
}

The eventWizardSession object is obtained by calling the getMaybeWizardSession function, which is defined in another module called wizard-session.server. This function takes a request object as its argument, which is also defined in the @remix-run/server-runtime module. The getMaybeWizardSession function returns a promise that resolves to an object of type EventWizardSession or null.

The json function is a helper function from the @remix-run/node module that serializes the eventWizardSession object to JSON format and sets the appropriate headers for the response.

Now, let's create a an event wizard session to store the data for the event wizard.

events.new.basic-info.tsx

1
import type { ActionArgs } from "@remix-run/server-runtime";
2
import { redirect } from "@remix-run/node";
3
import {
4
commitWizardSession,
5
} from "~/sessions/wizard-session.server";
6
import type { EventWizardSession } from "~/sessions/wizard-session.server";
7
8
export async function action({ request }: ActionArgs) {
9
const formData = await request.formData();
10
11
const { name, date, time, description, price, nextStep } = Object.fromEntries(
12
formData
13
) as Pick<
14
EventWizardSession,
15
"name" | "date" | "time" | "price" | "description"
16
> & {
17
nextStep: string;
18
};
19
20
return redirect(`/events/new/${nextStep}`, {
21
headers: {
22
"Set-Cookie": await commitWizardSession(request, {
23
name,
24
date,
25
time,
26
description,
27
price,
28
}),
29
},
30
});
31
}

The action function is used to handle form submissions in the event creation process.

  • ArrowAn icon representing an arrow
    The formData is called on the request object to parse the form data submitted by the user.
  • ArrowAn icon representing an arrow
    The form data is then extracted and assigned to variables using destructuring.
  • ArrowAn icon representing an arrow
    The Object.fromEntries function is used to convert the form data into an object.
  • ArrowAn icon representing an arrow
    The redirect function is called to redirect the user to the next step in the event creation process.
  • ArrowAn icon representing an arrow
    The nextStep variable is used to construct the URL for the next step.
  • ArrowAn icon representing an arrow
    The commitWizardSession function is called to update the eventWizardSession object in the server session with the form data.
  • ArrowAn icon representing an arrow
    The Set-Cookie header is set to the updated eventWizardSession object.

The action function returns a response object with the redirect function and the Set-Cookie header.

Now, let's bring it all together and create a wizard session.

events.new.basic-info.tsx

1
import React from "react";
2
import type { V2_MetaFunction } from "@remix-run/react";
3
import { useLoaderData } from "@remix-run/react";
4
import { Stack } from "@chakra-ui/react";
5
import type { ActionArgs, LoaderArgs } from "@remix-run/server-runtime";
6
import { json, redirect } from "@remix-run/node";
7
import { ValidatedForm } from "remix-validated-form";
8
import { z } from "zod";
9
import { withZod } from "@remix-validated-form/with-zod";
10
import FormInput from "~/components/Form/FormInput";
11
import FormNumber from "~/components/Form/FormNumber";
12
import FormTextarea from "~/components/Form/FormTextarea";
13
import type { EventWizardHandle } from "~/routes/events.new";
14
import Button from "~/components/Button";
15
import type { EventWizardSession } from "~/sessions/wizard-session.server";
16
import {
17
commitWizardSession,
18
getMaybeWizardSession,
19
} from "~/sessions/wizard-session.server";
20
21
export const validator = withZod(
22
z.object({
23
nextStep: z.string().nonempty("Next step is required"),
24
name: z.string().nonempty("Event name is required"),
25
date: z.string().nonempty("Event date is required"),
26
time: z.string().nonempty("Event time is required"),
27
price: z
28
.string()
29
.nonempty("Event price is required")
30
.min(0, "Price must be greater than 0"),
31
description: z.string().nonempty("Event description is required"),
32
})
33
);
34
35
export const handle: EventWizardHandle = {
36
key: "event",
37
title: "Basic Info",
38
step: "basic-info",
39
submitButton: <Button type="submit" form="basic-info" title="Next" />,
40
};
41
42
export const meta: V2_MetaFunction = () => [
43
{ title: `Create Event ${handle.title} Step | Culture Management Group` },
44
];
45
46
export async function loader({ request }: LoaderArgs) {
47
const eventWizardSession = await getMaybeWizardSession<EventWizardSession>(
48
request
49
);
50
51
return json(eventWizardSession);
52
}
53
54
export async function action({ request }: ActionArgs) {
55
const formData = await request.formData();
56
57
const { name, date, time, description, price, nextStep } = Object.fromEntries(
58
formData
59
) as Pick<
60
EventWizardSession,
61
"name" | "date" | "time" | "price" | "description"
62
> & {
63
nextStep: string;
64
};
65
66
return redirect(`/events/new/${nextStep}`, {
67
headers: {
68
"Set-Cookie": await commitWizardSession(request, {
69
name,
70
date,
71
time,
72
description,
73
price,
74
}),
75
},
76
});
77
}
78
79
const BasicInfo = () => {
80
const data = useLoaderData<typeof loader>();
81
82
return (
83
<ValidatedForm
84
validator={validator}
85
id="basic-info"
86
method="post"
87
defaultValues={data as any}
88
>
89
<Stack spacing={6}>
90
<FormInput
91
label="Whats the name of this event?"
92
placeholder="Event name"
93
name="name"
94
/>
95
<FormInput
96
label="When is this event happening?"
97
type="date"
98
name="date"
99
/>
100
<FormInput label="What time does this event?" type="time" name="time" />
101
<FormNumber
102
label="How much does this ticket cost?"
103
name="price"
104
min={100}
105
step={50}
106
/>
107
<FormTextarea label="What is this event about?" name="description" />
108
</Stack>
109
110
<input type="hidden" name="nextStep" value="lineups" />
111
</ValidatedForm>
112
);
113
};
114
115
export default BasicInfo;

The component also uses the ValidatedForm component from the remix-validated-form module to validate the form input. The validator function defines the validation schema using the zod library.

The form has several input fields for the user to fill in, such as the name of the event, the date and time of the event, the price of the ticket, and a description of the event. Each input field is defined using a FormInput, FormNumber, or FormTextarea component from the remix-ui module.

When the form is submitted, the action function is called. This function extracts the form data and updates the eventWizardSession object in the server session. It then redirects the user to the next step in the event creation process.

The input element with type="hidden" is used to store the value of the next step in the event creation process. This value is passed to the action function when the form is submitted.

You should know!

In this article, I've shared my journey of building a wizard session in Remix. I've also shared some of the key takeaways from my experience.

  • ArrowAn icon representing an arrow
    Remix's server-side rendering approach makes it easy to create a wizard session.
  • ArrowAn icon representing an arrow
    The eventWizardSession object is stored in the server session.
  • ArrowAn icon representing an arrow
    The eventWizardSession object is updated using the commitWizardSession function.
  • ArrowAn icon representing an arrow
    The eventWizardSession object is retrieved using the getMaybeWizardSession function.
  • ArrowAn icon representing an arrow
    The action function is used to handle form submissions in the event creation process.
  • ArrowAn icon representing an arrow
    The action function uses the redirect function to redirect the user to the next step in the event creation process.
  • ArrowAn icon representing an arrow
    The action function uses the Set-Cookie header to update the eventWizardSession object in the server session.

Conclusion

In conclusion, my mental model of web application development underwent a transformation through my experience with Remix. I had to unlearn certain practices and embrace Remix's unique approach. By emphasizing server-side rendering, modularity, and extensibility, Remix challenged me to think differently and helped me build web applications that are performant, maintainable, and scalable. In this article, I've shared my journey and insights, and I hope it sparks inspiration for others to explore the exciting world of Remix.

If you need help with your next project, get in touch with me. I'd love to hear from you!

Liked this article? Share it with a friend on Twitter or contact me let's start a new project. Have a question, feedback or simply wish to contact me privately? Shoot me a DM and I'll always be available to respond to your messages.

Have a wonderful day.

– Felix

When developers are starting a new project, we often think about what libraries/tools we need to use to simplify our development process.