Data loading and mutations

The product page from the introduction fetches its product inside a memo. That works, and three things about it are not quite right yet: the request does not start until the page component runs, a ProductCard in a list and the Product page fetch the same product twice, and the Add to cart form has no way to tell the cart it changed.

The router's data APIs add one thing for each: preload starts a request before the page's component exists, query gives two readers one request, and a server function called from an action writes, with the router revalidating the cache from its response; formAction puts the same mutation behind a form that works without JavaScript. This page continues the store: a product page and a cart.

All of it is optional

A Solid Router app can load data with plain async memos and submit forms with plain server functions, and the Async reactivity page is all it needs. Each piece below earns its place on its own, so take the ones the page in front of you needs.


The three pieces

Here is the product page with each piece labeled:

src/data/products.ts
import { query } from "@solidjs/router";
// 1. A cached read. Same name + same arguments = same request.
export const getProduct = query(async (id: string) => {
const response = await fetch(`/api/products/${id}`);
return (await response.json()) as Product;
}, "product");

In the routes array of src/router.ts:

{
path: "/products/:id",
component: lazy(() => import("./pages/Product")),
// 2. Start it as soon as the route matches, before the component runs.
preload: ({ params }) => void getProduct(params.id),
}
src/pages/Product.tsx
import { createMemo } from "solid-js";
import type { RouteProps } from "@solidjs/router";
import { getProduct } from "../data/products";
import type { Router } from "../router";
// 3. Read it where it is displayed.
export default function Product(
props: RouteProps<typeof Router.paths.products>
) {
const product = createMemo(() => getProduct(props.params.id));
return <h1>{product().name}</h1>;
}

With link preloading turned on, hover a product link and the request appears in the network tab before the click. Click, and the heading renders with the name already there.

A timeline from hovering the link, to the click, to the component running. The preload calls the query on hover; the memo in the component reads the same query; both reach one cached request, started at the earliest call.

Delete piece 2 and the page still works; the request starts when the memo first runs, a few milliseconds later. Delete piece 1 and the page still works; two components that both need the product would each fetch it. With the file-system adapter, piece 2 moves into the route module's route export, as Convert a file-system manifest shows.


Start work before the component runs

A route's preload runs when the route is matched, with the matched params, the target location, and an intent:

preload: ({ params, intent }) => {
void getProduct(params.id);
if (intent !== "preload") void getReviews(params.id);
},

The intent tells you why the work started:

intentWhen
"initial"First render, including the server render
"navigate"A link click or navigate() call
"native"Browser back or forward
"preload"A link preload strategy or usePreloadRoute

Use it to skip work that is too expensive to run on a hover.

The tempting shape, from routers where the loader is the data, is to return what the page needs:

// Avoid: props.data is captured once, so a param change shows the old product
preload: async ({ params }) => getProduct(params.id),
// Prefer: start the request and let the page read it reactively
preload: ({ params }) => void getProduct(params.id),

With the Avoid version, a page that renders props.data.name shows the mug after navigating from /products/mug to /products/bowl, because props.data is whatever preload returned when the route first matched. The Prefer version reads the query in a memo that tracks props.params.id, so the bowl appears. Return a value from preload only for something that should not change while the route stays matched.

Links do not preload until you give the router a strategy with preloadLinks. intentPreload() preloads when a user rests the pointer on a link, focuses it, or touches it:

src/router.ts
import { createRouter, intentPreload } from "@solidjs/router";
export const Router = createRouter({ routes, preloadLinks: intentPreload() });

That warms the lazy component chunk and runs the route's preload. The query cache keeps the result for a few seconds so the navigation that follows finds it ready.

The other strategies combine with it in an array:

StrategyPreloadsRuns route preload by default
intentPreload()On hover after a short delay, on focus, and on touchYes
tapPreload()On pointerdown, ahead of the clickYes
viewportPreload()Links marked preload="viewport" once they have stayed in viewNo
eagerPreload()Links marked preload="eager" once the page has loaded and the browser is idleNo

Pass { data: false } or { data: true } to change the last column. The router factory reference lists every option.

Opt one link out of preloading, chunk and data, when it is expensive or has side effects:

<a href={paths.account.orders} preload="false">
Orders
</a>

To warm a route from code, for example when a list scrolls into view:

