Untangling dialogs in React Router

Dialogs seem simple....until you run into when and where to data loading, handling errors and success feedback, and inevitably resorting to useEffect. In this post, I’ll break down how to implement modal dialogs using React Router 7, covering patterns for nested routes, optimizing loader requests, showing feedback messages, programmatically closing dialogs, and preserving animations...all without a single useEffect.

Project setup

I'm going to be walking through a simple demo app. The app is a UI to manage an Ollama instance. Ollama is an open-source tool that lets you run large language models (LLMs) locally on your own computer or server. Our app will have a route that lists installed models, allow the user to install a model, and allow the user to uninstall a model. The install and uninstall options will be in dialogs.

We're going to be using React Router in framework mode, tailwind, and shadcn components.

Here's what it looks like (Stackblitz demo here)

First attempt

Your first inclination, like mine, is probably to simply use a <Dialog>, or two, in this app/routes/models.tsx route. The Install Model dialog requires some data though. We need to make a request to get the list of available, uninstalled models. Pulling in useFetcher is a logical next step. However, now we need a resource route to provide the list of models to choose from as well. We'll assume that already exists. The models route component could look something like this:

export async function loader() {
  const models = await listInstalledModels()
  return data({ models })
}

export default function ModelsPage({ loaderData }: Route.ComponentProps) {
  const { models } = loaderData
  const loadModelsFetcher = useFetcher()

  const onInstallClick = () => {
    loadModelsFetcher.load(href("/resources/models/installable"));
  }

  return (
    <>
      ...page header...

      <Dialog>
        <DialogTrigger asChild onClick={onInstallClick}>
          <Button type='button'>Install</Button>
        </DialogTrigger>
        <DialogContent>
          <Form method="post">
            ....
            <ModelSelect models={loadModelsFetcher.data ?? []} />
            ....
          </installModelFetcher.Form>
        </DialogContent>
      </Dialog>

      ...models list...
    </>
  );
}

We also need a <Dialog> for the Uninstall Model dialog. That could also be put into this ModelsPage route component or in a <ModelsTable> component. Pick your poison. Is there a right answer? 🤷‍♂️ We'll need to account for both install and uninstall in this route's action though which means we'll need to pass form data to the action to distinguish our intent.

<input type="hidden" name="intent" value="install" />
// or
<input type="hidden" name="intent" value="uninstall" />

Oh wait, we also want to dismiss the dialogs after successful submissions. This means we need to be able to control the open state of our dialogs. We need to throw in at least one useState for this.

Hmm, how do we close the dialog on a successful submission? We could await a useSubmit or a fetcher.submit() but those return void, not the result of the submission. We could optimistically close it but that's pretty poor UX if an error is returned. I guess we need a useEffect to "listen" to the actionData and toggle the state that way...? 😩

const [open, setOpen] = useState(false);

useEffect(() => {
  if (actionData?.success) {
    setOpen(false);
  }
}, [actionData])

// ...

<Dialog open={open} onOpenChange={setOpen}>
...
</Dialog>

We also need some bits of state for the Uninstall Model dialog: dialog open state and which model we're uninstalling.

Our ModelsPage route is quickly getting complex. It handles loading models, installing, and uninstalling models, fetchers, dialog state, syncing the submission state. There's a lot going on here.

There must be a better way!

Take two

Instead of implementing the dialogs directly in the app/routes/models.tsx route, let's make this route a parent route that renders an <Outlet /> after the models table and then we'll create distinct routes for install and uninstall. These will render into the <Outlet />.

app/routes/models.tsx
app/routes/models.install.tsx
app/routes/models.uninstall.$name.tsx

Right off the bat, we have a way better idea of what's possible in the application just by looking at the routes. However, I've tried this before and ran into problems. React Router has recently had some features implemented that could come in handy though. Let's see how far we can get.

Back to basics

In the app/routes/models.tsx route, let's rip out out the dialog stuff:

  • Dialog markup
  • Dialog useStates
  • fetchers
  • useEffect
  • resource route

