useFetcher

Watch the 📼 Remix Singles: Concurrent Mutations w/ useFetcher and Optimistic UI

In HTML/HTTP, data mutations and loads are modeled with navigation: <a href> and <form action>. Both cause a navigation in the browser. The Remix equivalents are <Link> and <Form>.

But sometimes you want to call a loader outside of navigation, or call an action (and get the routes to reload) but you don’t want the URL to change. Many interactions with the server aren’t navigation events. This hook lets you plug your UI into your actions and loaders without navigating.

This is useful when you need to:

  • fetch data not associated with UI routes (popovers, dynamic forms, etc.)
  • submit data to actions without navigating (shared components like a newsletter sign ups)
  • handle multiple concurrent submissions in a list (typical “todo app” list where you can click multiple buttons and all be pending at the same time)
  • infinite scroll containers
  • and more!

It is common for Remix newcomers to see this hook and think it is the primary way to interact with the server for data loading and updates—because it looks like what you might have done outside of Remix. If your use case can be modeled as “navigation”, it’s recommended you use one of the core data APIs before reaching for useFetcher:

If you’re building a highly interactive, “app-like” user interface, you will use useFetcher often.

  1. import { useFetcher } from "@remix-run/react";
  2. function SomeComponent() {
  3. const fetcher = useFetcher();
  4. // trigger the fetch with these
  5. <fetcher.Form {...formOptions} />;
  6. useEffect(() => {
  7. fetcher.submit(data, options);
  8. fetcher.load(href);
  9. }, [fetcher]);
  10. // build UI with these
  11. fetcher.state;
  12. fetcher.formMethod;
  13. fetcher.formAction;
  14. fetcher.formData;
  15. fetcher.formEncType;
  16. fetcher.data;
  17. }

Notes about how it works:

  • Automatically handles cancellation of the fetch at the browser level
  • When submitting with POST, PUT, PATCH, DELETE, the action is called first
    • After the action completes, the loaders on the page are reloaded to capture any mutations that may have happened, automatically keeping your UI in sync with your server state
  • When multiple fetchers are inflight at once, it will
    • commit the freshest available data as they each land
    • ensure no stale loads override fresher data, no matter which order the responses return
  • Handles uncaught errors by rendering the nearest ErrorBoundary (just like a normal navigation from <Link> or <Form>)
  • Will redirect the app if your action/loader being called returns a redirect (just like a normal navigation from <Link> or <Form>)

fetcher.state

You can know the state of the fetcher with fetcher.state. It will be one of:

  • idle - Nothing is being fetched.
  • submitting - A form has been submitted. If the method is GET, then the route loader is being called. If POST, PUT, PATCH, or DELETE, then the route action is being called.
  • loading - The loaders for the routes are being reloaded after an action submission.

fetcher.type

fetcher.type will be removed in v2. For instructions on preparing for this change see the v2 guide.

This is the type of state the fetcher is in. It’s like fetcher.state, but more granular. Depending on the fetcher’s state, the types can be the following:

  • state === "idle"

    • init - The fetcher isn’t doing anything currently and hasn’t done anything yet.
    • done - The fetcher isn’t doing anything currently, but it has completed a fetch and you can safely read the fetcher.data.
  • state === "submitting"

    • actionSubmission - A form has been submitted with POST, PUT, PATCH, or DELETE, and the action is being called.
    • loaderSubmission - A form has been submitted with GET and the loader is being called.
  • state === "loading"

    • actionReload - The action from an “actionSubmission” returned data and the loaders on the page are being reloaded.
    • actionRedirect - The action from an “actionSubmission” returned a redirect and the page is transitioning to the new location.
    • normalLoad - A route’s loader is being called without a submission (fetcher.load()).

fetcher.submission

fetcher.submission will be flattened into the fetcher object itself in v2. For instructions on preparing for this change see the v2 guide.

When using <fetcher.Form> or fetcher.submit(), the form submission is available to build optimistic UI.

It is not available when the fetcher state is “idle” or “loading”.

fetcher.data

The returned response data from your loader or action is stored here. Once the data is set, it persists on the fetcher even through reloads and resubmissions (like calling fetcher.load() again after having already read the data).

fetcher.Form

