@wisemen/vue-core-api-utils
Version:
462 lines (360 loc) • 14.3 kB
Markdown
---
name: foundations
description: >
neverthrow Result architectural basis; three-state AsyncResult relationship to Result; /vue-query lifecycle (staleTime, gcTime, refetch); composition of TanStack Query + neverthrow + Vue 3 reactivity.
type: core
library: vue-core-api-utils
library_version: "0.0.3"
sources:
- "wisemen-digital/wisemen-core:docs/packages/api-utils/pages/concepts/result-types.md"
- "wisemen-digital/wisemen-core:packages/web/api-utils/src/async-result/asyncResult.ts"
- "wisemen-digital/wisemen-core:packages/web/api-utils/src/types/apiError.type.ts"
- "wisemen-digital/wisemen-core:packages/web/api-utils/src/config/config.ts"
---
# /vue-core-api-utils — Foundations
Understand how `AsyncResult` from `neverthrow` and `/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:
```typescript
type AsyncResult<T, E> = AsyncResultLoading
| AsyncResultOk<T>
| AsyncResultErr<E>
```
The three states replace traditional Vue composition with separate flags:
```typescript
// ❌ Old pattern: multiple flags
const isLoading = ref(false)
const isError = ref(false)
const data = ref(null)
const error = ref(null)
// Which combinations are valid? isLoading + isError? isLoading + data?
// The state machine is implicit, error-prone
```
```typescript
// ✅ AsyncResult: single discriminated union
const result = ref<AsyncResult<Contact, ApiError>>(new AsyncResultLoading())
// Only three valid states; type system enforces them
// Pattern matching makes every state explicit
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 |
```typescript
import { useQuery } from '@/api'
const { result } = useQuery('contactDetail', {
queryFn: () => ContactService.getDetail(uuid),
})
// result is computed ref to AsyncResult<Contact, ApiError>
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:
```typescript
// neverthrow Result: already resolved
const result: Result<Contact, ApiError> = await contactService.getDetail()
result.match({
ok: (contact) => console.log(contact.name),
err: (error) => console.error(error.message),
})
```
```typescript
// AsyncResult: waiting for promise
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:
```typescript
const result = new AsyncResultOk(contact)
// Type predicate
if (result.isOk()) {
const contact = result.getValue() // No type error; TypeScript knows it's Contact
}
const errResult = new AsyncResultErr(error)
if (errResult.isErr()) {
const error = errResult.getError() // No type error; TypeScript knows it's ApiError
}
if (!result.isLoading()) {
// Could be Ok or Err
}
```
## TanStack Query Lifecycle
`/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?
```typescript
const { result } = useQuery('contactDetail', {
queryFn: () => ContactService.getDetail(uuid),
staleTime: 5 * 60 * 1000, // 5 minutes
})
// Timeline:
// T=0: First fetch. Result is Ok(contact). freshInterval starts.
// T=4m59s: Data is still fresh. Returning cached contact instantly.
// T=5m01s: Data is now stale. Still showing cached contact, but next interaction refetches.
// T=next-page-view: Fresh fetch triggered automatically.
```
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?
```typescript
const gcTime = 5 * 60 * 1000 // 5 minutes
// Query runs, then becomes unused (component unmounts, user navigates away).
// For gcTime duration, data is kept in memory (but marked as stale).
// After gcTime, if query hasn't been accessed, it's deleted from cache.
```
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:
1. **Manual trigger** — `refetch()` function
2. **Mutation invalidation** — `queryKeysToInvalidate` in mutation definition
3. **Stale time expired** — Next component interaction after staleTime passes
4. **Focus refetch** — Window regains focus (configurable)
5. **Component mount** — If cache is beyond gcTime
```typescript
const { result, refetch } = useQuery('contactDetail', {
queryFn: () => ContactService.getDetail(uuid),
staleTime: 5 * 60 * 1000,
})
// Manual refetch
async function handleRefresh() {
await refetch()
// result updates to new Ok or Err
}
// Automatic refetch on mutation (via queryKeysToInvalidate)
const { execute } = useMutation({
queryFn: (data) => ContactService.update(data),
queryKeysToInvalidate: { contactDetail: () => true },
})
// After execute succeeds, contactDetail is invalidated
// Next useQuery('contactDetail') refetches fresh data
```
## Composable architecture
Each composable in vue-core-api-utils is built from:
1. **TanStack Query composable** — `useQuery`, `useInfiniteQuery`, `useMutation` from /vue-query
2. **AsyncResult wrapper** — Result from neverthrow with loading state
3. **Type-safe parameters** — ProjectQueryKeys and error codes from your domain
```typescript
// High-level (what you use)
const { result, isLoading, refetch } = useQuery('contactDetail', {
params: computed(() => ({ uuid })),
queryFn: () => ContactService.getDetail(uuid),
staleTime: 5 * 60 * 1000,
})
// Under the hood:
// 1. TanStack Query manages the fetch lifecycle
const query = useQueryRaw(queryKey, queryFn, { staleTime })
// 2. Wrap query state in AsyncResult
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)
})
// 3. Expose typed composable
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`:
```typescript
// Error type definition
interface ApiExpectedError {
errors: Array<{
code: string
message: string
details?: unknown
}>
}
type ApiError = ApiExpectedError | ApiUnexpectedError
// In AsyncResult
const result = new AsyncResultErr(apiError)
result.match({
ok: (data) => {}, // not executed
err: (error) => {
// error is ApiError
if (error instanceof ApiExpectedError) {
// Handle known API errors
const codes = error.errors.map(e => e.code)
} else {
// Handle network/parsing errors
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.
## Common Mistakes
### CRITICAL: Confuse Result (neverthrow) with AsyncResult; treat ok/err as boolean
```typescript
// ❌ Wrong: neverthrow Result is not AsyncResult
const result = new Result(contact, null) // This is not how neverthrow works
if (result.ok) { // `.ok` doesn't exist
console.log(result.value)
}
// Or even worse: treating AsyncResult like a boolean
const { result } = useQuery(...)
if (result.value) {
// This is always true; result is always defined (Loading | Ok | Err)
}
```
```typescript
// ✅ Correct: AsyncResult requires exhaustive pattern matching
const { result } = useQuery('contactDetail', {
queryFn: () => ContactService.getDetail(uuid),
})
result.value.match({
loading: () => showSpinner(),
ok: (contact) => showContact(contact),
err: (error) => showError(error),
})
// Or use type guards
if (result.value.isOk()) {
console.log(result.value.getValue())
} else if (result.value.isErr()) {
console.log(result.value.getError())
} else {
showSpinner()
}
```
AsyncResult requires explicit handling of all three states. The type system won't let you skip a state.
Source: `docs/packages/api-utils/pages/concepts/result-types.md`
### MEDIUM: Misunderstand staleTime; think data refreshes automatically after staleTime
```typescript
// ❌ Wrong: assuming staleTime auto-refetches
const { result } = useQuery('contactDetail', {
queryFn: () => ContactService.getDetail(uuid),
staleTime: 5 * 60 * 1000, // Not an auto-refresh interval
})
// At T=5m, data doesn't automatically refetch.
// It's just marked stale. Refetch happens on next interaction
// (component mount, user action, mutation invalidation)
```
```typescript
// ✅ Correct: staleTime is a grace period, not an interval
const { result, refetch } = useQuery('contactDetail', {
queryFn: () => ContactService.getDetail(uuid),
staleTime: 5 * 60 * 1000,
})
// Data is fresh for 5 minutes (instant returns)
// After 5 minutes, next access triggers refetch
// For auto-refresh, use refetch() in a watchEffect or timer
watchEffect(async () => {
// Refetch every 10 seconds
const interval = setInterval(() => refetch(), 10 * 1000)
onCleanup(() => clearInterval(interval))
})
```
Stale time is not an auto-refresh interval. It's the duration the cache is considered fresh without refetching. Refetch happens on next access or when explicitly triggered.
### HIGH: Misunderstand gcTime and cache eviction; assume cache persists forever
```typescript
// ❌ Wrong: assuming cache is permanent
const { result } = useQuery('contactDetail', {
queryFn: () => ContactService.getDetail(uuid),
gcTime: 5 * 60 * 1000, // Default is 5 minutes
})
// If component unmounts and user is gone for > 5 minutes,
// Next access refetches fresh (cache is evicted)
// This is correct behavior, but if you expected cached data...
```
```typescript
// ✅ Correct: increase gcTime if you want longer-lived cache
const { result } = useQuery('contactDetail', {
queryFn: () => ContactService.getDetail(uuid),
staleTime: 5 * 60 * 1000, // Fresh for 5 minutes
gcTime: 60 * 60 * 1000, // Keep in memory for 1 hour
})
// After 5 minutes (stale) but before 1 hour (gc),
// Returning cached data (but trigger refetch automatically)
// After 1 hour, cache is deleted; next access refetches fresh
```
gcTime controls cache eviction. Longer gcTime = cache lives longer. Longer staleTime = more queries use cache without refetching. Both are configurable defaults in the plugin config.
Source: `packages/web/api-utils/src/config/config.ts`
### MEDIUM: Forget that QueryClient is shared; one query invalidation affects all components
```typescript
// ❌ Wrong: not realizing cache is global
const { execute } = useMutation({
queryFn: () => ContactService.update(data),
queryKeysToInvalidate: { contactDetail: () => true },
})
// Component A: detail view
// Component B: list view (also uses contactDetail)
// Even though A only updated one contact,
// B's cache is invalidated too (because same query key)
```
```typescript
// ✅ Correct: QueryClient is intentionally shared
export function useContactMutation() {
const { execute } = useMutation({
queryFn: () => ContactService.update(data),
queryKeysToInvalidate: {
// Invalidates all components using this key
contactDetail: () => true,
// Also invalidate lists that show this contact
contactList: () => true,
},
})
return { execute }
}
// When A updates a contact, both A and B refetch.
// This is the intended design: shared cache across app.
```
QueryClient is application-wide (singleton). Invalidating a query invalidates for all components using that key. This is a feature: synchronized cache across the app.
Source: `packages/web/api-utils/src/utils/query-client/queryClient.ts`
## Integration pattern
The full integration:
```
User interaction
↓
useQuery/useMutation composable
↓
/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
- [AsyncResult Handling](../asyncresult-handling/SKILL.md) — Deep dive into pattern matching and type guards
- [Writing Queries](../writing-queries/SKILL.md) — Applying staleTime and refetch in real queries
- [Writing Mutations](../writing-mutations/SKILL.md) — How mutations invalidate cache
- [Cache Management](../cache-management/SKILL.md) — Manual cache operations behind the scenes