Ah, doesn't that feel nice? A route responsible for one thing: listing the models.

Let's add the Install button back in but make it a link to a new route we'll create. We also need to make sure we're rendering an <Outlet /> into this route.

<Button className="mt-6" asChild>
  <Link
    to={href("/models/install")}
    unstable_defaultShouldRevalidate={false}
    preventScrollReset
  >
    <Download className="mr-1" />
    Install
  </Link>
</Button>

...models list...

<Outlet />

Whoa, what are those props on the <Link>???

unstable_defaultShouldRevalidate is a new feature I was able to contribute to the framework in November. You can read more about it here. Basically, it allows you to disable the revalidation of active routes on a navigation. Since clicking this link will result in a navigation, React Router usually revalidates all active routes. We don't need the app/root.tsx loader(if there is one) and the app/routes/models.tsx route loader to revalidate in this case. Instead of implementing shouldRevalidate functions in these routes, we can change the behavior here at the call-site. Pretty handy for optimizing our network calls.

preventScrollReset does exactly what it says. Since we just want the UX to seem like the user is opening a dialog that overlays the existing content, we can set this prop so React Router doesn't automatically scroll the user to the top of the screen. It makes for a seamless transition.

We'll change the Uninstall buttons to also be links to the appropriate route.

<Button variant="destructive" size="sm" asChild>
  <Link
    to={href("/models/uninstall/:name", model)}
    unstable_defaultShouldRevalidate={false}
    preventScrollReset
  >
    <Trash2 />
    Uninstall
  </Link>
</Button>

First dialog

We'll implement the uninstall route/dialog first. Since it will be in its own route we can use a boring loader and action instead of fetchers.

// app/routes/models.install.tsx

export async function loader() {
  const models = await listUninstalledModels();
  return data({ models });
}

export async function action({ request }: Route.ActionArgs) {
  const formData = await request.formData();
  const name = formData.get("name") as string;

  if (name.includes('qwen')) {
      return data({ error: "Something went wrong :("}, { status: 400 })
  }

  const model = await searchModel(name);

  await installModel(model);

  throw redirect(href("/models"));
}

export default function ModelInstall({ 
    loaderData, 
    actionData
}: Route.ComponentProps) {
  const navigate = useNavigate();
  return (
    <Dialog
      open
      onOpenChange={(open) => {
        if (!open) {
          navigate(href("/models"), {
            unstable_defaultShouldRevalidate: false,
            preventScrollReset: true,
            replace: true,
          });
        }
      }}
    >
      ...

      {actionData?.error ? (
          <Alert variant="destructive">
            <AlertCircleIcon />
            <AlertTitle>Unable to install model</AlertTitle>
            <AlertDescription>{actionData.error}</AlertDescription>
          </Alert>
        ) : null}

      <Form method="post" preventScrollReset replace>
        ...
      </Form>

    </Dialog>
  );
}

This looks like basically any other route which is great. Boring is good.

Because we need to account for the user dismissing the dialog, we need to add an onOpenChange callback to listen for it being closed and then navigate back to the /models route. We also navigate away after a model is installed in the action.

The replace prop is added to clean up the browser history. We don't really want the dialog route to be navigate-able via the back button so we can "replace" the /models/install stack entry with the /models entry.

We still don't want to trigger a revalidation of routes when the user closes the dialog so we use unstable_defaultShouldRevalidate. We also don't want the screen to jump on close so preventScrollReset stays as well.

I'm just hardcoding in a known condition to emulate error handling so we can see what that will look like. We display the errors directly in the dialog.

Issues

This seems pretty good so far but there are two problems we haven't accounted for:

  • Success feedback message
  • Close dialog animation

Success feedback

After a successful submission, we're navigating back to the /models route. The route will revalidate which means the newly installed model will show up in the list but other than that there's no feedback to let the user know their action was successful. It can be pretty easy to miss the new entry popping into existence. Let's add a toast.