const preloadRoute = usePreloadRoute();
preloadRoute(paths.products("mug"), { preloadData: true });

Cache reads with query

query wraps an async function and gives it a name. The name plus the arguments form the cache key, and every call with the same key during the cache lifetime shares one request and one result:

export const getProduct = query(fetchProduct, "product");
getProduct.key; // "product"
getProduct.keyFor("mug"); // 'product["mug"]'

That is what lets the preload and the component share a request, and what lets a ProductCard in a list and the Product page share one when they read the same id.

The cache has a different lifetime in each place it runs:

  • On the server it lives for one request. Two components rendering the same product during one server render make one call; the next request starts empty.
  • In the browser a fresh result is reused for a few seconds, so a preload and the navigation that follows share it. After that, an entry stays alive as long as something is reading it and is swept a few minutes after the last reader goes away. Browser back and forward can reuse a retained entry, which is why returning to a page is usually instant.

In a fullstack project the function inside query is normally a server function. When that function has no declared method, query declares it as a GET so the read is cacheable by the browser and CDNs and never goes through the mutation path.

Read a query through a memo, as with any async value:

const product = createMemo(() => getProduct(props.params.id));

When props.params.id changes, the memo calls getProduct with the new id, which is a cache miss and a new request. The old product stays on screen while it loads; Async reactivity explains that behavior and the choices it gives you.

Revalidate

A cached value stays until something invalidates it. Call revalidate with a key to mark matching entries stale and re-run the ones that are being read:

import { revalidate } from "@solidjs/router";
revalidate(getProduct.keyFor("mug")); // one product
revalidate(getProduct.key); // every product
revalidate(); // everything

Keys match by prefix, so the broad key reaches every argument combination. Calling this by hand is rare; the usual trigger is a mutation finishing, covered below.


Mutate with a server function

A mutation is a server function that writes, called from an action. Start with the server function:

src/data/cart.ts
import { query } from "@solidjs/router";
import { markSafeError, reload } from "@solidjs/web";
import { currentSessionId } from "./session";
export const getCart = query(async () => {
"use server";
return db.cart.forSession(currentSessionId());
}, "cart");
export async function addToCart(productId: string, quantity = 1) {
"use server";
if (!Number.isInteger(quantity) || quantity < 1) {
throw markSafeError(new Error("Quantity must be a whole number"));
}
await db.cart.add(currentSessionId(), productId, quantity);
return reload({ revalidate: getCart.key });
}

currentSessionId() stands for whatever your session layer provides; Sessions and auth shows one built on a signed cookie.

Then call it from an action:

src/pages/Product.tsx
import { action } from "solid-js";
import { addToCart } from "../data/cart";
const add = action(function* (productId: string) {
yield addToCart(productId);
});
<button onClick={() => add(props.params.id)}>Add to cart</button>;

Click Add to cart and every reader of getCart shows the new line once the call returns. Nothing here is router-specific except the response: the mounted router applies the response of every server function call, so the reload revalidates getCart, a redirect navigates, and in a fullstack project single-flight data seeds the cache. The action makes the call one transaction, so the revalidated cart and anything the action wrote land as one update. A call made straight from an event handler, with no action around it, gets the same revalidation; the action is what lets the page predict the result and hold it together.

Before the server confirms

Write the expected result into an optimistic store before the yield:

import { For, action, createOptimisticStore } from "solid-js";
import { getCart, addToCart } from "../data/cart";
function CartPanel(props: { productId: string }) {
const [cart, setCart] = createOptimisticStore(
() => getCart(),
[] as CartLine[]
);
const add = action(function* (productId: string) {
setCart((draft) => {
draft.push({ productId, quantity: 1, pending: true });
});
yield addToCart(productId);
});
return (
<>
<button onClick={() => add(props.productId)}>Add to cart</button>
<ul>
<For each={cart}>
{(line) => (
<li class={{ pending: !!line.pending }}>
{line.productId} × {line.quantity}
</li>
)}
</For>
</ul>
</>
);
}

The new line appears at once with the pending class. When the call returns, the router's revalidation of getCart and the release of the optimistic line commit together: the line is replaced by the server's copy, or disappears if the server rejected it, with no frame of the old cart in between. Mutations explains the optimistic model in depth.

When it fails

A server function that throws rejects the yield, so the action can catch it:

