Solid gives you createResource, Suspense and ErrorBoundary β a complete async-UI toolkit that's begging for an API that can actually exercise it. A hard-coded array can't show you your loading skeleton. It can't throw a 500 into your ErrorBoundary, and it can't tell you whether your Retry button really retries (spoiler from our test run: it probably doesn't). This guide points createResource at a hosted mock API with realistic data whose latency, errors and chaos you control from the URL bar.
npm create vite project (--template solid, Vite 8.2.0, solid-js 1.9.14), driven in a real browser against the live API. The three gotchas below were each reproduced for real in that run, not paraphrased from docs.Click "try it" on the Mockbird landing page, or from a terminal:
curl -X POST https://mockbird.mockbird.workers.dev/api/projects \
-d '{"name":"solid-demo","preset":"ecommerce"}'
# β {"id":"abc123","adminKey":"...","baseUrl":"https://mockbird.mockbird.workers.dev/m/abc123"}
The ecommerce preset seeds products, orders, customers and reviews with realistic data. No signup, CORS on by default. Put the base URL in .env.local:
# .env.local
VITE_API_BASE=https://mockbird.mockbird.workers.dev/m/abc123
(No terminal handy? Every example below also works against the public demo project: https://mockbird.mockbird.workers.dev/m/demo.)
curl -o src/types.ts https://mockbird.mockbird.workers.dev/m/abc123/types.ts
Interfaces for every resource, generated from the live schema β or ?format=zod for Zod schemas if you validate at the boundary.
import { createSignal, createResource, Suspense, ErrorBoundary, For } from "solid-js";
const BASE = import.meta.env.VITE_API_BASE;
async function fetchProducts(q) {
const url = new URL(`${BASE}/products`);
if (q) url.searchParams.set("q", q);
url.searchParams.set("limit", "10");
const res = await fetch(url);
if (!res.ok) throw new Error(`API ${res.status}`); // fetch does NOT throw on 500
return res.json();
}
export default function App() {
const [query, setQuery] = createSignal("");
const [products, { refetch }] = createResource(query, fetchProducts);
return (
<main>
<input placeholder="searchβ¦" value={query()}
onInput={(e) => setQuery(e.currentTarget.value)} />
<ErrorBoundary fallback={(err, reset) => (
<p>Failed to load: {err.message}{" "}
<button onClick={() => { refetch(); reset(); }}>Retry</button>
</p>
)}>
<Suspense fallback={<p>Loading productsβ¦</p>}>
<ul>
<For each={products.latest}>
{(p) => <li>{p.name} β ${p.price}</li>}
</For>
</ul>
</Suspense>
</ErrorBoundary>
</main>
);
}
That's the whole app. The search box is reactive for free: query is the resource's source, so every keystroke re-runs the fetch with the new value β in our run, typing cloud live-filtered 10 products down to 7 matches with zero extra wiring. Three lines in that snippet earn their place because we watched each one fail without it; the next three sections are those gotchas.
createResource skips fetching while its source is false, null or undefined β that's the documented way to defer a fetch. The trap is doing it by accident:
// looks harmless, breaks the initial load:
const [products] = createResource(() => query() || undefined, fetchProducts);
When we ran this, the page rendered nothing β no list, no loading fallback, no error, because the initial "" became undefined and the fetch never fired. It started working the moment you typed a character, which makes it a confusing bug to chase. An empty string is a perfectly good source value (our verified snippet passes query directly); reserve the falsy-skip for fetches that genuinely depend on something existing (() => user()?.id).
Add ?mock_delay=3000 to the request (or bake it into the URL in .env.local) and the API holds the response for 3 seconds:
https://mockbird.mockbird.workers.dev/m/abc123/products?mock_delay=3000
In our browser run the Suspense fallback ("Loading productsβ¦") stayed up for the full 3 s, then the list swapped in. This is how you check the skeleton actually renders, doesn't jump layout, and disappears β instead of shipping a spinner you've never seen.
Two things have to be true before your ErrorBoundary ever fires. First: fetch resolves successfully on a 500 β if you don't check res.ok and throw, an error response just becomes garbage data. Second, the one we reproduced: the boundary's reset callback only re-renders the children. The resource is still in its errored state, so the children re-throw instantly β our first Retry button looped straight back to the error screen without a single network request. The fix is to retry the data, then the UI:
<button onClick={() => { refetch(); reset(); }}>Retry</button>
Test both halves from the URL bar: ?mock_status=500 forces every response to be a 500 (our run: boundary caught "API 500"), and ?mock_chaos=0.5 makes a random half of requests fail so Retry has something real to recover from β in our run it took two retries to get the list back, exactly the flow your users will hit on a flaky train connection.
.latestReading products() under Suspense re-triggers the fallback on every source change. With a realistic 1.5 s latency (?mock_delay=1500), each search keystroke blanked the whole list and flashed "Loadingβ¦" β we counted: 10 items β 0 items β fallback β 7 items. The stale-while-revalidate fix is one property:
<For each={products.latest}> // keeps the previous list while refetching
Same run with .latest: the 10 old items stayed on screen during the refetch, then settled to the 7 matches. You only see the difference because the mock has real latency β against localhost-fast responses, both versions look identical and the flash ships to production.
const res = await fetch(`${BASE}/products`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name: "Solid Test Product", price: 9.99 }),
});
const created = await res.json(); // β { id: 31, ... } β a real row, not an echo
refetch();
Unlike echo-only fake APIs, the POST inserts a real record: our test's id 31 was immediately fetchable at /products/31 and survived refreshes until we deleted it. One honest footnote from the run: with limit=10 the new record lands beyond page one β fetch with ?sortBy=id&order=desc if you want newest-first.
An imported JSON file is offline and zero-dependency, but it can't suspend, can't error, has no latency, and writes go nowhere β every state your async UI exists for is unrepresentable.
MSW is excellent for in-process JS tests, and if you're testing Solid components in Vitest it's a great fit (they compose with this setup fine). But the mock lives inside your JS runtime: your phone, your teammate, Postman and your deployed preview build all get nothing, and delay/error scenarios are handler code you maintain.
| Imported JSON | MSW | Mockbird | |
|---|---|---|---|
| Exercises Suspense/ErrorBoundary | β | β handler code | β from the URL bar |
| Reachable from phone / teammate / CI preview | β | β | β hosted URL |
| Writes persist | β | DIY state | β real database |
| Delay / error / chaos injection | β | β in code | ?mock_delay / ?mock_status / ?mock_chaos |
| Where it wins | Zero deps, offline | Unit tests, offline, zero latency | β |
Mockbird follows plain REST conventions, so moving to the real backend is changing VITE_API_BASE. Nothing else changes. If the backend team wants a contract, hand them the mock's live spec β https://mockbird.mockbird.workers.dev/m/abc123/openapi.json β or the generated Postman collection.