In the app/root.tsx, we'll add the toast container component.

<body>
  {children}
  <Toaster position="top-center" />
  <ScrollRestoration />
  <Scripts />
</body>

Now we need to figure out how to actually trigger a toast. We could use a query param in the navigation but that's not really something we want in our urls.

const params = new URLSearchParams({
  message: "Model installed successfully."
})
throw redirect(href("/models")} + `?${params}`);

👎

This is a good candidate for session state. We'll set up cookie session storage in a new file.

// app/session.server.ts
import { createCookieSessionStorage } from "react-router";

type SessionData = {
  userId: string;
};

type SessionFlashData = {
  toast: string;
};

const {
  getSession: getSessionBase,
  commitSession,
  destroySession,
} = createCookieSessionStorage<SessionData, SessionFlashData>({
  cookie: {
    name: "___session",
    httpOnly: true,
    maxAge: 60,
    path: "/",
    sameSite: "lax",
    secrets: ["s3cret1"],
    secure: true,
  },
});

async function getSession(request: Request) {
  return getSessionBase(request.headers.get("Cookie"));
}

export { getSession, commitSession, destroySession };

This is mostly taken from the docs, but I have created a more convenient getSession function and implemented the types for the session data and flash session data.

Session data is what needs to exist for the life the user using the app. Things like the current user details. Flash session data is temporary data meant to be read out of the session once and is removed afterwards. This is ideal for our use case.

We can now use this in our action.

// ...

await installModel(model);

const session = await getSession(request);
session.flash("toast", "Model installed successfully.");

throw redirect(href("/models"), {
  headers: {
    "Set-Cookie": await commitSession(session),
  },
});

We flash the message to the session and commit it as a header.

We can consume this in the /models route. First we need to read it out in the loader.

// app/routes/models.tsx
export async function loader({ request }: Route.LoaderArgs) {
  const models = await listUninstalledModels();
  const session = await getSession(request)
  const toast = session.get('toast')
  return data({ models, toast }, {
    headers: {
        'Set-Cookie': await commitSession(session)
    }
  });
}
Heads up! You must commit the session after reading the flash data for it to actually be cleared!

Okay, so now we're getting it out of the session but this is on the server side. We need to do something with this on the client. useEffect in the component? Nope!

We can implement a clientLoader to intercept this data before it gets sent to the route component.

// app/routes/models.tsx
export async function clientLoader({ serverLoader }: Route.ClientLoaderArgs) {
  const result = await serverLoader();
  if (result.toast) {
    toast.success(result.toast);
  }
  return result;
}

Our component remains untainted by useEffect and anything related to toasts. Perfect.

Don't like toasts? No problem. We're still passing the toast message as loaderData that the /models route component will have. You could display directly in the component above the models list if you like that better.

Exit animation

Now we need to tackle the exit animation. The problem is that when the user navigates from a dialog route back to the parent /models route, the dialog elements are immediately removed from the dom. The dialog just pops out of existence. There's no nice fade/scale out animation like when the dialog is opened.

When a dialog pops into existence, you can animate the elements because...well...they exist. The reverse is a problem though. Animating an element that doesn't exist anymore is...tricky. Normally, when you use a shadcn dialog it manages the enter/exit animation behind the scenes and you don't have to worry about it. It does some fancy stuff to wait until it animates out until elements are removed from the dom.

Now that we've tied the dialog to the routing, there is no waiting. The elements are immediately removed.

Luckily for us, browsers have implemented a feature that React Router also supports that we can leverage here.

View transitions

View transitions let the browser animate smoothly between dom states. So for our use case this would be state of the dialog existing and then the dialog ceasing to exist.

We need to target the relevant elements for animation. In the shadcn dialog component file we need to update the overlay and content components:

// app/components/ui/dialog.tsx

<DialogPrimitive.Overlay
    data-slot="dialog-overlay"
    style={{ viewTransitionName: "dialog-overlay" }}

// ...

