Skip to content

Commit 22535df

Browse files
docs: add the Data fetching patterns guide
Worked shapes for the requests a real app makes: load one thing, search as you type, several requests per page, dependent requests, pagination and infinite scroll, sharing a request, keeping data fresh, mutate-then-refetch, and failure handling. Uses the one-line async store where rows need identity and links the async concept page and the Solid 1 migration page. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 6af49e6 commit 22535df

3 files changed

Lines changed: 303 additions & 0 deletions

File tree

src/routes/(2)concepts/(3)async-reactivity.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -769,6 +769,7 @@ Use `yield promise` as the suspension point, or place a bare `yield` before writ
769769
## Where to go next
770770

771771
- [Boundaries](/concepts/boundaries): where to place `Loading` and `Errored`, how `Reveal` orders sibling regions, and how an errored region recovers.
772+
- [Data fetching patterns](/guides/data-fetching-patterns): search as you type, several requests per page, pagination, sharing a request, polling, and failures, each as working code.
772773
- [Migrate data fetching from Solid 1](/migration/data-fetching-from-solid-1): what changes when `createResource` or an effect-and-flag pattern becomes an async memo, and how to keep the old feel where you want it.
773774
- [Server functions](/building-apps/server-functions): a `"use server"` function returns a promise, so everything on this page applies to it unchanged; that page covers the transport, `GET` reads, and `live` streams.
774775
- [Rendering and SSR](/concepts/rendering-and-ssr): the same boundaries decide what streams in the initial HTML.
Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
1+
---
2+
title: "Data fetching patterns"
3+
titleTemplate: ":title"
4+
mainNavExclude: true
5+
version: "2.0"
6+
description: "Worked patterns for loading data in Solid: search as you type, several requests on one page, dependent requests, pagination, sharing a request, keeping data fresh, and handling failures."
7+
---
8+
9+
The [Async reactivity](/concepts/async-reactivity) page explains the model: a computation that returns a promise is a value, first loads reach a `Loading` boundary, and later updates are held until the new answer is ready.
10+
This guide is the other half.
11+
It takes the requests a real application makes and shows the Solid shape for each one, including the choices that are yours to make.
12+
13+
The examples use a small storefront: products you can search, a product page with reviews, an order history, and a dashboard.
14+
Each `api.*` call is a function that returns a promise; a `"use server"` function, a `fetch` wrapper, or a client SDK all work the same way.
15+
16+
## Load one thing
17+
18+
The baseline everything else builds on:
19+
20+
```tsx
21+
import { Loading, createMemo } from "solid-js";
22+
23+
function ProductPage(props: { id: string }) {
24+
const product = createMemo(() => api.product(props.id));
25+
26+
return (
27+
<Loading fallback={<ProductSkeleton />}>
28+
<h1>{product().name}</h1>
29+
<p>{product().description}</p>
30+
</Loading>
31+
);
32+
}
33+
```
34+
35+
`product()` is a `Product`.
36+
When `props.id` changes, the request starts again, the current product stays on screen, and the new one replaces it when it arrives.
37+
If the page should show the skeleton again for a different product, add `on={props.id}` to the boundary.
38+
39+
When the data is a list or a tree whose items need identity across updates, load it into a store instead:
40+
41+
```tsx
42+
const [orders] = createStore(
43+
async () => api.orders(customerId()),
44+
[] as Order[]
45+
);
46+
```
47+
48+
The response reconciles into the same proxy by `id`, so a row that did not change keeps its DOM.
49+
[Stores](/concepts/stores#fetch-into-a-store) covers when that matters.
50+
51+
## Search as you type
52+
53+
An input writes a signal, and a memo turns the signal into results:
54+
55+
```tsx
56+
import { For, createMemo, createSignal, isPending, latest } from "solid-js";
57+
58+
function ProductSearch() {
59+
const [query, setQuery] = createSignal("");
60+
const results = createMemo(() => {
61+
const text = query().trim();
62+
if (!text) return [];
63+
return api.search(text);
64+
});
65+
66+
return (
67+
<>
68+
<input
69+
type="search"
70+
value={latest(query)}
71+
onInput={(event) => setQuery(event.currentTarget.value)}
72+
/>
73+
<ul class={{ stale: isPending(results) }}>
74+
<For each={results()}>{(product) => <li>{product.name}</li>}</For>
75+
</ul>
76+
</>
77+
);
78+
}
79+
```
80+
81+
Three things this code does not do, because Solid does them:
82+
83+
- **Discard stale responses.** Typing `sh`, then `sho`, then `shoe` starts three requests.
84+
Only the answer to the current question is used; if `sho` arrives after `shoe`, it is dropped.
85+
There is no request counter and no `AbortController`.
86+
- **Blank the list between keystrokes.** The previous results stay visible with the `stale` class until the new ones land.
87+
- **Show a spinner for an empty query.** Returning `[]` synchronously is a settled answer, so the memo never becomes pending for it.
88+
89+
The input binds `value={latest(query)}` rather than `value={query()}`.
90+
The write to `query` is held while results load, and `latest` reads the value the update is moving toward, so a controlled input reflects what the user typed.
91+
An uncontrolled input, with no `value` binding, needs nothing.
92+
93+
Debounce at the event, not in the graph:
94+
95+
```tsx
96+
onInput={debounce((event) => setQuery(event.currentTarget.value), 150)}
97+
```
98+
99+
A debounce that lives in an effect copying one signal into another is the pattern the [effects guide](/guides/avoid-unnecessary-effects) warns about.
100+
At the event handler it is one line and the graph stays simple.
101+
102+
## Load several things for one page
103+
104+
A product page needs the product, its reviews, and related items.
105+
Create all three where the page is created, and they start together:
106+
107+
```tsx
108+
function ProductPage(props: { id: string }) {
109+
const product = createMemo(() => api.product(props.id));
110+
const reviews = createMemo(() => api.reviews(props.id));
111+
const related = createMemo(() => api.related(props.id));
112+
113+
return (
114+
<article>
115+
<Loading fallback={<ProductSkeleton />}>
116+
<ProductHeader product={product()} />
117+
</Loading>
118+
<Loading fallback={<ReviewsSkeleton />}>
119+
<ReviewList reviews={reviews()} />
120+
</Loading>
121+
<Loading fallback={<RelatedSkeleton />}>
122+
<RelatedGrid products={related()} />
123+
</Loading>
124+
</article>
125+
);
126+
}
127+
```
128+
129+
Each boundary reveals when its own data is ready, so the header can appear before the reviews.
130+
If the page should not reveal out of order, wrap the boundaries in [`Reveal`](/reference/solid-js/components-jsx/reveal): `order="sequential"` shows them top to bottom, `order="together"` waits for all three.
131+
132+
When `props.id` changes, all three requests start again and the update is held until all three have answered; the page then swaps as one.
133+
That is usually what you want for a detail page, where the header and the reviews must describe the same product.
134+
If a slow section should not delay the others on a subject change, give it `on={props.id}` and it will show its skeleton instead of holding the rest.
135+
136+
## Dependent requests
137+
138+
A request that needs a value from another response has to wait for it:
139+
140+
```tsx
141+
const product = createMemo(() => api.product(props.id));
142+
const brand = createMemo(() => api.brand(product().brandId));
143+
```
144+
145+
`brand` reads `product().brandId`, so it cannot start until `product` resolves.
146+
The dependency is visible in the code, and that is the right shape when the data is sequential.
147+
148+
When it is not, remove the dependency rather than working around it.
149+
Two common fixes:
150+
151+
- **Pass the input you already have.** If the route knows `brandId`, read it from `props` and both requests start together.
152+
- **Join on the server.** One server function that returns the product with its brand replaces two round trips with one.
153+
154+
Development builds report long chains as `ASYNC_WATERFALL` when attribution is enabled; [Debugging reactivity](/guides/debugging-reactivity) shows the report.
155+
156+
## Paginate
157+
158+
A page number is an input like any other:
159+
160+
```tsx
161+
function OrderHistory() {
162+
const [page, setPage] = createSignal(1);
163+
const [orders] = createStore(
164+
async () => api.orders({ page: page() }),
165+
[] as Order[]
166+
);
167+
168+
return (
169+
<Loading fallback={<TableSkeleton />}>
170+
<table class={{ stale: isPending(() => orders.length) }}>
171+
<For each={orders}>{(order) => <OrderRow order={order} />}</For>
172+
</table>
173+
<Pager page={latest(page)} onChange={setPage} />
174+
</Loading>
175+
);
176+
}
177+
```
178+
179+
Clicking to page 2 keeps page 1 visible and dimmed until page 2 arrives, and the pager shows page 2 as selected at once because it reads `latest(page)`.
180+
Rows that appear on both pages keep their DOM.
181+
182+
If the table should show a skeleton for each new page instead, move the choice to the boundary: `<Loading on={page()} fallback={<TableSkeleton />}>`.
183+
184+
### Infinite scroll
185+
186+
Accumulate pages with the memo's previous value:
187+
188+
```tsx
189+
const [page, setPage] = createSignal(1);
190+
const orders = createMemo(async (previous: Order[] = []) => {
191+
const next = await api.orders({ page: page() });
192+
return [...previous, ...next];
193+
});
194+
```
195+
196+
Each run receives the last committed list and returns the longer one.
197+
To reset the list when a filter changes, read the filter inside the memo and start over when it differs from the previous run's filter, or keep the accumulated list in a store keyed by filter.
198+
The simplest version is often a `Show` keyed on the filter around the whole list, so a filter change remounts it with `page` back at 1.
199+
200+
## Share one request across components
201+
202+
Two components that create the same memo make two requests.
203+
Create the request once and pass the value down, or make it available through context:
204+
205+
```tsx
206+
function StorefrontLayout(props: ParentProps) {
207+
const cart = createMemo(() => api.cart());
208+
return <CartContext value={cart}>{props.children}</CartContext>;
209+
}
210+
211+
function CartBadge() {
212+
const cart = useContext(CartContext);
213+
return <span>{cart().items.length}</span>;
214+
}
215+
```
216+
217+
Passing an accessor through context keeps the read lazy: nothing waits on the cart until a component reads `cart()`, and only that component's boundary is involved.
218+
219+
For requests shared across routes, or for deduplication and caching by argument, use [`query`](/routing/solid-router/data#cache-reads-with-query) from Solid Router.
220+
It returns the same in-flight promise to every caller with the same arguments and revalidates on navigation and after actions.
221+
222+
## Keep data fresh
223+
224+
A memo answers its question once and keeps the answer until an input changes.
225+
When the world changes without an input changing, ask again:
226+
227+
```tsx
228+
import { onSettled, refresh } from "solid-js";
229+
230+
const stats = createMemo(() => api.dashboardStats());
231+
232+
onSettled(() => {
233+
const interval = setInterval(() => refresh(stats), 30_000);
234+
return () => clearInterval(interval);
235+
});
236+
```
237+
238+
[`refresh(source)`](/reference/solid-js/lifecycle-actions/refresh) re-runs the computation with the same inputs and returns a promise for the settled result.
239+
A bare `refresh` is quiet: the current answer still fits the question, so `isPending` stays `false` and the new value replaces the old one without a pending phase.
240+
When the reload should be visible, declare it: call [`affects(stats)`](/reference/solid-js/lifecycle-actions/affects) inside an action before the `refresh`, and readers report pending until it lands.
241+
242+
Polling is the fallback when the server cannot push.
243+
When it can, a [`live()` server function](/building-apps/server-functions/reads-and-live-data#declare-a-live-source) returns an async iterable that a memo consumes like any other async source, and each yielded value becomes the next answer.
244+
245+
## Mutate, then refetch
246+
247+
A write goes through an [`action`](/reference/solid-js/lifecycle-actions/action) so the request and the refetch belong to one update:
248+
249+
```tsx
250+
const addReview = action(function* (productId: string, text: string) {
251+
yield api.addReview(productId, text);
252+
refresh(reviews);
253+
});
254+
```
255+
256+
The reviews list does not flicker: the refetch runs inside the action, and the list updates once when the fresh data lands.
257+
To show the new review before the server confirms it, hold the list in a `createOptimisticStore` and write to it before the `yield`; [Async reactivity](/concepts/async-reactivity#mutations-from-a-client-only-list-to-a-server-backed-one) walks through that version.
258+
259+
With Solid Router, `action` from `@solidjs/router` adds submissions and automatic revalidation of `query` reads; the [Forms guide](/guides/forms) uses it.
260+
261+
## Handle failures
262+
263+
A rejected promise travels through the graph like a value and stops at the nearest [`Errored`](/reference/solid-js/components-jsx/errored) boundary.
264+
Place boundaries where a failure should be contained:
265+
266+
```tsx
267+
<article>
268+
<Loading fallback={<ProductSkeleton />}>
269+
<ProductHeader product={product()} />
270+
</Loading>
271+
<Errored
272+
fallback={(error, reset) => <RetryPanel error={error()} onRetry={reset} />}
273+
>
274+
<Loading fallback={<ReviewsSkeleton />}>
275+
<ReviewList reviews={reviews()} />
276+
</Loading>
277+
</Errored>
278+
</article>
279+
```
280+
281+
A failed reviews request shows the retry panel and leaves the header alone.
282+
`reset` retries the sources the boundary collected, and a boundary also recovers on its own when an input changes or a `refresh` lands.
283+
284+
Throw from the request when the response is not usable, rather than returning a `{ success: false }` object and checking it at every read.
285+
On the server, [`markSafeError`](/reference/solid-web/request-response/safe-errors) marks a message that is intended for the client; unmarked errors are replaced with a generic message in production.
286+
287+
## Checklist
288+
289+
- [ ] Each request is a memo or store created where the data is needed, or higher when it should start earlier.
290+
- [ ] Requests that do not depend on each other are created in the same scope so they run in parallel.
291+
- [ ] Each `Loading` boundary wraps the smallest region its fallback should replace; `on` is set where a changed subject should show the fallback again.
292+
- [ ] Controls whose writes feed a request read `latest` so they respond at once; `isPending` marks the content that is waiting.
293+
- [ ] No loading flags, request counters, or abort controllers remain for work Solid does.
294+
- [ ] Reloads use `refresh`, with `affects` when the reload should show as pending.
295+
- [ ] Failures throw and an `Errored` boundary covers each region that should fail on its own.
296+
297+
## Next steps
298+
299+
- [Async reactivity](/concepts/async-reactivity): the model these patterns rest on, including held updates and optimistic writes.
300+
- [Server functions](/building-apps/server-functions): `GET` reads, `live` sources, and what a `"use server"` function does on the wire.
301+
- [Data loading and mutations](/routing/solid-router/data): `query`, `preload`, and router actions.
302+
- [Data fetching from Solid 1](/migration/data-fetching-from-solid-1): the same patterns from the other direction, for code that already exists.
File renamed without changes.

0 commit comments

Comments
 (0)