useFetcher
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:
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.
import { useFetcher } from "@remix-run/react";
function SomeComponent() {
const fetcher = useFetcher();
// trigger the fetch with these
<fetcher.Form {...formOptions} />;
useEffect(() => {
fetcher.submit(data, options);
fetcher.load(href);
}, [fetcher]);
// build UI with these
fetcher.state;
fetcher.type;
fetcher.submission;
fetcher.data;
}
Notes about how it works:
ErrorBoundary
(just like a normal navigation from <Link>
or <Form>
)<Link>
or <Form>
)fetcher.state
You can know the state of the fetcher with fetcher.state
. It will be one of:
fetcher.type
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"
fetcher.data
.state === "submitting"
state === "loading"
fetcher.load()
).fetcher.submission
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. (You'll get over the dot in JSX, don't worry.)
function SomeComponent() {
const fetcher = useFetcher();
return (
<fetcher.Form method="post" action="/some/route">
<input type="text" />
</fetcher.Form>
);
}
fetcher.submit()
Just like useSubmit
except it doesn't cause a navigation.
function SomeComponent() {
const fetcher = useFetcher();
const onClick = () =>
fetcher.submit({ some: "values" }, { method: "post" });
// ...
}
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:
fetcher.submit(
{ some: "values" },
{ method: "post", action: "/accounts?index" }
);
See also:
fetcher.load()
Loads data from a route loader.
function SomeComponent() {
const fetcher = useFetcher();
useEffect(() => {
if (fetcher.type === "init") {
fetcher.load("/some/route");
}
}, [fetcher]);
fetcher.data; // the data from the loader
}
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:
fetcher.load("/some/route?index");
See also:
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:
export async function action({ request }: ActionArgs) {
const email = (await request.formData()).get("email");
try {
await subscribe(email);
return json({ error: null, ok: true });
} catch (error) {
return json({ error: error.message, ok: false });
}
}
Then, somewhere else in your app (your root layout in this example), you render the following component:
// ...
function NewsletterSignup() {
const newsletter = useFetcher();
const ref = useRef();
useEffect(() => {
if (newsletter.type === "done" && newsletter.data.ok) {
ref.current.reset();
}
}, [newsletter]);
return (
<newsletter.Form
ref={ref}
method="post"
action="/newsletter/subscribe"
>
<p>
<input type="text" name="email" />{" "}
<button
type="submit"
disabled={newsletter.state === "submitting"}
>
Subscribe
</button>
</p>
{newsletter.type === "done" ? (
newsletter.data.ok ? (
<p>Thanks for subscribing!</p>
) : newsletter.data.error ? (
<p data-error>{newsletter.data.error}</p>
) : null
) : null}
</newsletter.Form>
);
}
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.
export async function action({ request }: ActionArgs) {
// just like before
}
export default function NewsletterSignupRoute() {
const newsletter = useActionData<typeof action>();
return (
<Form method="post" action="/newsletter/subscribe">
<p>
<input type="text" name="email" />{" "}
<button type="submit">Subscribe</button>
</p>
{newsletter.data.ok ? (
<p>Thanks for subscribing!</p>
) : newsletter.data.error ? (
<p data-error>{newsletter.data.error}</p>
) : null}
</Form>
);
}
You could even refactor the component to take props from the hooks and reuse it:
import { Form, useFetcher } from "@remix-run/react";
// used in the footer
export function NewsletterSignup() {
const newsletter = useFetcher();
return (
<NewsletterForm
Form={newsletter.Form}
data={newsletter.data}
state={newsletter.state}
type={newsletter.type}
/>
);
}
// used here and in the route
export function NewsletterForm({
Form,
data,
state,
type,
}) {
// refactor a bit in here, just read from props instead of useFetcher
}
And now you could reuse the same form, but it gets data from a different hook for the no-js experience:
import { Form } from "@remix-run/react";
import { NewsletterForm } from "~/NewsletterSignup";
export default function NewsletterSignupRoute() {
const data = useActionData<typeof action>();
return (
<NewsletterForm
Form={Form}
data={data}
state="idle"
type="done"
/>
);
}
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:
function useMarkAsRead({ articleId, userId }) {
const marker = useFetcher();
useSpentSomeTimeHereAndScrolledToTheBottom(() => {
marker.submit(
{ userId },
{
method: "post",
action: `/article/${articleID}/mark-as-read`,
}
);
});
}
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.
export async function loader({ params }: LoaderArgs) {
return json(
await fakeDb.user.find({ where: { id: params.id } })
);
}
function UserAvatar({ partialUser }) {
const userDetails = useFetcher<typeof loader>();
const [showDetails, setShowDetails] = useState(false);
useEffect(() => {
if (showDetails && userDetails.type === "init") {
userDetails.load(`/users/${user.id}/details`);
}
}, [showDetails, userDetails]);
return (
<div
onMouseEnter={() => setShowDetails(true)}
onMouseLeave={() => setShowDetails(false)}
>
<img src={partialUser.profileImageUrl} />
{showDetails ? (
userDetails.type === "done" ? (
<UserPopup user={userDetails.data} />
) : (
<UserPopupLoading />
)
) : null}
</div>
);
}
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:
export async function loader({ request }: LoaderArgs) {
const url = new URL(request.url);
return json(
await searchCities(url.searchParams.get("city-query"))
);
}
function CitySearchCombobox() {
const cities = useFetcher<typeof loader>();
return (
<cities.Form method="get" action="/city-search">
<Combobox aria-label="Cities">
<div>
<ComboboxInput
name="city-query"
onChange={(event) =>
cities.submit(event.target.form)
}
/>
{cities.state === "submitting" ? (
<Spinner />
) : null}
</div>
{cities.data ? (
<ComboboxPopover className="shadow-popup">
{cities.data.error ? (
<p>Failed to load cities :(</p>
) : cities.data.length ? (
<ComboboxList>
{cities.data.map((city) => (
<ComboboxOption
key={city.id}
value={city.name}
/>
))}
</ComboboxList>
) : (
<span>No results found</span>
)}
</ComboboxPopover>
) : null}
</Combobox>
</cities.Form>
);
}