Fresh logo

Partials

Partials let you update a sub-section of the page during navigation. When the user clicks a link or submits a form, Fresh fetches the destination, picks out the relevant region, and swaps it into the live page. The rest of the page stays mounted. Island state survives. The browser does not reload the document.

This makes the application feel more app-like. Only the parts of the page that need to change actually change.

Partial navigation is still browser navigation. The address bar updates, the back and forward buttons work, the browser’s loading indicator runs while the request is in flight, and the stop button cancels it. None of that has to be reimplemented in JavaScript. Fresh hooks into the browser’s Navigation API, so the page swap is just another history entry from the browser’s point of view.

Enabling partials

Partials are enabled by adding an f-client-nav attribute to an HTML element and wrapping one or more areas of the page in a <Partial name="..."> component from fresh/runtime.

The quickest way to get started is to enable partials for every page in routes/_app.tsx.

Typescript routes/_app.tsx
  import { app } from "./$_app.ts";
+ import { Partial } from "fresh/runtime";

  export default app(({ children }) => (
    <html lang="en">
      <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>My Fresh app</title>
      </head>
-     <body>
+     <body f-client-nav>
+       <Partial name="body">
          {children}
+       </Partial>
      </body>
    </html>
  ));

The f-client-nav attribute opts every element beneath the <body> tag into client-driven navigation. Each <Partial> marks a region that can be replaced. Every partial needs a unique name.

Behind the scenes, when the user clicks an <a> tag, Fresh fetches the new page and pulls out the regions that match a <Partial> on the current page. The matching live regions are updated in place.

The destination route renders a <Partial> with the same name. Its content replaces the live region.

Typescript routes/recipes/lemonade.tsx
import { page } from "./$lemonade.ts";
import { Partial } from "fresh/runtime";

export default page(() => (
  <Partial name="body">
    <h2>Lemonade</h2>
    <ul>
      <li>9 lemons</li>
      <li>1 cup sugar</li>
    </ul>
  </Partial>
));

When the request is a partial navigation, Fresh renders only the <Partial> regions in the response. No document shell, no other markup. The response is small.

Info

The name prop of <Partial> is expected to be unique among partials on a page. That’s how Fresh knows which area of the response goes where.

Info

Setting f-client-nav={false} on an element disables client-side navigation for that element and everything below it.

Optimizing partial requests

By default Fresh fetches the full next page and picks the relevant regions out of the response. The handler still runs, so the work is roughly the same as a normal navigation, even though only the partials are sent back.

To do less work on the server, branch on ctx.isPartial in the handler. Skip data the swapped region does not use, or short-circuit to render just the changing parts.

Typescript routes/docs/[id].tsx
import { handler, page } from "./$[id].ts";
import { Partial } from "fresh/runtime";

export const handlers = handler({
  async GET(ctx) {
    if (ctx.isPartial) {
      // Skip the sidebar data, it isn't part of the swapped region.
      const content = await loadContent(ctx.params.id);
      return { data: { content, links: [] } };
    }
    const [content, links] = await Promise.all([loadContent(ctx.params.id), loadSidebarLinks()]);
    return { data: { content, links } };
  },
});

export default page((props) => (
  <div>
    <aside>
      {props.data.links.map((l) => (
        <a key={l.href} href={l.href}>
          {l.title}
        </a>
      ))}
    </aside>
    <Partial name="docs-content">{props.data.content}</Partial>
  </div>
));

If you want a link to fetch its content from a different URL than the one it navigates to, add the f-partial attribute. Fresh navigates to the href URL but fetches the partial response from f-partial. This is useful when the same page has a fast partial-only route alongside the full one.

Typescript routes/docs/[id].tsx
  <aside>
-   <a href="/docs/page1">Page 1</a>
-   <a href="/docs/page2">Page 2</a>
+   <a href="/docs/page1" f-partial="/partials/docs/page1">Page 1</a>
+   <a href="/docs/page2" f-partial="/partials/docs/page2">Page 2</a>
  </aside>
Info

f-partial works on <a>, <form>, and submit buttons.

Sending multiple partials at the same time

A response can return as many partials as you want. Each one is matched by name against the live page and applied. This is useful for swapping two unrelated parts of the page in one round trip, for example a shopping cart.

Typescript routes/partials/cart.tsx
import { page } from "./$cart.ts";
import { Partial } from "fresh/runtime";

export default page(() => (
  <>
    <Partial name="cart-items" mode="append">
      {/* Render the new cart item here. */}
    </Partial>
    <Partial name="total-price">
      <p>Total: 12.50 €</p>
    </Partial>
  </>
));