import { action, createSignal } from "solid-js";
const [failure, setFailure] = createSignal<Error>();
const add = action(function* (productId: string, quantity: number) {
setFailure(undefined);
try {
yield addToCart(productId, quantity);
} catch (error) {
setFailure(error as Error);
}
});

Ask for a quantity of 0 and failure() holds "Quantity must be a whole number". The message reaches the browser because addToCart threw it through markSafeError. A plain throw new Error(...) arrives in production as Internal Server Error, so database and infrastructure details do not leak; Mutations and responses covers markSafeError and the alternative of throwing validation issues with respond().


Add a form with formAction

A formAction puts the same mutation behind a <form>. It adds what a form needs: a URL the browser can post to before JavaScript loads, aria-busy while the submission runs, a record of each submission, and hooks around it.

src/data/cart.ts
import { formAction } from "@solidjs/router";
export const addToCartForm = formAction(async (form: FormData) => {
"use server";
return addToCart(
String(form.get("productId")),
Number(form.get("quantity") ?? 1)
);
});
src/pages/Product.tsx
<form method="post" action={addToCartForm}>
<input type="hidden" name="productId" value={props.params.id} />
<input type="number" name="quantity" value="1" min="1" />
<button>Add to cart</button>
</form>

Read that form as a browser would. It is a POST to a URL with FormData, and it works with JavaScript disabled: the server runs the form action, sees the reload, and sends the browser back to the page. With JavaScript, the router intercepts the submit, calls the form action over the server-function transport, and applies the response the same way it does for a direct call.

Two rules follow from the form being the contract:

  • Form actions accept POST forms only; a GET form throws Only POST forms are supported for Actions. A GET form describes a URL, not a mutation; for search and filters, use a route with typed search parameters.
  • Everything the form action needs must be in the form or bound to it. Hidden inputs are the plain way; .with() is the typed way.

The Forms guide builds a complete form on this shape, including validation messages and no-JavaScript outcomes.

Bind arguments with .with()

When the argument is known where the form is rendered, bind it instead of adding a hidden input:

export const removeFromCart = formAction(
async (productId: string, form: FormData) => {
"use server";
await db.cart.remove(currentSessionId(), productId);
return reload({ revalidate: getCart.key });
}
);
<For each={cart()}>
{(line) => (
<form method="post" action={removeFromCart.with(line.productId)}>
<button>Remove</button>
</form>
)}
</For>;

.with(...) returns a form action whose remaining parameters start after the bound ones. The bound values travel in the form action's URL, so the no-JavaScript path receives them too.

While it runs

The router sets aria-busy="true" on the submitting form until the form action's result is on screen, including any revalidation or redirect it triggers. Style it in CSS and no component code is needed:

form[aria-busy] button {
opacity: 0.6;
pointer-events: none;
}

Read results with useSubmissions

useSubmissions(addToCartForm) returns a reactive list of the form action's completed submissions that produced a result or an error:

import { Show } from "solid-js";
import { useSubmissions } from "@solidjs/router";
function AddToCartForm(props: { productId: string }) {
const submissions = useSubmissions(addToCartForm);
const failure = () => submissions.at(-1)?.error as Error | undefined;
return (
<form method="post" action={addToCartForm}>
<input type="hidden" name="productId" value={props.productId} />
<input type="number" name="quantity" value="1" min="1" />
<button>Add to cart</button>
<Show when={failure()}>
{(error) => <p role="alert">{error().message}</p>}
</Show>
</form>
);
}

Submit a quantity of 0 and "Quantity must be a whole number" appears under the button. Each submission carries the input that was sent, the result or error, and retry() and clear(). A submission that redirected or returned nothing does not stay in the list; use .onSettled when you need to observe those too.

Optimistic writes from a form

A form has no action body to write in, so .onSubmit runs a callback with the form action's arguments before the mutation is sent, inside the same transaction:

addToCartForm.onSubmit((form) => {
setCart((draft) => {
draft.push({
productId: String(form.get("productId")),
quantity: Number(form.get("quantity") ?? 1),
pending: true,
});
});
});

With setCart from the optimistic store in Before the server confirms, the form shows the same pending line and the same release as the action did. Registering .onSubmit inside a component ties the hook to that component's lifetime.

