Why Remix is worth your attention

Authors
Last updated
 

It feels like there’s an explosion of “React distros” to stay abreast of. Create React App, Next.js, Gatsby, Hydrogen, Blitz, Redwood, etc.—not to mention pseudo-React-distros like Astro. And more importantly, these frameworks are rapidly expanding in how much they bring to the table in terms of features outside the shared React concepts, increasing the depth needed to properly learn each one, as well as the divide among them. Who has attention to spare?

Remix is a brand new entrant from Ryan Florence and Michael Jackson of React Router, now joined by the prolific Kent C. Dodds. It is worth your attention because of its uniquely different approach to structuring React web apps. Even if you aren’t immediately switching everything over, learning Remix’s approach can help shape how you think about and approach React apps in any other context.

What caught my interest is how it unifies a number of concepts from past and present to simplify the approaches that many of us React developers take for granted. At the heart of its philosophy are:

  • A return to a “client/server model.”
  • An embrace of web standards.
  • Progressive enhancement that treats JS as optional.

These aren’t so much a set of independent features as they are values that interact heavily throughout the framework’s design decisions. But (importantly) they come together elegantly.

We’ll look at how these values manifest across a few different features.

Moving the conversation beyond rendering with form handlers

Remix is unique in being a framework that looks beyond the render path and brings a fresh take to the core state update/mutation logic side of the app. This is where much—often, most—of the complexity exists in many full-stack applications. Remix recognizes that the core update interaction in many conventional web apps comes down to submitting varying types of forms.

Consider how you might render a normal HTML “contact me” form in a React app:

// Rendering a normal HTML form in React.
export default function ContactForm({ errors }) {
  return (
    <form method="post">
      <input type="text" name="name" />
      {errors?.name && <em>Name is required</em>}
      <input type="text" name="email" />
      {errors?.email && <em>Email is required</em>}
      <button type="submit">Contact me</button>
    </form>
  )
}

Many React apps need much more to make their forms actually work, however! Instead, we attach form-level submit event handlers that preventDefault, serialize the form data as JSON, send it over an XHR to a REST API endpoint, and either re-render the form with validation errors unpacked into our state management infrastructure, or perform a client-side route navigation to another screen.

The following is a simplified example—and we’re skipping over the extra work of adding client-side validation logic, optimistic UI, etc.

// Typical React app submitting form data REST API endpoints.
export default function ContactForm() {
  // Or use some form state management library.
  const [name, setName] = useState(null)
  const [email, setEmail] = useState(null)
  // For submission-related form state.
  const [submitting, setSubmitting] = useState(false)
  const [errors, setErrors] = useState(null)
  // The actual form.
  return (
    <form onSubmit={handleSubmit}>
      <input type="text" name="name" />
      {errors?.name && <em>Name is required</em>}
      <input type="text" name="email" />
      {errors?.email && <em>Email is required</em>}
      <button type="submit">Contact me</button>
    </form>
  )
  async function handleSubmit(e) {
    // Don't actually use the browser to submit the form and navigate away from the page.
    e.preventDefault()
    // Prepare the form data for submission.
    const formData = { name, email }
    // Perform client-side error validation.
    const errors = validateForm()
    if (errors) {
      setErrors(errors)
    } else {
      setSubmitting(true)
      try {
        // Communicate with the server.
        const response = await fetch('/api/contact', {
          method: 'POST',
          body: JSON.stringify({
            contact: {
              ...formData,
            },
          }),
        })
        const result = response.json()
        if (result.errors) {
          setErrors(result.errors)
        } else {
          // Reset the form state...
          setName(null)
          setEmail(null)
          // ...or navigate back to the homepage.
          router.push('/')
        }
      } finally {
        setSubmitting(false)
      }
    }
  }
}

// We aren't even showing the server-side request handling!

All of this works around the browser instead of leveraging its ability to already perform this client-server communication natively. This was probably popularized by React single-page apps, where all client-server communication is necessarily over REST APIs orchestrated by Javascript.

But back in the days of client-server web apps written in PHP/Rails/etc., the simple plain HTML form was all you would need on the client. The browser knows how to form-encode the state and submit it to the server. The server, on receipt of the form data, simply returns a newly rendered page response (typically returning the same form on validation error, or redirecting to a destination page on success).

