Updating UI in Remix Without Leaving the Page with useFetcher
Use Remix useFetcher to run route actions in the background and update UI without navigation, keeping progressive enhancement and server validation.
18 Jan 2026, 07:15 UTC

Adding a comment, toggling a favorite, or adjusting a cart quantity should feel instant, but a full page navigation breaks context and wastes work already loaded by the parent route. Remix handles this with server actions and a background fetch primitive that keeps the URL stable while the UI updates from real server data.
The practical takeaway is to use useFetcher for non-navigational mutations. It triggers a route action or loader without changing location, returns submission state and data, and degrades to a normal form POST when JavaScript is unavailable.
Route actions and loaders as the server boundary
In Remix a route module exports loader for GET and action for mutations. Both run on the server, receive request info, and must return serializable data or a Response. Loaders run parent to child, so a layout can provide shared data once and nested routes compose it.
Forms use native HTML method and action attributes. With JavaScript enabled Remix intercepts the submission and updates the UI; without JavaScript the browser performs a full request. That progressive enhancement is the baseline for any mutation.
Background updates with useFetcher
useFetcher is a hook that gives a miniature Remix navigation object for a component. Calling fetcher.submit sends data to a route action or loader in the background. The URL does not change, and the component can read fetcher.state, fetcher.formData, and fetcher.data to render pending and result states.
Because the request still hits a server action, validation, authorization, and side effects stay on the server. The client only re-renders with the serialized result. Errors can be thrown as Response objects from an action or loader and are caught by the route's error boundary.
Worked example: add a comment without navigation
Assume a posts route with a nested comments segment. The route module app/routes/posts.$postId.tsx exports a loader that returns post data and an action that creates a comment.
// app/routes/posts.$postId.tsx
export async function loader({ params }) {
// fetch post and comments, return serializable JSON
return { post, comments };
}
export async function action({ request, params }) {
const formData = await request.formData();
const body = formData.get('body');
// create comment server side, return updated comments
return { comments };
}
The comment form uses useFetcher so the submission stays on the same page.
// app/routes/posts.$postId/comments.tsx
import { useFetcher } from 'remix';
export default function CommentsForm() {
const fetcher = useFetcher();
const isSubmitting = fetcher.state !== 'idle';
return (
<fetcher.Form method="post" action="/posts/$postId">
<textarea name="body" required />
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Saving...' : 'Add comment'}
</button>
</fetcher.Form>
);
}
When the form is submitted, fetcher.submit is invoked implicitly by the form. The component can render fetcher.data?.comments to show the updated list without a navigation. A practical check is to open network tools and confirm a background request to the route action with no URL change, then disable JavaScript and confirm the same form performs a full POST.
Trade-offs and limits
useFetcher does not change navigation state. For critical writes you must handle pending and error states explicitly, otherwise the UI can appear unchanged while the request is in flight. It also does not replace a loader refresh when other parts of the page depend on the mutation result; you may need to revalidate with fetcher.load or a navigation.
Server code in loaders and actions must avoid client-only APIs. It runs in a server runtime and the return value is serialized to the client. Signatures differ across Remix v1, Remix v2, and React Router v7 based routing, so verify the exact export shape for your target version before adopting.
To verify behavior, inspect a route module for exported loader and action, test the form with JavaScript disabled for progressive enhancement, use useFetcher and observe background request state, and throw a Response in a loader to confirm the route error boundary renders.
Use useFetcher when the mutation is local to a component and URL stability matters. Keep destructive or multi-step flows on a real navigation with a full action response so the browser history and reload semantics remain predictable.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.