<DialogPrimitive.Content
    data-slot="dialog-content"
    style={{ viewTransitionName: "dialog-content" }}

We also need to remove the blur effect on the overlay. It doesn't seem to play nice with view transitions here.

-- bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs
++ bg-black/40 duration-100

Next, we need write some view transition css to match the existing animation classes on those components.

/* app/app.css */

@keyframes dialog-content-in {
  from {
    opacity: 0;
    transform: scale(0.95);
  }
}
@keyframes dialog-content-out {
  to {
    opacity: 0;
    transform: scale(0.95);
  }
}
::view-transition-old(dialog-content) {
  animation: dialog-content-out 100ms ease forwards;
}
::view-transition-new(dialog-content) {
  animation: dialog-content-in 100ms ease forwards;
}
::view-transition-old(dialog-overlay),
::view-transition-new(dialog-overlay) {
  animation-duration: 100ms;
}

The final step is to actually start a view transition on navigate. Back in our app/routes/models.install.tsx component:

<Dialog
  open
  onOpenChange={(open) => {
    if (!open) {
      navigate(href("/models"), {
        unstable_defaultShouldRevalidate: false,
        preventScrollReset: true,
        replace: true,
        viewTransition: true,
      });
    }
  }}
>
...
<Form method="post" preventScrollReset replace>

There are two cases where we need to set the viewTransition option. Both result in navigating back to the /models route and the dialog ceasing to exist.

Success! Now the dialog animates nicely out in a way that matches the "in" animation.

I may have lied...

If you're following along, you might notice that if there is an error returned, the dialog kind of bounces. This is because it's all wrapped in a view transition and due to our css, we're fading and scaling when the dom changes(the error renders). It's almost like the dialog is optimistically closing but then the error prevents the dialog from actually being removed and the dialog bounces back.

I feel like this is a fair trade off but if your dialogs return errors often you might disagree. We can solve this by pulling in a useEffect.

First, the action needs to change to always return action data instead of throwing a redirect.

// app/routes/models.install.tsx

// ...

const session = await getSession(request);
session.flash("toast", "Model installed successfully.");

return data(
  { error: false },
  {
    headers: {
      "Set-Cookie": await commitSession(session),
    },
  },
);

Next, we'll remove the viewTransition prop from the <Form> so it doesn't animate when errors update the dom.

// app/routes/models.install.tsx

<Form method="post" preventScrollReset replace>

Finally, we need to add a useEffect to listen for the error being explicitly false. Then we can navigate with the viewTransition option there.

// app/routes/models.install.tsx

useEffect(() => {
  if (actionData?.error === false) {
    navigate(href("/models"), {
      unstable_defaultShouldRevalidate: false,
      preventScrollReset: true,
      replace: true,
      viewTransition: true,
    });
  }
}, [actionData]);

This prevents the dialog from bouncing when there's an error and we're still syncing the dialog with the route buuut we're resorting to the dreaded useEffect. Tradeoffs. ⚖️

Wrapping up

What started as mess of useEffect, fetchers, and dialog state has turned into something fairly straightforward. Each dialog lives in its own route with a single responsibility.

The real win here isn't just avoiding useEffect, it's that the code now reflects how we actually think about the application. Want to know what actions are possible? Look at the routes.

View transitions solved the exit animation problem that's made route-based dialogs feel janky. Combined with unstable_defaultShouldRevalidate for performance and flash sessions for feedback, we have a pattern that's actually pleasant to work with.

I've been fighting with dialog implementations for years, trying tons of different ideas. This is the first approach that's felt genuinely good. The framework does the heavy lifting; we just need to leverage the available tools.

If you've been wrestling with dialog state in your React Router apps, give this pattern a shot. The full demo code is available on GitHub if you want to see everything wired up together.

Want more posts like these? Let me know by signing up to be notified when I publish. 👇 ✌️

Contributing Callsite Revalidation Opt-out to React Router

This post is long overdue since it happened back in November but alas here we are...

