A reusable fetch composable
Turn the fetch pattern into useResource(), a composable that takes a string, a ref or a getter, refetches when it changes, aborts the previous request and cleans up with the scope.
What you'll learn
When you finish, you can do these.- 1Write a fetch composable that any component can reuse
- 2Refetch when the URL changes, by passing a getter
- 3Switch requests off and clean up with onScopeDispose()
- 4Fix two mistakes that keep a composable from refetching
Many pages load data from a server: a team page loads a list of people, a profile page one person. Each needs the same thirty lines of code: a loading line, an error message, the request with a check of its reply, and a way to cancel it. Every copy is a chance to forget a step. A composable, a function that sets this up for whichever component calls it, keeps the code in one place.
The Code tab holds one in useResource.ts: useResource() takes a URL and returns data, error, loading and refetch(). App.vue shows the users in a select, a drop-down list, and when you pick one, the composable fetches that user by itself. You need two ideas from the lesson Fetching data in a component. A request keeps its data, a loading flag and an error message in refs, boxes that hold a value and tell Vue when it changes. An AbortController stops a request nobody waits for.
The shape of the composable
useResource() is a plain function that any component can call. Its URL may arrive three ways: a plain string, a ref, or a getter, a function that returns the URL. Vue's type MaybeRefOrGetter<string> accepts all three, and Vue's toValue() reads whichever it got.
The three refs, data, error and loading, are created inside the function, so every caller gets its own:
export function useResource<T>(url: MaybeRefOrGetter<string>, options: Options = {}) {
const data = ref<T | null>(null)
const error = ref('')
const loading = ref(false)
let controller: AbortController | undefined
// … run() does the request, shown below
return { data, error, loading, refetch: run }
}
The generic T, a type the caller fills in, is the shape of the answer: useResource<User>(…) gives a data ref typed User | null.
options holds one switch, enabled, explained in the section Abort, enabled and dispose. refetch is run under another name, for the Refetch button.
App.vue calls it twice: with a string for the list of users in the select, and with a getter for the user picked there, whose id the select keeps in selectedId. { data: users } names the data ref users.
const { data: users } = useResource<User[]>('/api/users')
const selectedId = ref(1)
const { data: user, loading, error, refetch } = useResource<User>(
() => `/api/users/${selectedId.value}`,
)
The preview says Asha lives in Lisbon, with the Requests line, Requests: 2 sent, 0 aborted, under it: one request for the list, one for Asha. The preview has no network, so fakeServer.ts stands in for a server, answering /api/ URLs after 0.8 seconds and counting every request.
A getter makes it refetch
Vue's watchEffect() makes the composable ask again by itself: it runs a function straight away, records every ref the function reads, and runs it again when one of them changes. useResource hands it run(), the function that sends the request, which reads the URL and the enabled switch first.
async function run() {
const target = toValue(url)
const enabled = toValue(options.enabled ?? true)
controller?.abort()
// …
}
watchEffect(run)
With the getter from App.vue, toValue(url) calls it, the getter reads selectedId.value, and the effect, the function watchEffect() reruns, now depends on selectedId. Pick another user in the preview and the Requests line counts one more sent. A plain string has nothing to read, so the effect runs once and never again, which is right for the list.
The diagram draws such links in a general example, not this page.
One rule decides whether the link exists. An effect records reads only while its function is running, and an async function pauses at its first await, here await fetch(…). The rest runs later, after the effect has stopped recording, so a ref read after that line is never tracked. That is why target and enabled are read on the first two lines.
Abort, enabled and dispose
Three things stop a request: a newer request, the enabled switch, and the caller going away.
Each run() first aborts the previous request through its AbortController, so an old, slow answer never lands after a newer one. Click Next, which picks the following user, twice within a second: the aborted count goes up by one, and the page shows the user of the second click.
The enabled switch is a MaybeRefOrGetter<boolean> too, read in the effect. App.vue does not pass it yet; Try it 2 does. When it turns false, the effect runs again, aborts whatever is in flight and takes this branch:
if (!enabled) {
data.value = null
error.value = ''
loading.value = false
return
}
run() returns before fetch(), so nothing is sent and the page shows no user.
One more line cleans up when the caller goes away:
onScopeDispose(() => controller?.abort())
onScopeDispose() runs its function when whatever called the composable stops: a component that leaves the page, a Pinia store (state shared between components) or an effectScope() (a group of effects stopped together). A component's own onUnmounted() hook would miss the other two.
A failed request clears data. Pick Someone who does not exist, the last entry in the select: the page shows Request failed with 404 and no name, because a 404 for user 4 must not leave another user on the page.
What goes wrong
Two mistakes keep the composable from refetching. The first builds the URL as a string.
const { data: user } = useResource<User>(`/api/users/${selectedId.value}`)
The string is built once, when the component is set up, with the id at that moment. Picking Ben changes the select and nothing else: the page keeps saying Asha lives in Lisbon, and the Requests line stays at 2 sent. There is no warning, because a string is a valid argument. Pass a getter, so the effect reads selectedId every time it runs.
const { data: user } = useResource<User>(() => `/api/users/${selectedId.value}`)
Picking Ben now loads Ben.
The second mistake reads the URL after an await. A pause before fetching looks like a cheap way to wait for the visitor to stop clicking.
async function run() {
await new Promise(resolve => setTimeout(resolve, 300)) // wait before fetching
const target = toValue(url)
The first user loads after a short pause, then picking another user does nothing. By the time toValue(url) runs, the effect has finished its run and recorded no reads, so no change can trigger it. Read every reactive value first, then wait.
async function run() {
const target = toValue(url)
const enabled = toValue(options.enabled ?? true)
await new Promise(resolve => setTimeout(resolve, 300))
Every pick sends a request again.
What to remember
useResource(url)returnsdata,error,loadingandrefetch(). Each caller gets its own refs, and the template only decides what to show.- The URL is a
MaybeRefOrGetter. A string is fetched once; a ref or a getter is read throughtoValue()inside the effect, so a change refetches. - While
enabledis false,run()returns beforefetch()and sends nothing. Abort the previous request before each new one, and the last one inonScopeDispose(), which also works in stores and effect scopes. watchEffect()records reads only until the firstawait. Read the URL and theenabledflag on the first lines ofrun().
Try it
Open the Code tab and change the starter code. Each task says what you should see.- In App.vue, replace the getter with the string
`/api/users/${selectedId.value}`. Pick Ben in the select: the page keeps saying Asha lives in Lisbon, and the Requests line stays at 2 sent. Put the getter back, and picking Ben sends a request again. - Pass
{ enabled: () => selectedId.value !== 4 }as the second argument of the user call. Pick Someone who does not exist: no request is sent, the Requests line does not move, and the page shows no name and no error. Pick Chen: a request goes out and the page says Chen lives in Taipei. - In useResource.ts, insert
await new Promise(resolve => setTimeout(resolve, 300))as the first line ofrun(), above the twotoValue()lines. The first user still loads after a pause, but picking another user changes nothing and the Requests line stays. Move the line below the twotoValue()lines, and the composable follows the select again.