The form did not change to get this. Before hydration it is the same POST as before: the browser submits, the server runs the form action, and the server renders the page again with the cart from the database. After hydration the router intercepts the submit, runs the .onSubmit hooks as the first step of the transaction, sends the call, and reconciles the revalidated data over the prediction.

Client-side form actions

A form action does not have to call a server function. A plain async function works, for a client-only mutation or a call to a third-party API:

const savePreference = formAction(async (form: FormData) => {
localStorage.setItem("theme", String(form.get("theme")));
}, "save-preference");
Name a client form action that renders on the server

A client form action rendered on the server needs the explicit name so both sides agree on the form's URL; without it the server render throws Client Actions need explicit names if server rendered. A server function already has a stable URL and does not need one.


What revalidates after a mutation

When a mutation completes, the router revalidates the query cache. A mutation is a form action or any POST server function call, whether it came from a form, a core action, or an event handler; POST is the default method for server functions. By default that means every query on screen: the safest assumption after a write is that anything might have changed. Reads are exempt: query declares its server function as a GET, and so does wrapping one in GET(fn).

Narrow it from the server function's response:

return reload({ revalidate: getCart.key }); // this query only
return respond(cart, { revalidate: [getCart.key, getTotals.key] }); // a value plus keys
return redirect(paths.cart, { revalidate: getCart.key }); // navigate, then revalidate

A POST that does not change anything, such as a log call or a search sent on each keystroke, declares an empty revalidate so it touches nothing:

return respond(results, { revalidate: "" }); // revalidate nothing

A same-origin redirect becomes a router navigation; anything else leaves the app. The revalidation runs in the same update as the navigation, so the destination page renders with fresh data rather than flashing stale data first.

Deep diveWhy the redirect and the data land together

The router applies the response's redirect and revalidation metadata inside one update. Readers that survive the navigation, such as a cart count in the shared layout, refetch inside that update and hold the commit until they have a value, so the new page never paints with the old cart. In a fullstack project with the router's single-flight collector installed, the server goes one step further and includes the fresh query results in the mutation response, so the client seeds its cache from the payload instead of making a second round trip. Server rendering and hydration shows the setup.


Common problems

The list still shows the old data after a mutation

The response narrowed revalidate to keys that do not include that list's query; add the key or return reload() without keys to revalidate everything. If nothing revalidated at all, the mutation did not go through the router: a plain fetch to an API route, a call made where no router is mounted, or a server function called directly with singleFlight: false set on the router. Call a server function, submit a form action, or call revalidate yourself after the write.

'use' router primitives can be only used inside a Route when a query runs

A query read inside a reactive scope binds to the router, so a component that reads one outside the <Router>, for example next to it in App, throws this. Move the reader inside the router's function child or a route component.

The form action ran but useSubmissions shows nothing

The form action returned nothing or redirected. Only a submission that produced a result or an error enters the list; register .onSettled to observe every completion, including void ones.

The error message is Internal Server Error in production

The mutation threw a plain Error. Wrap it in markSafeError to send the message to the browser, or throw the validation issues with respond(issues, { status: 400 }); a returned envelope is a successful result, not an error.


Recap

  • Declare each read once with query(fn, "name"); the name plus the arguments is the cache key two readers share.
  • Start reads in the route's preload with void, and read them through a memo in the page; props.data is captured once.
  • Write through a server function called from an action; the mounted router applies its revalidation and redirect, and an optimistic store written before the yield shows the result at once.
  • Throw markSafeError or throw respond() for messages the user should see, and catch the rejected yield in the action.
  • Add formAction when the mutation needs a <form method="post" action={...}> that works before JavaScript loads; put everything it needs in the form or bind it with .with().
  • On a form, show progress with form[aria-busy], failures with useSubmissions, and optimistic state with .onSubmit.
  • Narrow revalidation with reload, respond, or redirect options; without keys, every query revalidates after any POST server function, and revalidate: "" opts out.

Next steps

  • Forms: a complete form on form actions, from no-JavaScript through inline validation.
  • Server rendering and hydration: hydrating query results and single-flight mutations.
  • Data fetching patterns: search, pagination, sharing, polling, and failures, with or without the router.
  • Protected routes: a preload and a query that redirect a signed-out visitor before the page paints.
  • Data API reference: signatures for query, liveQuery, revalidate, formAction, and useSubmissions.
Last updated: 10/20/26, 7:57 PMEdit this pageReport an issue with this page