Just like <Form> except it doesn’t cause a navigation.

  1. function SomeComponent() {
  2. const fetcher = useFetcher();
  3. return (
  4. <fetcher.Form method="post" action="/some/route">
  5. <input type="text" />
  6. </fetcher.Form>
  7. );
  8. }

fetcher.submit()

Just like useSubmit except it doesn’t cause a navigation.

  1. function SomeComponent() {
  2. const fetcher = useFetcher();
  3. const onClick = () =>
  4. fetcher.submit({ some: "values" }, { method: "post" });
  5. // ...
  6. }

Although a URL matches multiple Routes in a remix router hierarchy, a fetcher.submit() call will only call the action on the deepest matching route, unless the deepest matching route is an “index route”. In this case, it will post to the parent route of the index route (because they share the same URL).

If you want to submit to an index route use ?index in the URL:

  1. fetcher.submit(
  2. { some: "values" },
  3. { method: "post", action: "/accounts?index" }
  4. );

See also:

fetcher.load()

Loads data from a route loader.

  1. function SomeComponent() {
  2. const fetcher = useFetcher();
  3. useEffect(() => {
  4. if (fetcher.state === "idle" && fetcher.data == null) {
  5. fetcher.load("/some/route");
  6. }
  7. }, [fetcher]);
  8. fetcher.data; // the data from the loader
  9. }

Although a URL matches multiple Routes in a remix router hierarchy, a fetcher.load() call will only call the loader on the deepest matching route, unless the deepest matching route is an “index route”. In this case, it will load the parent route of the index route (because they share the same URL).

If you want to load an index route use ?index in the URL:

  1. fetcher.load("/some/route?index");

See also:

Examples

Watch the 📼 Remix Single: Remix Newsletter Signup Form

Newsletter Signup Form

Perhaps you have a persistent newsletter signup at the bottom of every page on your site. This is not a navigation event, so useFetcher is perfect for the job. First, you create a Resource Route:

  1. export async function action({ request }: ActionArgs) {
  2. const email = (await request.formData()).get("email");
  3. try {
  4. await subscribe(email);
  5. return json({ error: null, ok: true });
  6. } catch (error) {
  7. return json({ error: error.message, ok: false });
  8. }
  9. }

Then, somewhere else in your app (your root layout in this example), you render the following component:

  1. // ...
  2. function NewsletterSignup() {
  3. const newsletter = useFetcher();
  4. const ref = useRef();
  5. useEffect(() => {
  6. if (
  7. newsletter.state === "idle" &&
  8. newsletter.data?.ok
  9. ) {
  10. ref.current.reset();
  11. }
  12. }, [newsletter]);
  13. return (
  14. <newsletter.Form
  15. ref={ref}
  16. method="post"
  17. action="/newsletter/subscribe"
  18. >
  19. <p>
  20. <input type="text" name="email" />{" "}
  21. <button
  22. type="submit"
  23. disabled={newsletter.state === "submitting"}
  24. >
  25. Subscribe
  26. </button>
  27. </p>
  28. {newsletter.state === "idle" && newsletter.data ? (
  29. newsletter.data.ok ? (
  30. <p>Thanks for subscribing!</p>
  31. ) : newsletter.data.error ? (
  32. <p data-error>{newsletter.data.error}</p>
  33. ) : null
  34. ) : null}
  35. </newsletter.Form>
  36. );
  37. }

You can still provide a no-JavaScript experience

Because useFetcher doesn’t cause a navigation, it won’t automatically work if there is no JavaScript on the page like a normal Remix <Form> will, because the browser will still navigate to the form’s action.

If you want to support a no JavaScript experience, just export a component from the route with the action.

  1. export async function action({ request }: ActionArgs) {
  2. // just like before
  3. }
  4. export default function NewsletterSignupRoute() {
  5. const newsletter = useActionData<typeof action>();
  6. return (
  7. <Form method="post" action="/newsletter/subscribe">
  8. <p>
  9. <input type="text" name="email" />{" "}
  10. <button type="submit">Subscribe</button>
  11. </p>
  12. {newsletter.data.ok ? (
  13. <p>Thanks for subscribing!</p>
  14. ) : newsletter.data.error ? (
  15. <p data-error>{newsletter.data.error}</p>
  16. ) : null}
  17. </Form>
  18. );
  19. }
  • When JS is on the page, the user will subscribe to the newsletter and the page won’t change, they’ll just get a solid, dynamic experience.
  • When JS is not on the page, they’ll be transitioned to the signup page by the browser.

