The difference between a great user experience on the web and mediocre one is how well the developer implements network-aware user interface feedback by providing visual cues during network-intensive actions. There are three main types of pending UI: busy indicators, optimistic UI, and skeleton fallbacks. This document provides guidelines for selecting and implementing the appropriate feedback mechanism based on specific scenarios.
Busy Indicators: Busy indicators display visual cues to users while an action is being processed by the server. This feedback mechanism is used when the application cannot predict the outcome of the action and must wait for the server's response before updating the UI.
Optimistic UI: Optimistic UI enhances perceived speed and responsiveness by immediately updating the UI with an expected state before the server's response is received. This approach is used when the application can predict the outcome of an action based on context and user input, allowing for an immediate response to actions.
Skeleton Fallbacks: Skeleton fallbacks are used when the UI is initially loading, providing users with a visual placeholder that outlines the structure of the upcoming content. This feedback mechanism is particularly useful to render something useful as soon as possible.
Use Optimistic UI:
Use Busy Indicators:
Use Skeleton Fallbacks:
Busy Indicator: You can indicate the user is navigating to a new page with useNavigation
:
import { useNavigation } from "@remix-run/react";
function PendingNavigation() {
const navigation = useNavigation();
return navigation.state === "loading" ? (
<div className="spinner" />
) : null;
}
Busy Indicator: You can indicate on the nav link itself that the user is navigating to it with the <NavLink className>
callback.
import { NavLink } from "@remix-run/react";
export function ProjectList({ projects }) {
return (
<nav>
{projects.map((project) => (
<NavLink
key={project.id}
to={project.id}
className={({ isPending }) =>
isPending ? "pending" : null
}
>
{project.name}
</NavLink>
))}
</nav>
);
}
Or add a spinner next to it by inspecting params:
import { useParams } from "@remix-run/react";
export function ProjectList({ projects }) {
const params = useParams();
return (
<nav>
{projects.map((project) => (
<NavLink key={project.id} to={project.id}>
{project.name}
{params.projectId === project.id ? (
<Spinner />
) : null}
</NavLink>
))}
</nav>
);
}
While localized indicators on links are nice, they are incomplete. There are many other ways a navigation can be triggered: form submissions, back and forward button clicks in the browser chrome, action redirects, and imperative navigate(path)
calls, so you'll typically want a global indicator to capture everything.
Busy Indicator: It's typically best to wait for a record to be created instead of using optimistic UI since things like IDs and other fields are unknown until it completes. Also note this action redirects to the new record from the action.
import type { ActionArgs } from "@remix-run/node"; // or cloudflare/deno
import { redirect } from "@remix-run/node"; // or cloudflare/deno
import { useNavigation } from "@remix-run/react";
export async function action({ request }: ActionArgs) {
const formData = await request.formData();
const project = await createRecord({
name: formData.get("name"),
owner: formData.get("owner"),
});
return redirect(`/projects/${project.id}`);
}
export default function CreateProject() {
const navigation = useNavigation();
// important to check you're submitting to the action
// for the pending UI, not just any action
const isSubmitting =
navigation.formAction === "/create-project";
return (
<Form method="post" action="/create-project">
<fieldset disabled={isSubmitting}>
<label>
Name: <input type="text" name="projectName" />
</label>
<label>
Owner: <UserSelect />
</label>
<button type="submit">Create</button>
</fieldset>
{isSubmitting ? <BusyIndicator /> : null}
</Form>
);
}
You can do the same with useFetcher
, which is useful if you aren't changing the URL (maybe adding the record to a list)
import { useFetcher } from "@remix-run/react";
function CreateProject() {
const fetcher = useFetcher();
const isSubmitting = fetcher.state === "submitting";
return (
<fetcher.Form method="post" action="/create-project">
{/* ... */}
</fetcher.Form>
);
}
Optimistic UI: When the UI simply updates a field on a record, optimistic UI is a great choice. Many, if not most user interactions in a web app tend to be updates, so this is a common pattern.
import { useFetcher } from "@remix-run/react";
function ProjectListItem({ project }) {
const fetcher = useFetcher();
const starred = fetcher.formData
? // use to optimistic value if submitting
fetcher.formData.get("starred") === "1"
: // fall back to the database state
project.starred;
return (
<>
<div>{project.name}</div>
<fetcher.Form method="post">
<button
type="submit"
name="starred"
// use optimistic value to allow interruptions
value={starred ? "0" : "1"}
>
{/* display optimistic value */}
{starred ? "โ" : "โ
"}
</button>
</fetcher.Form>
</>
);
}
Skeleton Fallback: When data is deferred, you can add fallbacks with <Suspense>
. This allows the UI to render without waiting for the data to load, speeding up the perceived and actual performance of the application.
import type { LoaderArgs } from "@remix-run/node"; // or cloudflare/deno
import { defer } from "@remix-run/node"; // or cloudflare/deno
import { Await } from "@remix-run/react";
import { Suspense } from "react";
export async function loader({ params }: LoaderArgs) {
const reviewsPromise = getReviews(params.productId);
const product = await getProduct(params.productId);
return defer({
product: product,
reviews: reviewsPromise,
});
}
export default function ProductRoute() {
const { product, reviews } =
useLoaderData<typeof loader>();
return (
<>
<ProductPage product={product} />
<Suspense fallback={<ReviewsSkeleton />}>
<Await resolve={reviews}>
{(reviews) => <Reviews reviews={reviews} />}
</Await>
</Suspense>
</>
);
}
When creating skeleton fallbacks, consider the following principles:
<Link prefetch="intent">
can often skip the fallbacks completely. When users hover or focus on the link, this method preloads the needed data, allowing the network a quick moment to fetch content before the user clicks. This often results in an immediate navigation to the next page.Creating network-aware UI via busy indicators, optimistic UI, and skeleton fallbacks significantly improves the user experience by showing visual cues during actions that require network interaction. Getting good at this is the best way to build applications your users trust.