I contributed callsite revalidation opt-out to React Router(with a lot of help from Matt Brophy) and am pretty proud of that. React Router is very well established, and new features don't get incorporated without tons of thought and consideration from the steering committee. Pretty pumped I got to implement a feature Ryan Florence pitched way back in early 2023. Feels good to finally contribute code, not just feedback, after years of using the software.

Revalidation

If you've worked with React Router for any length of time you'll immediately notice the nuclear approach to revalidations. What's a "revalidation" you ask?

Imagine you have three nested routes that each exports a loader to supply data.

app/routes/root.tsx                 -> supplies account data
app/routes/projects.tsx             -> supplies list of projects
app/routes/projects.$projectId.tsx  -> supplies project details

If you make a successful POST request to mutate some data in the projects.$projectId.tsx, then all three loaders will "revalidate": run again to fetch fresh data.

History lesson

This behavior harkens back to the days where you just relied on <form method="post" action="action.php"> and the browser would refresh the whole page. Your backend would have to refetch all the data that the page needs.

We've come along way since then though. The pendulum swung to SPA's where most people used react-query to make all their api calls and it was up to you to explicitly revalidate data. React Router's default behavior swings that pendulum all the way back and revalidates everything for you.

Today

React Router does give you means to control revalidations in the form of exporting a shouldRevalidate function from your route. This is your means of opting loaders out of rerunning based on certain app specific conditions. This is great but a bit unwieldy at times.

Tags/Labels

Imagine the projects.$projectId.tsx route allows you to "tag" the project. Think GitHub labels. You could tag the project "High Priority", "Feature", "Frontend", etc. In GitHub you can create new labels on the fly.

You would most likely use a fetcher.submit() call for this request and since that's a mutation it will automatically revalidate all three loaders in our hypothetical app. This can lead to some wonky behavior and we know that for our use-case these automatic revalidations are completely unnecessary.

Old solution

The old way was to export a shouldRevalidate function in every active route.

// app/routes/root.tsx
// app/routes/projects.tsx
// app/routes/projects.$projectId.tsx

import type { ShouldRevalidateFunctionArgs } from "react-router";

export function shouldRevalidate({
  defaultShouldRevalidate,
  formMethod,
  formAction,
}: ShouldRevalidateFunctionArgs) {

  const isTagCreate =
    formMethod === "post" &&
    formAction?.endsWith("/tags");

  if (isTagCreate) return false;

  return defaultShouldRevalidate;
};

Now, imagine your coworker needs to use your fancy new creatable tag combobox component. They better remember to do the same shouldRevalidate shenanigans in the active routes they're working in.

This gets messy and is prone to being missed.

After some research, I came across Ryan's old proposal for Callsite Revalidation Opt-out which is exactly what I wanted. I wanted to be able to control this revalidation at the callsite.

