| name | foundations |
| description | neverthrow Result architectural basis; three-state AsyncResult relationship to Result; @tanstack/vue-query lifecycle (staleTime, gcTime, refetch); composition of TanStack Query + neverthrow + Vue 3 reactivity.
|
| type | core |
| library | vue-core-api-utils |
@wisemen/vue-core-api-utils — Foundations
Understand how AsyncResult from neverthrow and @tanstack/vue-query combine to provide structured error handling and reactive query management. This knowledge informs all other skills.
Core Concepts
AsyncResult: The three-state type system
AsyncResult<T, E> is a Result type from the neverthrow library that explicitly models three states:
type AsyncResult<T, E> = AsyncResultLoading
| AsyncResultOk<T>
| AsyncResultErr<E>
The three states replace traditional Vue composition with separate flags:
const isLoading = ref(false)
const isError = ref(false)
const data = ref(null)
const error = ref(null)
const result = ref<AsyncResult<Contact, ApiError>>(new AsyncResultLoading())
result.value.match({
loading: () => 'Loading...',
ok: (contact) => contact.name,
err: (error) => error.message,
})
Three-state representation
AsyncResult wraps any promise-based operation:
| State | Setup | Usage | Next |
|---|
| Loading | Initial state when query starts | Show spinner/skeleton | → Ok or Err |
| Ok(T) | Server returned success with data | Show data with getValue() | Stays Ok until refetch |
| Err(E) | Server returned error or network failed | Show error with getError() | Query can be retried |
import { useQuery } from '@/api'
const { result } = useQuery('contactDetail', {
queryFn: () => ContactService.getDetail(uuid),
})
result.value.match({
loading: () => <div>Loading</div>,
ok: (contact) => <div>{contact.name}</div>,
err: (error) => <div>Error: {error.message}</div>,
})
neverthrow Result vs AsyncResult
neverthrow provides Result<T, E> for synchronous operations. AsyncResult extends it for async:
const result: Result<Contact, ApiError> = await contactService.getDetail()
result.match({
ok: (contact) => console.log(contact.name),
err: (error) => console.error(error.message),
})
const result: AsyncResult<Contact, ApiError> = new AsyncResultLoading()
result.match({
loading: () => console.log('Waiting...'),
ok: (contact) => console.log(contact.name),
err: (error) => console.error(error.message),
})
AsyncResult is Result + loading state. Every composable that fetches data returns AsyncResult.
Type guards from neverthrow
Safely extract values using type guards:
const result = new AsyncResultOk(contact)
if (result.isOk()) {
const contact = result.getValue()
}
const errResult = new AsyncResultErr(error)
if (errResult.isErr()) {
const error = errResult.getError()
}
if (!result.isLoading()) {
}
TanStack Query Lifecycle
@tanstack/vue-query manages the async lifecycle beneath AsyncResult.
Query state machine
[Initial]
↓
[Fetching] (isLoading)
↓
[Stale] (cached data exists but flagged for refresh)
↓
[Inactive] (unused queries auto-cleanup after gcTime)
Stale time: How long is cached data fresh?
const { result } = useQuery('contactDetail', {
queryFn: () => ContactService.getDetail(uuid),
staleTime: 5 * 60 * 1000,
})
Stale time is the grace period before the cache is considered outdated. While fresh, subsequent requests return cache instantly without refetching.
Garbage collection time: When does cache disappear?
const gcTime = 5 * 60 * 1000
gcTime is cleanup. If you navigate back before gcTime expires, you get the cached (stale) data. After gcTime, next access refetches fresh.
Refetch triggers
Queries refetch when:
- Manual trigger —
refetch() function
- Mutation invalidation —
queryKeysToInvalidate in mutation definition
- Stale time expired — Next component interaction after staleTime passes
- Focus refetch — Window regains focus (configurable)
- Component mount — If cache is beyond gcTime
const { result, refetch } = useQuery('contactDetail', {
queryFn: () => ContactService.getDetail(uuid),
staleTime: 5 * 60 * 1000,
})
async function handleRefresh() {
await refetch()
}
const { execute } = useMutation({
queryFn: ({ body }: { body: ContactUpdateForm }) => ContactService.update(body),
queryKeysToInvalidate: { contactDetail: {} },
})
Composable architecture
Each composable in vue-core-api-utils is built from:
- TanStack Query composable —
useQuery, useInfiniteQuery, useMutation from @tanstack/vue-query
- AsyncResult wrapper — Result from neverthrow with loading state
- Type-safe parameters — ProjectQueryKeys and error codes from your domain
const { result, isLoading, refetch } = useQuery('contactDetail', {
params: computed(() => ({ uuid })),
queryFn: () => ContactService.getDetail(uuid),
staleTime: 5 * 60 * 1000,
})
const query = useQueryRaw(queryKey, queryFn, { staleTime })
const result = computed(() => {
if (query.isLoading.value) return new AsyncResultLoading()
if (query.isError.value) return new AsyncResultErr(query.error.value)
return new AsyncResultOk(query.data.value)
})
return { result, isLoading: query.isLoading, refetch: query.refetch }
The composables handle this composition. You just use result.value.match().
Error handling strategy
Errors are typed and structured using neverthrow:
interface ApiExpectedError {
errors: Array<{
code: string
detail: string
status: string
source?: { pointer: string }
}>
}
type ApiError = ApiExpectedError | ApiUnexpectedError
const result = new AsyncResultErr(apiError)
result.match({
ok: (data) => {},
err: (error) => {
if ('errors' in error) {
const codes = error.errors.map(e => e.code)
const detail = error.errors[0].detail
} else {
console.error(error.message)
}
},
})
Error types are defined at library initialization via the generic TErrorCode. This ensures type-safe error handling across queries and mutations.
Integration pattern
The full integration:
User interaction
↓
useQuery/useMutation composable
↓
@tanstack/vue-query (fetch + cache mgmt)
↓
Promise from queryFn
↓
neverthrow Result handling
↓
AsyncResult (Loading | Ok | Err)
↓
Vue computed ref (reactive)
↓
Template pattern matching with result.value.match()
Each layer adds value:
- User interaction triggers the flow
- Composable provides type safety (ProjectQueryKeys)
- TanStack Query handles caching, refetching, lifecycle
- neverthrow enforces error handling at compile time
- AsyncResult makes state explicit in templates
- Vue reactivity keeps UI synchronized
Understanding this stack helps you use each piece correctly.
See Also