|
| 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. |
0 commit comments