fetcher.submit(target, {
  method: 'post',
  shouldRevalidate: () => false
}

It was validating to see others experiencing this same pain point. It was at this point that I thought "Hey, maybe I can I figure this out and make it a reality".

Rolling up my sleeves

I forked the repo and was able to figure out how to get the examples to run but I was struggling to figure out a workflow to modify the core code and test that out. I added some console.log and debugger statements but they were never being triggered when they absolutely should have been. I have been fairly active in the React Router discord so I headed to the #contributing channel.

I was able to get the console.logs to show up but couldn't get it to break with my debugger statements. After some head scratching, I figured out that my console had a rule to ignore files in node_modules. I needed to uncheck the checkbox in the Custom exclusion rules here.

I was in business after that! I could step through the code to see how React Router worked and flowed under the hood.

After a couple hours of investigation and trying things I was able to put a PR together.

The steering committee met and made some design choices that made a lot of sense. Matt asked if i'd like to make the changes or hand it off.

I wasn't going to come this far and not try to see it through myself.

I was able to make the requested changes and then Matt wrote a tests covering it. Brooks Lybrand was checking out the PR and discovered a bug which I was quickly able to determine the cause but wasn't sure the best way to go about fixing. Matt figured out that another PR would solve the problem here so he merged that in and it was good to go. 🎉

The feature was included in the v7.11.0 version as "unstable" to give people a chance to try it out and let it soak before they're marked as stable. Here's what it looks like to use currently. Eventually the unstable_ bit will drop off.

fetcher.submit(target, {
  method: 'post',
  unstable_defaultShouldRevalidate: false
}

<Form method="post" unstable_defaultShouldRevalidate={false}>
...
</Form>

<Link to={href('/some/where')} unstable_defaultShouldRevalidate={false}>
...
</Link>

const navigate = useNavigate()

navigate(href('/some/where'), {
  unstable_defaultShouldRevalidate: false
})

It's been added to all navigations which should cover any use-case you might have.

This gives you a lot of flexibility: the value you set becomes the defaultShouldRevalidate argument in the route's shouldRevalidate function, so you can control revalidation at both the callsite and the route level.

Try it out

If you've been wrestling with React Router's aggressive revalidation behavior, this feature should make your life easier.

Big thanks to Matt Brophy and the React Router steering committee for guiding this through. Open source collaboration at its best.

Give it a go and let me know how it works out for ya.

Enjoy this? Sign up for my newsletter below to get notified when I publish new posts. ✌️

Executing api requests in React Router

There's not a lot of opinions on how to structure code that interacts with apis in React Router's actions and loaders. This is what I've come up with and the post I wish existed before I started using React Router's actions/loaders with my existing api(and if middleware existed back then). You need some understanding of how context works which you can learn about in the middleware docs.

getLoadContext

This is where you can define context values before any of your actions and loaders get hit.

// server.ts
import { createRequestHandler } from "@react-router/express";
import express from "express";
import { unstable_RouterContextProvider } from "react-router";
import { getSession } from "~/services/session.server";
import { appContext, makeFetchTyped } from "~/contexts.server";

export const app = express();

app.use(
  createRequestHandler({
    build: () => import("virtual:react-router/server-build"),
    async getLoadContext(req) {
      const session = await getSession(req.headers.cookie);
      const user = session.get("user");
      const fetchTyped = makeFetchTyped(user);
      const context = new unstable_RouterContextProvider();
      context.set(appContext, {
        user,
        fetchTyped,
      });

      return context;
    },
  }),
);

Now you have access to the the appContext values in your actions and loaders.

import { appContext } from '~/contexts.server'

export async function loader({ context }: Route.LoaderArgs) {
  const ctx = context.get(appContext)
  // ctx.user
  // ctx.fetchTyped
}

See this middleware project example for all the pieces to make this work: vite.config.ts, react-router.config.ts, etc.

Middleware

You can also skip getLoadContext and do this directly in middleware.

// app/root.tsx
const rootMiddleware: Route.unstable_MiddlewareFunction = async ({
  request,
  context,
}) => {
  const session = await getSession(request.headers.get("Cookie"));
  const user = session.get("user");
  const fetchTyped = makeFetchTyped(user);
  context.set(appContext, {
    user,
    fetchTyped,
  });
};

export const unstable_middleware = [rootMiddleware];

The middleware version is the way of the future. I only mention the getLoadContext because you might come across it being mentioned and be confused since it shares a lot of similarity with middleware.

fetchTyped

What is fetchTyped? Another concept I picked up from Bryan Ross(@rossipedia) in React Router's discord. This is a function I've defined in my codebase. When dealing with an api, you're usually making authenticated requests to a particular user via auth token that's stored in the session. fetchTyped is a thin wrapper around fetch that binds the current user to a fetch wrapper. It accepts a zod schema to validate the response. Here's the implementation.

import { z } from 'zod'
import { fromZodError } from 'zod-validation-error'
import { env } from '~/env.server'

export function makeFetchTyped(user: User) {
    const defaultHeaders: HeadersInit = {
        Accept: 'application/json',
        'Content-Type': 'application/json',
        Authorization: user.apiToken,
    }

    return async function fetchTyped<T extends z.ZodType>(
        url: string,
        schema: T,
        options?: RequestInit,
    ): Promise<z.infer<T>> {
          // Merge the default headers with the provided options
          const mergedOptions: RequestInit = {
            ...options,
            headers: {
              ...defaultHeaders,
              ...(options.headers || {}),
            },
          }
        const res = await fetch(env.API_HOST + url, mergedOptions)
        const json = await res.json()
        try {
          return schema.parse(json)
        } catch (reason) {
          throw reason instanceof z.ZodError ? fromZodError(reason) : reason
        }
    }
}

export type FetchTyped = ReturnType<typeof makeFetchTyped>

And here's how it can be used.

import { redirect, href, data } from "react-router";
import { appContext } from "~/contexts.server";
import { z } from "zod";
import type { Route } from "./+types/route";

export async function loader({ context }: Route.LoaderArgs) {
  const ctx = context.get(appContext);
  const schema = z.object({
    data: z.array(
      z.object({
        id: z.number(),
        firstName: z.string(),
        lastName: z.string(),
      }),
    ),
  });
  const res = await ctx.fetchTyped("/contacts", schema);
  return {
    contacts: res.data,
  };
}

export async function action({ request, context }: Route.LoaderArgs) {
  const formData = await request.formData();
  const ctx = context.get(appContext);
  const schema = z
    .object({
      id: z.string(),
      message: z.string(),
    })
    .or(
      z.object({
        error: z.string(),
      }),
    );
  const contactId = String(formData.get("contactId"));
  const res = await ctx.fetchTyped(`/contacts/${contactId}`, schema, {
    method: "PUT",
    body: JSON.stringify({
      data: {
        firstName: String(formData.get("firstName") ?? ""),
        lastName: String(formData.get("lastName") ?? ""),
      },
    }),
  });

  if ("error" in res) {
    return data({ error: res.error }, { status: 400 });
  }

  return redirect(href('/app/contacts/:id', { id: res.id }));
}

Organization

Defining your api requests directly in your actions and loaders is totally valid but it leaves a lot to be desired in terms of code organization. If we extract these interactions into a class, we can organize and reuse these methods across the application if needed.

// app/service/contact-client.server.ts
import type { FetchTyped } from "~/lib/fetch-typed.server";
import { DomainError } from "~/lib/validation.server";

export class ContactClient {
  #fetchTyped: FetchTyped;

  constructor(fetchTyped: FetchTyped) {
    this.#fetchTyped = fetchTyped;
  }

  async getContacts() {
    const schema = z.object({
      data: z.array(
        z.object({
          id: z.number(),
          firstName: z.string(),
          lastName: z.string(),
        }),
      ),
    });
    const { data } = await this.#fetchTyped("/contacts", schema);
    return data;
  }

  async updateContact({
    contactId,
    values,
  }: {
    contactId: string;
    values: CreateContactValues;
  }) {
    const schema = z
      .object({
        id: z.string(),
        firstName: z.string(),
        lastName: z.string(),
      })
      .or(
        z.object({
          error: z.string(),
        }),
      );
    const res = await this.#fetchTyped(`/contacts/${contactId}`, schema, {
      method: "PUT",
      body: JSON.stringify({
        data: values,
      }),
    });
    if ("error" in res) {
      throw new DomainError(res.error);
    }
    return res;
  }
}

