Remix
02 / 02

Fetchers, Sessions & HTTP Caching

Remix: Fetchers, Sessions & HTTP Caching

useFetcher: Non-Navigational Mutations

import { useFetcher } from "@remix-run/react";

// A "like" button that submits without changing the URL or triggering
// a full navigation -- unlike <Form>, which does navigate
function LikeButton({ postId, initiallyLiked }: { postId: string; initiallyLiked: boolean }) {
  const fetcher = useFetcher();

  // Optimistic UI: while the submission is in flight, fetcher.formData
  // holds the pending values -- render the assumed result immediately
  const isLiked = fetcher.formData
    ? fetcher.formData.get("liked") === "true"
    : initiallyLiked;

  return (
    <fetcher.Form method="post" action={`/posts/${postId}/like`}>
      <input type="hidden" name="liked" value={String(!isLiked)} />
      <button type="submit">{isLiked ? "♥" : "♡"}</button>
    </fetcher.Form>
  );
}

// useNavigation() reflects pending navigation/submission state --
// commonly used for a loading indicator or disabling a submit button
import { useNavigation } from "@remix-run/react";

function SubmitButton() {
  const navigation = useNavigation();
  const isSubmitting = navigation.state === "submitting";
  return <button disabled={isSubmitting}>{isSubmitting ? "Saving..." : "Save"}</button>;
}

Sessions & Auth

// app/sessions.server.ts
import { createCookieSessionStorage, redirect } from "@remix-run/node";

export const sessionStorage = createCookieSessionStorage({
  cookie: {
    name: "__session",
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    secrets: [process.env.SESSION_SECRET!],
    sameSite: "lax",
  },
});

export async function requireUserId(request: Request) {
  const session = await sessionStorage.getSession(request.headers.get("Cookie"));
  const userId = session.get("userId");
  if (!userId) {
    throw redirect("/login");
  }
  return userId;
}

// In a protected route's loader
export async function loader({ request }: LoaderFunctionArgs) {
  const userId = await requireUserId(request);
  return json(await getDashboardData(userId));
}

HTTP Caching via headers()

// Remix leans on real HTTP semantics for caching, not a custom API
export function headers() {
  return {
    "Cache-Control": "public, max-age=300, s-maxage=3600",
  };
}

// Or set headers directly on the loader's Response
export async function loader() {
  const data = await getPublicStats();
  return json(data, {
    headers: { "Cache-Control": "public, max-age=60" },
  });
}

// A CDN/browser in front of the app can cache using these standard
// headers -- no Remix-specific cache invalidation API to learn.

Breadcrumbs via handle + useMatches

// A layout route can't normally see its active child route's specifics --
// handle + useMatches is the standard way to bridge that

// app/routes/projects.$projectId.tsx
export const handle = {
  breadcrumb: (data: { name: string }) => data.name,
};

// app/root.tsx (or any ancestor layout)
import { useMatches } from "@remix-run/react";

function Breadcrumbs() {
  const matches = useMatches();
  const crumbs = matches
    .filter((match) => match.handle?.breadcrumb)
    .map((match) => match.handle.breadcrumb(match.data));

  return <nav>{crumbs.join(" / ")}</nav>;
}

Keep your own version of these notes — editable, searchable, and organised by your stack.

Start free