Both partials are applied to the current page.

Replacement modes

By default the content inside a partial is replaced. There are also modes for inserting new content before or after the existing content. Set the mode prop on the <Partial> to pick.

Mode Behavior
replace Swap out the content of the existing partial (default).
prepend Insert the new content before the existing content.
append Insert the new content after the existing content.

append is useful for UI that displays a growing list, like log lines or a chat transcript.

Typescript routes/log.tsx
import { page } from "./$log.ts";
import { Partial } from "fresh/runtime";

export default page(() => {
  const lines = getNewLogLines();
  return (
    <Partial name="logs-list" mode="append">
      {lines.map((line) => (
        <li key={line}>{line}</li>
      ))}
    </Partial>
  );
});
Info

When picking prepend or append, make sure to add a key to the new elements.

Keeping island state intact across partial navigations

By default a swap replaces the islands inside the partial. Each new island mounts from scratch. Its hooks, refs, signals, and useEffect mount-once side effects all start over.

Give an island a Preact key to keep it across navigations. When the response carries an island with the same key, Fresh reuses the one that is already on the page. The component instance stays the same. Its signals, refs, and hook state stay. Props from the new response are applied to it in place.

Take a dashboard whose URL controls the time range, with a <Chart> island showing the data. Wrapping the chart in a <Partial> and giving it a key keeps the same chart instance across range switches, so its animations, zoom level, and any other hook state continue uninterrupted while only the series prop updates.

Typescript routes/dashboard/[range].tsx
import { handler, page } from "./$[range].ts";
import { Partial } from "fresh/runtime";
import { Chart } from "../../islands/Chart.tsx";

export const handlers = handler({
  async GET(ctx) {
    return { data: { series: await loadSeries(ctx.params.range) } };
  },
});

export default page(({ data, params }) => (
  <div f-client-nav>
    <nav>
      <a href="/dashboard/day">Day</a>
      <a href="/dashboard/week">Week</a>
      <a href="/dashboard/month">Month</a>
    </nav>
    <Partial name="dashboard">
      <h1>{params.range}</h1>
      <Chart key="main-chart" series={data.series} />
    </Partial>
  </div>
));

Clicking a link runs a partial swap of the dashboard partial. The <Chart> carries the same key on every render of this route, so it survives the swap and just sees a new series prop.

A few things to keep in mind:

  • A key only keeps an island across replace swaps. append and prepend do not remove anything, so there is nothing to keep.
  • The Component on both sides should be the same. Pointing the same key at a different island is not supported.
  • A keyed island on the current page that does not appear in the response is removed along with the rest of the swap.
  • A key is only meaningful within a single <Partial>. Two islands with the same key inside the same partial response are not supported.

Bypassing or disabling partials

To exempt a particular element from triggering a partial request, set f-client-nav={false} on the element or on one of its ancestors.

Typescript  
<body f-client-nav>
  {/* This causes a partial navigation. */}
  <a href="/docs/page1">With partials</a>

  {/* This does NOT cause a partial navigation. */}
  <a href="/docs/page1" f-client-nav={false}>
    No partials
  </a>

  {/* No element below this div causes a partial navigation. */}
  <div f-client-nav={false}>
    <div>
      <a href="/docs/page1">No partials</a>
    </div>
  </div>
</body>

When an element is clicked, Fresh walks up the DOM looking for an f-client-nav attribute. If the nearest one is true, a partial navigation is triggered. If it is false, or if no ancestor sets the attribute, the browser performs a normal navigation.

How it works

Partial navigation is built on the browser’s Navigation API. One listener handles link clicks, form submissions, and back/forward traversal. For each, Fresh:

  1. Walks up from the source element looking for f-client-nav. If there is no enabling ancestor, the browser navigates normally.
  2. Fetches the destination with a Fresh-Partial header. The handler can branch on ctx.isPartial.
  3. Swaps each returned region into the matching live <Partial>, syncs the <Head>, and hydrates any islands that landed in the swapped DOM.

On a browser without the Navigation API, or on a fetch error, Fresh falls back to a full page load.

Loading indicator

The navigating signal from fresh/runtime is true while a partial navigation is in flight. Drive a spinner, progress bar, or any other indicator off it.

Typescript islands/Spinner.tsx
import { navigating } from "fresh/runtime";

export function Spinner() {
  return navigating.value ? <div class="spinner" /> : null;
}