· 9 min read

Exploring Remix's Form and Data Fetching Mechanisms

This article was auto-translated from Chinese. Some nuances may be lost in translation.

Recently, the release of remix has caused quite a stir on Twitter, with a lot of buzz across front-end communities. Since I happened to have an internal tool to develop recently, I decided on a whim to give it a spin. The overall feel is very similar to Next.js—primarily route-based, fetching server data for SSR via standard functions similar to getServerSideProps.

Remix was created by Ryan Florence, the author of React Router. It was initially planned as a paid product before being open-sourced. Although many might think, “Oh, not another framework,” Remix indeed has its unique characteristics. If you’ve looked through the documentation and tracked its development, the most obvious aspect is Remix’s obsession with forms. This is probably the biggest difference between Remix and other SSR frameworks. Looking back, form handling was also one of the easiest things to get confused about when just starting out in front-end development.

Wait, doesn’t a browser form cause a full page reload? That doesn’t meet our requirements!

Remix has put a lot of effort into form handling. In addition to using forms to implement asynchronous requests, if JavaScript isn’t enabled, it falls back to the browser’s built-in form behavior. Although it requires a reload, at least submission still works. (And honestly, I feel that sometimes submitting a form directly with a full page reload offers a much better user experience than doing it asynchronously 🤔).

This article won’t delve into every feature of Remix, but will instead focus on two main aspects: form handling and data fetching.

What Are the Benefits of Form Handling?

You don’t need to write a ton of JavaScript code, nor do you need extra variables (or state) to store input values. Take the following example:

<form action="/my/api" method="post">
  <input name="user_name" type="text" />
  <input name="world" type="text" />
  <button type="submit">
    Submit
  </button>
</form>

By default (without any custom code), clicking the button causes the browser to automatically send a request to /my/api using the POST method, submitting the user_name and world fields as form data.

However, this default behavior requires server-side support to work smoothly. For instance, if the server only returns JSON, the user’s browser stays on the raw response page. Therefore, in typical web applications, the server usually responds with a redirect (such as 301/302) to another page, like a success or failure page.

Sometimes, a full page reload actually keeps things much simpler, because the front end doesn’t have to manage various states or manipulate browser history. However, for applications that demand real-time interactivity, such as instant comments or chat, page reloads are far from ideal.

So how do you turn a form into an asynchronous operation? You simply prevent the default behavior on the submit event and write code to send the request:

<form action="/my/api" method="post" onSubmit={e => {
  e.preventDefault()
  // call API!
}}>
  <input name="user_name" type="text" />
  <input name="world" type="text" />
  <button type="submit">
    Submit
  </button>
</form>

The greatest advantage of this approach is delegating form handling to native browser mechanisms, allowing you to easily extract form values via FormData:

<form action="/my/api" method="post" onSubmit={e => {
  e.preventDefault()
  const formData = new FormData(e.currentTarget)
  const userName = formData.get('user_name')
  const world = formData.get('world')
}}>
  <input name="user_name" type="text" />
  <input name="world" type="text" />
  <button type="submit">
    Submit
  </button>
</form>

This way, you can retrieve input values without needing refs or state. If you don’t leverage native form mechanics, you might write something like this in React:

const Component = () => {
  const [value, setVal1] = useState()
  const [value, setVal2] = useState()
  const [value, setVal3] = useState()
  return <>
    <input value={value1} onChange={handleChange} />
    <input value={value2} onChange={handleChange} />
    <input value={value3} onChange={handleChange} />
  </>
}

As you can see, as the number of input fields grows, so does the number of useState calls, making the code increasingly tedious. Of course, this can be solved by adding an abstraction layer, but that introduces extra complexity and overhead.

However, once you turn forms into asynchronous operations, there are many states you need to account for:

  • Loading: The form is submitting, so inputs should be disabled to prevent user interaction.
  • Validation errors: Field values need to be preserved while displaying error messages.
  • API success: Show corresponding feedback and clear the form.

In Remix, API support is provided for both standard form submissions with redirects and asynchronous form applications. The reason I like this design is that, often, you really don’t need to go through the hassle of async handling and writing heaps of code just for a simple form submission.

Synchronous (Using Native Form Mechanisms)

In the native scenario, all you need to do is define the form content step by step, without declaring any state at all.

// routes/users/index.js
export default function UserProfile() {
  return <form method="post" action="/users">
    <label>
      Name: <input type="text" name="userName" />
    </label>
    <label>
      Age: <input type="text" name="age" />
    </label>
    <button>Submit</button>
  </form>
}

On the server side, corresponding handling is required. Remix’s approach is to export a function called action in the same file:

import { redirect } from 'remix';

export async function action({ request }) {
  const formData = await request.formData()
  const user = await createUser(formData)
  return redirect(`/users/${user.id}`)
}

// routes/users/index.js
export default function UserProfile() {
  return <form method="post" action="/users">
    <label>
      Name: <input type="text" name="userName" />
    </label>
    <label>
      Age: <input type="text" name="age" />
    </label>
    <button>Submit</button>
  </form>
}

When the HTTP method is not GET, the action function is called, so you inspect the HTTP method inside the action function.

This redirect function is provided by Remix and redirects to the users/id page on the server side. An important point here is that the redirection happens on the server rather than using history.push. If there are form validation errors or server errors, you can retrieve the server response data via useActionData.

export async function action({ request }) {
  const formData = await request.formData()  
+  if (someError) {
+    json({ error: message })
+  }
  return redirect(`/users/${user.id}`)
}

// routes/users/index.js
export default UserProfile() {
+ const actionData = useActionData()
  return <form method="post" action="/users">
    <label>
      Name: <input type="text" name="userName" />
    </label>
    <label>
      Age: <input type="text" name="age" />
    </label>
+   {actionData.error && <p>{actionData.error}</p>}
    <button>Submit</button>
  </form>
}

Asynchronous

For asynchronous needs, Remix provides the Form component for developers. To display the current submission status, you can use useTransition:

import { Form, useTransition } from 'remix'

// routes/users/index.js
export default function UserProfile() {
  const actionData = useActionData()
  const transition = useTransition()
  
  return <form method="post" action="/users">
    <label>
      Name:
      <input
        type="text"
        name="userName"
        disabled={transition.state === 'submitting'}
      />
    </label>
    {actionData.error && <p>{actionData.error}</p>}
    <button>Submit</button>
  </form>
}

In addition to these two mechanisms, you can also use useSubmit to programmatically trigger form submissions. Whether synchronous or asynchronous, Remix’s philosophy behind form handling is: after submitting a form, some processing takes place, followed by a redirect to a page.

Data Fetching Mechanisms - Loaders and Fetchers

In web applications, there are two primary timings for fetching data:

  1. Fetching page data when the user accesses a page via an <a> tag or the address bar, such as a blog post.
  2. Calling an API to fetch data only after the user performs an action, such as clicking a comments section to load data.

For the first case, Remix provides the loader mechanism. It only runs on the server side, so components already have data when rendering, eliminating the need to handle loading states or write code for fetching and then rendering data.

For the second case, Remix provides the fetcher mechanism. You can use useFetcher to perform mutations and read data:

const MyComponent = () => {
  const fetcher = useFetcher()
  useEffect(() => {
    fetcher.load('/comments')
  }, [])

  if (fetcher.state === 'loading') {
    return <p>loading...</p>
  }

  return <div>
    <Comments comments={fetcher.data.comments} />
  </div>
}

Beyond reading data, fetchers can also be used for mutations:

const MyComponent = ({ content }) => {
  const fetcher = useFetcher()
  
  return <fetcher.Form method="post" action="/comments">
    <input type="text" name="content"></input>
    <button type="submit">Submit</button>
  </fetcher.Form>
}

Under the hood, Remix takes care of the aforementioned operations:

  • Changes the state to submitting on submit, making it easy to render loading UI.
  • Allows accessing returned data via fetcher.data.
  • fetcher.submit directly submits form content without navigating away (which handles API calling and state management under the hood).

Edge Computing

Another point is that Remix consistently emphasizes the concept of edge servers (CDNs)—that is, rather than merely deploying purely static web pages to a CDN, it deploys servers across distributed nodes to reduce network traffic.

Indeed, if the user’s device performance is no longer the bottleneck, the only remaining factor is network speed. Here, edge computing refers to deploying servers closer to users, thereby shortening the routing path of packets. However, for certain applications whose target audience is entirely local, the difference might not be as noticeable.

Conclusion

The reason I like Remix’s approach is that its obsession with forms reminds me of thoughts I’ve had before: native form mechanisms can save you from declaring an enormous amount of value states, and standard form handling can easily handle simpler use cases without having to write various async APIs. When development time is tight, async workflows demand consideration of many states—and when mishandled, the user experience often ends up worse than traditional forms.

Remix accommodates a wide range of scenarios: without JavaScript, it falls back to native forms; when responsiveness is needed, you can use the Form component; and for more interactive SPA-like scenarios, you can use fetcher. For the data management challenges front-end developers care about most, it solves more than half the battle.

Related Posts

Explore Other Topics