This is part 3 of the Remix Todo App series, which aims to teach you everything about Remix that you'll use daily. In part 2, we created a simulated database for the todo app, loaded tasks into the app, and added the ability to create new tasks by writing to the database.
In this part, we'll cover how Remix handles multiple simultaneous requests. Remix is built on web standards and aligns with default browser behaviour. But browsers don't natively support handling multiple requests at once. This poses a limitation for modern apps, where users expect the ability to perform different actions simultaneously. This article shows how Remix manages this with practical application in our todo app.
Remix is built on web fundamentals: HTML, HTTP, and browser behaviour. Let's look at some default browser behaviours related to forms and how Remix handles them.
The action attribute of an HTML <form> specifies the submission URL, defaulting to the current URL. The method attribute defines the HTTP method, defaulting to "get". Only GET and POST are allowed; unsupported methods default to GET.
Remix's <Form> extends <form>, with action defaulting to the route it renders on, and supports additional HTTP methods like PUT, PATCH, and DELETE. But, to support progressive enhancement, it's best to stick with GET and POST.
If you submit a form and then submit another before the first completes, the browser cancels the initial submission and processes only the latest one.
Remix follows this behaviour with forms. If a form is submitted and another submission happens before the first finishes, Remix cancels the initial fetch requests and waits for the latest submission to complete before revalidating the page.
When you click the back button in a browser after a form submission is completed, the browser may resubmit the form. This occurs because form submissions trigger a navigation event.
When you use <Form> instead of the native HTML <form>, Remix prevents this behaviour of resubmitting forms during navigation events, such as clicking back, forward, or refreshing.
As discussed above, Remix follows default browser behaviours and enhances them where necessary. But, two challenges arise:
If you're to model all mutations with HTML forms, how do you handle multiple in-flight requests? Consider our todo app, where a user deletes a task and then marks another as completed before the deletion finishes. Remix, like the browser, prioritizes the latest form submission and cancels the deletion.
If you stick to GET and POST requests for progressive enhancement, how do you handle different operations in your action? In our todo app, we need to be able to create, edit, mark as completed, and delete tasks. If you only send a POST request to your action, how do you manage these different operations?
To solve the first issue, Remix provides the useFetcher hook. With useFetcher, you can interact with your server outside of navigation, allowing multiple requests to run simultaneously without canceling earlier ones.
For the second, there are different approaches. You can create separate routes for each operation and post to their actions. Or, you can handle all forms in a single route using the single button forms technique.
In part 2 of this series, we learned how to use <Form> and useSubmit for mutations. But, these trigger navigation, limiting requests to one at a time. Remix improves this with the useFetcher hook, allowing multiple form submissions without triggering navigation.
Remix efficiently handles concurrent submissions, updating the UI as new data becomes available while avoiding stale data from race conditions. If multiple submissions are in progress, Remix updates the UI as each one completes, ensuring the latest data is displayed.
The useFetcher API is similar to the navigation API:
Navigation/URL API
Fetcher API
<Form>
<fetcher.Form>
useSubmit
fetcher.submit
useActionData()
fetcher.data
navigation.formAction
fetcher.formAction
navigation.formMethod
fetcher.formMethod
navigation.formData
fetcher.formData
navigation.state
fetcher.state
If JavaScript is disabled, <fetcher.Form> will fallback to a native HTML <form>.
If we weren't supporting progressive enhancement and handled all forms in one route, the typical approach would be to use different request methods and branch in the action function based on request.method.
To support progressive enhancement, you can use the single button forms technique. This technique leverages the fact that HTML <button> elements, like other form controls, can have name and value attributes. Instead of using different request methods, all buttons submitting a form to the same route's action can share the same name but have different value attributes to represent the intent. In the action function, you branch based on this intent.
This method focuses on intent, offering more flexibility than request methods, which are limited to POST, PUT, PATCH, and DELETE. Intent-based forms allow for unlimited operations within a route.
Did you notice the URL changes to localhost:5173/?index when you click the add button in the todo app? What's with the ?index?
Forms trigger navigation, and our add form is currently rendered with <Form>. Because of nested routing, index routes share the same URL as their parent. Since <Form> defaults to the route it's rendered in, Remix appends the ?index parameter to forms without an action attribute in an index route to distinguish them from those in the parent route.
That said, we don't want the add form to trigger navigation. Also, since we'll handle all forms in the index route, we want to switch to intent-based forms. Update app/routes/_index.tsx with the following changes:
You should now be able to add tasks as before but without any change to the URL.
The structure of the todo item is simple: a button to toggle completion, the task description, timestamps for when it was added and completed, an edit/save button, and a delete button.
First, let's bring in the icons for the buttons. Run the following commands to create the files for each icon:
Next, paste the code for each icon into its respective file:
With that out of the way, let's focus on the TodoList and TodoItem components. Run the following command to create the files for both components:
Copy the following code into app/components/TodoItem.tsx:
<input type="hidden" /> allows developers to include data that users cannot see or change during form submission. In TodoItem, the first fetcher.Form handles task completion by submitting with the intent "toggle completion". When not in edit mode, the task description and timestamps are shown. In edit mode, the description becomes an input field, and clicking the save button submits the second fetcher.Form with the intent "save task". The delete button prompts a confirmation dialog and, if confirmed, submits the second fetcher.Form with the intent "delete task".
Copy the following code into app/components/TodoList.tsx:
With the TodoList and TodoItem components ready, import TodoList and handle the intents from TodoItem in app/routes/_index.tsx:
With everything in place, you should now be able to add tasks, mark them as completed, edit and save them, and delete them. To test concurrent updates, add many tasks, then click the completion buttons as quickly as possible. You'll see the UI update as each task is completed—that's the power of useFetcher.
The process of clearing completed tasks or deleting all tasks works similarly to deleting a single task, as seen in the TodoItem component. Both actions prompt a confirmation dialog, and if confirmed, the form is submitted; otherwise, no action is taken.
To maintain clean and organized code, we will create a separate component for this functionality. Run the following command to create the TodoActions component:
Next, copy the following code into app/components/TodoActions.tsx:
Finally, update app/routes/_index.tsx with the following changes to implement the "clear completed" and "delete all" functionality:
With these changes made, you should be able to clear tasks marked as completed or delete all tasks.
We want users to customize their task view by showing all, active, or completed tasks. We can use React's useState to track the selected view and filter tasks accordingly. But, what if we want the URL to update when the user changes the view? To achieve this, we can use Remix's useNavigate alongside useState to set the view state and navigate to a URL that reflects the selected view. Here's how the code would look:
That approach works, but there's a better way. Remember from part 2, where we learned that forms with method="get" append the form data to the action value with a ? separator and navigate to the resulting URL? This means we can read and set the state directly in the URL—no need to synchronize state.
First, update app/types.ts to include the View type:
Next, update app/routes/_index.tsx and app/components/TodoList.tsx with the following changes to read and set the state in the URL and filter tasks based on that state:
useMemo was used to wrap the filtering operation in TodoList, caching the result between re-renders. Clicking each button should update the UI to show only tasks relevant to that view, with the URL updating as well. This concludes this part of the series. Booyah!
In this part of the series, you learned about concurrent mutations and single-button forms. You implemented functionality to mark a task as completed, edit, delete, clear all completed tasks, and delete all tasks. Finally, you added the ability to view all tasks, or only active or completed ones.
In the next part, you'll explore how pending UI works in Remix, enabling you to add pending states and implement network-aware feedback. Stay tuned!