You could even refactor the component to take props from the hooks and reuse it:

  1. import { Form, useFetcher } from "@remix-run/react";
  2. // used in the footer
  3. export function NewsletterSignup() {
  4. const newsletter = useFetcher();
  5. return (
  6. <NewsletterForm
  7. Form={newsletter.Form}
  8. data={newsletter.data}
  9. state={newsletter.state}
  10. />
  11. );
  12. }
  13. // used here and in the route
  14. export function NewsletterForm({ Form, data, state }) {
  15. // refactor a bit in here, just read from props instead of useFetcher
  16. }

And now you could reuse the same form, but it gets data from a different hook for the no-js experience:

  1. import { Form } from "@remix-run/react";
  2. import { NewsletterForm } from "~/NewsletterSignup";
  3. export default function NewsletterSignupRoute() {
  4. const data = useActionData<typeof action>();
  5. return (
  6. <NewsletterForm Form={Form} data={data} state="idle" />
  7. );
  8. }

Mark Article as Read

Imagine you want to mark that an article has been read by the current user, after they’ve been on the page for a while and scrolled to the bottom. You could make a hook that looks something like this:

  1. function useMarkAsRead({ articleId, userId }) {
  2. const marker = useFetcher();
  3. useSpentSomeTimeHereAndScrolledToTheBottom(() => {
  4. marker.submit(
  5. { userId },
  6. {
  7. method: "post",
  8. action: `/article/${articleID}/mark-as-read`,
  9. }
  10. );
  11. });
  12. }

User Avatar Details Popup

Anytime you show the user avatar, you could put a hover effect that fetches data from a loader and displays it in a popup.

  1. export async function loader({ params }: LoaderArgs) {
  2. return json(
  3. await fakeDb.user.find({ where: { id: params.id } })
  4. );
  5. }
  6. function UserAvatar({ partialUser }) {
  7. const userDetails = useFetcher<typeof loader>();
  8. const [showDetails, setShowDetails] = useState(false);
  9. useEffect(() => {
  10. if (
  11. showDetails &&
  12. userDetails.state === "idle" &&
  13. !userDetails.data
  14. ) {
  15. userDetails.load(`/users/${user.id}/details`);
  16. }
  17. }, [showDetails, userDetails]);
  18. return (
  19. <div
  20. onMouseEnter={() => setShowDetails(true)}
  21. onMouseLeave={() => setShowDetails(false)}
  22. >
  23. <img src={partialUser.profileImageUrl} />
  24. {showDetails ? (
  25. userDetails.state === "idle" && userDetails.data ? (
  26. <UserPopup user={userDetails.data} />
  27. ) : (
  28. <UserPopupLoading />
  29. )
  30. ) : null}
  31. </div>
  32. );
  33. }

Async Reach UI Combobox

If the user needs to select a city, you could have a loader that returns a list of cities based on a query and plug it into a Reach UI combobox:

  1. export async function loader({ request }: LoaderArgs) {
  2. const url = new URL(request.url);
  3. return json(
  4. await searchCities(url.searchParams.get("city-query"))
  5. );
  6. }
  7. function CitySearchCombobox() {
  8. const cities = useFetcher<typeof loader>();
  9. return (
  10. <cities.Form method="get" action="/city-search">
  11. <Combobox aria-label="Cities">
  12. <div>
  13. <ComboboxInput
  14. name="city-query"
  15. onChange={(event) =>
  16. cities.submit(event.target.form)
  17. }
  18. />
  19. {cities.state === "submitting" ? (
  20. <Spinner />
  21. ) : null}
  22. </div>
  23. {cities.data ? (
  24. <ComboboxPopover className="shadow-popup">
  25. {cities.data.error ? (
  26. <p>Failed to load cities :(</p>
  27. ) : cities.data.length ? (
  28. <ComboboxList>
  29. {cities.data.map((city) => (
  30. <ComboboxOption
  31. key={city.id}
  32. value={city.name}
  33. />
  34. ))}
  35. </ComboboxList>
  36. ) : (
  37. <span>No results found</span>
  38. )}
  39. </ComboboxPopover>
  40. ) : null}
  41. </Combobox>
  42. </cities.Form>
  43. );
  44. }