type CreateContactValues = {
  firstName: string;
  lastName: string;
};

Next, well add this to our context.

const rootMiddleware: Route.unstable_MiddlewareFunction = async ({
  request,
  context,
}) => {
  const session = await getSession(request.headers.get("Cookie"));
  const user = session.get("user");
  const fetchTyped = makeFetchTyped(user);
  context.set(appContext, {
    user,
    fetchTyped, 
    // you'll need to add contactClient to the AppContext type too
    contactClient: new ContactClient(fetchTyped)
  });
};

And update our route to use it.

import { redirect, href, data } from "react-router";
import { appContext } from "~/contexts.server";
import { z } from "zod";
import type { Route } from "./+types/route";
import { DomainError } from "~/validation.server";

export async function loader({ context }: Route.LoaderArgs) {
  const ctx = context.get(appContext);
  const contacts = await ctx.contactClient.getContacts();
  return {
    contacts,
  };
}

export async function action({ request, context }: Route.LoaderArgs) {
  const ctx = context.get(appContext);
  const formData = await request.formData();
  try {
    const contact = await ctx.contactClient.updateContact({
      contactId: String(formData.get("contactId")),
      values: {
        firstName: String(formData.get("firstName") ?? ""),
        lastName: String(formData.get("lastName") ?? ""),
      },
    });
    redirect(href('/app/contacts/:id', { id: contact.id }));
  } catch (e) {
    if (e instanceof DomainError) {
      return data({ error: e.message }, { status: 400 });
    }
    throw e;
  }
}