And that’s exactly how you can approach the problem in Remix—you can write your server-side form handling logic in these action functions, which can be defined on any route:

export let action: ActionFunction = async ({ request }) => {
  let formData = await request.formData()

  let name = formData.get('name')
  let email = formData.get('email')

  let errors = {}
  if (!name) errors.name = true
  if (!email) errors.email = true

  if (Object.keys(errors).length > 0) {
    return errors
  }

  await createContactRequest({ name, email })

  return redirect('/contact')
}

export default function ContactPage() {
  // We can get the server's error context as structured JSON data. This will
  // only be defined when this page was loaded as the response of a form
  // submission.
  let errors = useActionData()
  return (
    <form method="post">
      <input type="text" name="name" />
      {errors?.name && <em>Name is required</em>}
      <input type="text" name="email" />
      {errors?.email && <em>Email is required</em>}
      <button type="submit">Contact me</button>
    </form>
  )
}

It’s ostensibly just an HTML form with a PHP-style server-side POST handler. And this will just work—even without any Javascript. useActionData makes available any JSON data that was returned from the action handler. This action architecture is what defines Remix as a full-stack framework.

There is a tradeoff here—because this style of endpoint is tightly coupled with the views, you don’t get a generic REST API that could serve arbitrary clients. But if you wanted that, you could extract the REST API endpoints from your POST handlers as resource routes.

Now want client-side interactivity/validation and optimistic UI? Swap out the form with Form and use Remix’s useTransition hook:

export let action: ActionFunction = async ({ request }) => {
  /*...*/
}

export default function ContactPage() {
  // We can get the server's error context as structured JSON data, so as to re-render the form without reloading the page.
  let errors = useActionData()
  // Show the optimistic UI once submission starts.
  let { submission } = useTransition()
  return submission ? (
    <Confirmation contact={Object.fromEntries(submission.formData)} />
  ) : (
    <Form method="post">...</Form>
  )
}

The server still performs the authoritative validation (as it must!), but now users enjoy snappy client-side feedback. If you want client-side validation, you can further extract out that validation logic from the action and run it on the client. The subtle thing that’s happening here is that the client is able to communicate with the server both using fetch with JSON data or retrieving a full HTML response (if Javascript is disabled).

(I do feel like there’s an opportunity to provide a simple utility to make isomorphic form validation even easier, such that you can write otherwise straightforward formData-checking logic, and have it run client-side first. This could further be unified with the optimistic UI handling. In effect, a lightweight non-authoritative simulation of server-side logic.)

We see the core philosophies at play here:

  • Client/server: This directly captures the classic client/server model.
  • Web standards: We fit within the browser’s existing communication facilities.
  • Progressive enhancement: This can work without Javascript—or more to the point, before waiting for JS to finish loading and hydrating. JS is instead treated as the auxiliary progressive enhancement it should be for many apps.

There will be some “new old” patterns that web developers who have never worked in this paradigm will need to pick up—even just simple things like using redirects to take users to their destination on success, having the current page reload on validation failure, etc. This single facet of Remix is what in my mind contributes the most to the overall reaction from folks that Remix is from old curmudgeons to old curmudgeons

Overall, what I find compelling is how Remix takes the whole “lighterweight Javascript” direction the industry is heading toward, and pushes the conversation beyond partial hydration and into the core state update loop of the app.

Always-dynamic

Remix is always server-side-rendered, and doesn’t support the notion of certain pages being marked as statically generated—but this doesn’t mean it can’t achieve static site performance. Remix does this by simply relying on HTTP caching—not unlike any server side templating language can. By putting a CDN in front of the server, and returning the right Cache-Control headers, you can let the CDN save and serve your unchanging content with minimal latency. A significant benefit to this is that rather than performing full static site builds, you are able to immediately redeploy changes.

export function headers() {
  return {
    'Cache-Control': 'public, max-age=300, s-maxage=3600',
  }
}

This lazy pull vs. eager push does bring a tradeoff—you don’t get an actual directory of static files produced that you can just toss on S3 or GitHub Pages. And you may instead need to reach for more CDN-specific cache flushing machinery as a result. But it nicely demonstrates again the framework’s core values, especially around embracing web standards—it removes needing to have a separate in-framework concept of static pages (or related specific concepts such as incremental static regeneration), and elegantly satisfying the same performance goals within this simplified, unified mental model.

