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
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
1import { createCookieSessionStorage, redirect } from "@remix-run/node";23export type WizardType = "event" | "coupon";45type WizardSessionBase<Type extends WizardType> = {6type: Type;7};89export type EventWizardSession = WizardSessionBase<"event"> & {10id?: string;11slug?: string;12name: string;13date: string;14time: string;15price: string;16- // other fields17}
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
1export type CouponWizardSession = WizardSessionBase<"coupon"> & {2amount: number | null;3event: {4name: string;5date: string;6time: string;7location: string;8photos: string[];9};10};1112type WizardSession = EventWizardSession | CouponWizardSession;1314const 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:
- name: the name of the cookie, which is set to
__eventSession
- httpOnly: a boolean value that determines whether the cookie is accessible only through HTTP(S) requests, which is set to true
- path: a string that specifies the path for which the cookie is valid, which is set to /events
- sameSite: a string that specifies the SameSite attribute for the cookie, which is set to "lax"
- secrets: an array of strings that are used to sign the cookie, which is set to
["s3cr3t"]
- 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
1async function getSession(request: Request) {2const cookie = request.headers.get("Cookie");3return sessionStorage.getSession(cookie);4}56export async function getWizardSession<T>(request: Request) {7const session = await getSession(request);8return session.get(SESSION_KEY) as T;9}1011export async function getMaybeWizardSession<T>(request: Request) {12const wizardSession = await getWizardSession<T>(request);13return wizardSession || null;14}1516export async function commitWizardSession(17request: Request,18wizardSession: Partial<WizardSession> | null19) {20const session = await getSession(request);2122// merge the existing session with the new data23if (wizardSession) {24session.set(SESSION_KEY, {25...(session.get(SESSION_KEY) || {}),26...wizardSession,27});28} else {29session.set(SESSION_KEY, null);30}3132return sessionStorage.commitSession(session);33}3435export async function destroyWizardSession(request: Request) {36const session = await getSession(request);3738return redirect("/", {39headers: {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
1import { useMatches } from "@remix-run/react";2import type { WizardType } from "~/sessions/wizard-session.server";34export type WizardHandle<T extends WizardType> = {5key: T;6};78export function useOutletHandle<T extends WizardHandle<WizardType>>(9key: T["key"]10) {11const handles = useMatches()12.filter((match) => match.handle && match.handle.key === key)13.map((match) => match.handle);1415if (handles.length === 0) {16throw new Error(`This route should export a handle with key ${key}`);17}1819return 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
1export type EventWizardHandle = WizardHandle<"event"> & {2title: string;3step: string;4submitButton: 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|-- routes2| |-- events3| |-- events.new4| |-- events.new.basic-info5| |-- events.new.category6| |-- events.new.locations7| |-- events.new.misc8| |-- events.new.photos9| |-- events.new.status10| |-- events.new.tickets11| |-- 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.tsx2import { Outlet } from "@remix-run/react";34export default function EventsScreen() {5return <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.tsx2import type { WizardHandle } from "~/hooks/use-outlet-handle";3import { useOutletHandle } from "~/hooks/use-outlet-handle";45export type EventWizardHandle = WizardHandle<"event"> & {6title: string;7step: string;8submitButton: React.ReactElement;9};1011const EventsNew = () => {12const { title, step, submitButton } =13useOutletHandle<EventWizardHandle>("event")[0];1415return (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
1import { z } from "zod";2import { withZod } from "@remix-validated-form/with-zod";34export const validator = withZod(5z.object({6nextStep: z.string().nonempty("Next step is required"),7name: z.string().nonempty("Event name is required"),8date: z.string().nonempty("Event date is required"),9time: z.string().nonempty("Event time is required"),10price: z11.string()12.nonempty("Event price is required")13.min(0, "Price must be greater than 0"),14description: 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
1import type { EventWizardHandle } from "~/routes/events.new";2import Button from "~/components/Button";34export const handle: EventWizardHandle = {5key: "event",6title: "Basic Info",7step: "basic-info",8submitButton: <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
1import type { LoaderArgs } from "@remix-run/server-runtime";2import { json } from "@remix-run/node";3import type { EventWizardSession } from "~/sessions/wizard-session.server";4import {5getMaybeWizardSession,6} from "~/sessions/wizard-session.server";78export async function loader({ request }: LoaderArgs) {9const eventWizardSession = await getMaybeWizardSession<EventWizardSession>(10request11);1213return 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
1import type { ActionArgs } from "@remix-run/server-runtime";2import { redirect } from "@remix-run/node";3import {4commitWizardSession,5} from "~/sessions/wizard-session.server";6import type { EventWizardSession } from "~/sessions/wizard-session.server";78export async function action({ request }: ActionArgs) {9const formData = await request.formData();1011const { name, date, time, description, price, nextStep } = Object.fromEntries(12formData13) as Pick<14EventWizardSession,15"name" | "date" | "time" | "price" | "description"16> & {17nextStep: string;18};1920return redirect(`/events/new/${nextStep}`, {21headers: {22"Set-Cookie": await commitWizardSession(request, {23name,24date,25time,26description,27price,28}),29},30});31}
The action function is used to handle form submissions in the event creation process.
- The formData is called on the request object to parse the form data submitted by the user.
- The form data is then extracted and assigned to variables using destructuring.
- The Object.fromEntries function is used to convert the form data into an object.
- The redirect function is called to redirect the user to the next step in the event creation process.
- The nextStep variable is used to construct the URL for the next step.
- The commitWizardSession function is called to update the eventWizardSession object in the server session with the form data.
- 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
1import React from "react";2import type { V2_MetaFunction } from "@remix-run/react";3import { useLoaderData } from "@remix-run/react";4import { Stack } from "@chakra-ui/react";5import type { ActionArgs, LoaderArgs } from "@remix-run/server-runtime";6import { json, redirect } from "@remix-run/node";7import { ValidatedForm } from "remix-validated-form";8import { z } from "zod";9import { withZod } from "@remix-validated-form/with-zod";10import FormInput from "~/components/Form/FormInput";11import FormNumber from "~/components/Form/FormNumber";12import FormTextarea from "~/components/Form/FormTextarea";13import type { EventWizardHandle } from "~/routes/events.new";14import Button from "~/components/Button";15import type { EventWizardSession } from "~/sessions/wizard-session.server";16import {17commitWizardSession,18getMaybeWizardSession,19} from "~/sessions/wizard-session.server";2021export const validator = withZod(22z.object({23nextStep: z.string().nonempty("Next step is required"),24name: z.string().nonempty("Event name is required"),25date: z.string().nonempty("Event date is required"),26time: z.string().nonempty("Event time is required"),27price: z28.string()29.nonempty("Event price is required")30.min(0, "Price must be greater than 0"),31description: z.string().nonempty("Event description is required"),32})33);3435export const handle: EventWizardHandle = {36key: "event",37title: "Basic Info",38step: "basic-info",39submitButton: <Button type="submit" form="basic-info" title="Next" />,40};4142export const meta: V2_MetaFunction = () => [43{ title: `Create Event ${handle.title} Step | Culture Management Group` },44];4546export async function loader({ request }: LoaderArgs) {47const eventWizardSession = await getMaybeWizardSession<EventWizardSession>(48request49);5051return json(eventWizardSession);52}5354export async function action({ request }: ActionArgs) {55const formData = await request.formData();5657const { name, date, time, description, price, nextStep } = Object.fromEntries(58formData59) as Pick<60EventWizardSession,61"name" | "date" | "time" | "price" | "description"62> & {63nextStep: string;64};6566return redirect(`/events/new/${nextStep}`, {67headers: {68"Set-Cookie": await commitWizardSession(request, {69name,70date,71time,72description,73price,74}),75},76});77}7879const BasicInfo = () => {80const data = useLoaderData<typeof loader>();8182return (83<ValidatedForm84validator={validator}85id="basic-info"86method="post"87defaultValues={data as any}88>89<Stack spacing={6}>90<FormInput91label="Whats the name of this event?"92placeholder="Event name"93name="name"94/>95<FormInput96label="When is this event happening?"97type="date"98name="date"99/>100<FormInput label="What time does this event?" type="time" name="time" />101<FormNumber102label="How much does this ticket cost?"103name="price"104min={100}105step={50}106/>107<FormTextarea label="What is this event about?" name="description" />108</Stack>109110<input type="hidden" name="nextStep" value="lineups" />111</ValidatedForm>112);113};114115export 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.
- Remix's server-side rendering approach makes it easy to create a wizard session.
- The eventWizardSession object is stored in the server session.
- The eventWizardSession object is updated using the commitWizardSession function.
- The eventWizardSession object is retrieved using the getMaybeWizardSession function.
- The action function is used to handle form submissions in the event creation process.
- The action function uses the redirect function to redirect the user to the next step in the event creation process.
- 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.