I like this for several reasons.

  • We have a central location to define api requests related to this resource.
  • We're hiding the implementation of how the data is fetched and persisted. If we decide we want to hit a database directly, instead of going through an api, this code doesn't need to change. We could set up a database connection and feed that to the class constructor instead of fetchTyped and the route code would be none the wiser.
  • We can come up with style "rules" to make less thinking involved when developing new code.

Rules

Here are a few rules i've documented in my project repo to share with my team.


Organization

All api interactions should be located in app/module/<module-name>/<resouce-name>-client.server.tsx. The resource name should be singular. Ex. note-client.server.tsx, not notes-client.server.tsx.

Class methods

The method names should not allude to an api or database being used.

Good:

  • getNote
  • getNotes
  • createNote
  • updateNote
  • deleteNote

Bad:

  • fetchNote
  • queryNote

These methods should only contain one configuration object. Descriptive id field names should be used to decrease ambiguity inside the configuration object. Ex. contactId, not id.

Errors should be thrown inside of a DomainError, not returned as data.

The functions should be ui agnostic. For example, they should not return toast messages. This should be done in the route files. Example:

// app/routes/app.contacts.$id/route.tsx

export async function action({ request, params, context }: Route.ActionArgs) {
  const formData = await request.formData();
  const ctx = getAppContext(context);
  const result = await updateContact(ctx.contactClient, { // see updateContact below
    contactId: params.id,
    values: {
      firstname: String(formData.get("firstname") ?? ""),
      // ...
    },
  });

  // use react router's data() utility to send proper status code
  return data(result, { status: result.success ? 200 : 400 });
}

// clientAction should be origin of toasts, not useEffects
export async function clientAction({ serverAction }: Route.ClientActionArgs) {
  const result = await serverAction();
  toast({
    title: result.toast.title,
    variant: result.toast.variant,
  });
  return result;
}

// Wrapper function to put together toast messages based on the response.
// This code is specific to this use case of the api function.
async function updateContact(
  contactClient: ContactClient,
  ...args: Parameters<ContactClient['updateContact']>
) {
  try {
    const contact = await contactClient.updateContact(...args);
    return {
      success: true,
      contact,
      toast: {
        title: "Contact updated!",
        variant: "default",
      },
    };
  } catch (e) {
    if (e instanceof DomainError) {
      return {
        success: false,
        contact: null,
        toast: {
          title: e.message,
          variant: "destructive",
        },
      };
    }

    throw e;
  }
}

You may absolutely hate these rules but that's not really the point. The point is that your project will benefit if you spend some time figuring out how to make your app consistently built. Having a "standard" way to do things will make it so much easier to develop new features. There are less decisions to make in the moment. They've already been made and documented. Not only will this help your human coworkers, but also Copilot, Cursor, Claude Code, OpenCode, Junie, etc.

Do you have some opinions on how to structure your react router applications? I'd love to hear what those are. Reach out to me on twitter.

Happy building ✌️