Particularly with the general industry move toward edge serving infrastructure like Cloudflare Workers, Fly.io, and Oxygen, I think we’ll see lessened emphasis on static sites—along with the many constraints (and build times) they bring. (Pendulum swings.)

Data fetching colocated with nested routes

One of the nice things about React is that it lets you colocate different concerns for the same component—from the presentational markup to behavior/logic to styling to data fetching.

On that last one, “data fetching”—on the server, the story hasn’t been so simple. SSR frameworks necessarily require up-front data fetching at the page boundary, since React rendering is still synchronous. React 18 is on the horizon, and an eventual gift this brings is server-side data fetching—but this might not be available for a while even after React 18’s release, as it’s unclear still whether it will require React server components and not just SSR. There are tools like Apollo’s getDataFromTree and react-ssr-prepass that can help, but it’s not always straightforward (or even possible) to integrate these into SSR environments (ask me about this), and it would be nice to have a cleaner approach.

In Remix, each route can specify a loader for fetching data—for instance, a /root route might look like:

// app/routes/root.tsx

export let loader = () => {
  return getCurrentUser()
}

export default function Root() {
  let user = useLoaderData()
  return (
    <div>
      <h1>Hi {user.name}</h1>
    </div>
  )
}

But routes can be nested, and nested routes can render a nested subset of the component tree—not unlike how React Router routes can be nested. Each route can have its own loader that is just concerned with loading the respective data needed for that specific part of the UI, and the routes are composed together with <Outlet/>.

For instance, if we add a /root/posts route:

// app/routes/root/posts.tsx

export let loader = () => {
  return getBlogPosts()
}

export default function BlogPosts() {
  let posts = useLoaderData()
  return (
    <div>
      {posts.map((post) => (
        <Post post={post} />
      ))}
    </div>
  )
}

// Back in app/routes/root.tsx

export default function Root() {
  let user = useLoaderData()
  return (
    <div>
      <h1>Hi {user.name}</h1>
      {/* Render the nested route */}
      <Outlet />
    </div>
  )
}

Of course, with Remix, this data fetching and composition all runs on the server.

Note that this is limited to routes rather than arbitrary components, since there’s no way to statically determine up-front which components need what data, without just needing server-side data-fetching built into React. But even this degree of colocated fetching is quite helpful, as routes frequently do delineate the boundaries where you need to load different subsets of data.

Continuing the exploration

There’s a lot more to explore in Remix. I do miss some of the nice utilities around image optimization, etc. from Next.js, but these will likely land over time.

The biggest question looming in my head: understanding the roadmap and where Remix’s vision leads, including alignment with the React roadmap. I’d love to see whether and how Remix converges on some of the new things coming in React 18+, especially the new developments in server-side Suspense, streaming, data fetching, server components, and more—particularly as the rest of the framework ecosystem is quickly embracing this, and particularly given that Remix is well-positioned to take advantage of this (genuinely valuable) advancement in infrastructure.

Of course, I couldn’t help but integrate Plasmic visual page-building into Remix. And it was trivial. Because it’s just React. 😎 See the GitHub repo, the live Remix app, and the Plasmic design.

export let loader = () => {
  // Load the design from Plasmic. This happens server-side.
  const plasmicData = await PLASMIC.fetchComponentData("/");
  return json(plasmicData);
}};

export default function Index() {
  // Render the Plasmic design.
  let plasmicData = useLoaderData();
  return (
    <PlasmicRootProvider loader={PLASMIC} prefetchedData={plasmicData}>
      <PlasmicComponent component="/" />
    </PlasmicRootProvider>
  );
}

Next Remix + Plasmic project: build That Landing Page visually! With heaps of highly visual scroll interactions, and heaps more visuals constructed out of mere divs, it’s a perfect example of an experience that Plasmic is great for (and which I imagine must have been painstaking to craft with code).

Plasmic design rendering in Remix

Imagine a collaboration where non-technical designers can build an experience like this one—even using custom React components—and ship it straight into the Remix app, without developer involvement. 🚀🌈

Update: Plasmic now has an official quickstart guide for Remix!

I’m excited to be diving more into Remix in subsequent posts.

Thanks to Kent for reviewing an early draft of this post. Discuss on Twitter.

Follow @plasmicapp on Twitter for the latest updates.