Data Fetch
What is a Data Fetch Library?
Modern web applications frequently fetch data from servers. In the Vue.js ecosystem, libraries like Pinia Colada and TanStack Query provide this functionality.
In this chapter, we'll implement basic data fetching functionality similar to Pinia Colada as chibivue-fetch.
Why Do We Need a Library?
Simple data fetching might seem sufficient with just fetch and ref:
// composables/useUser.ts
import { ref, onMounted } from "chibivue";
export function useUser(id: number) {
const user = ref(null);
const isLoading = ref(true);
const error = ref(null);
onMounted(async () => {
try {
const response = await fetch(`/api/users/${id}`);
user.value = await response.json();
} catch (e) {
error.value = e;
} finally {
isLoading.value = false;
}
});
return { user, isLoading, error };
}However, this implementation has the following problems:
- No caching: Same data is fetched multiple times
- SSR is difficult: Cannot transfer server-fetched data to the client
- Duplicate requests: Multiple mounts of the same component cause duplicate requests
- Error handling: Retry and refetch logic becomes complex
Data fetching libraries solve these problems and provide a declarative API.
Package Structure
chibivue-fetch is provided in the @extensions/chibivue-fetch package.
@extensions/chibivue-fetch/src/
├── index.ts # Exports
├── queryCache.ts # QueryCache implementation (cache management)
├── useQuery.ts # Data fetching hook
├── useMutation.ts # Data mutation hook
└── types.ts # Type definitionsData State Pattern
Similar to Pinia Colada, chibivue-fetch represents data state with three states:
type DataStateStatus = "pending" | "error" | "success";
type DataState<TData, TError> =
| { status: "pending"; data: undefined; error: null }
| { status: "error"; data: TData | undefined; error: TError }
| { status: "success"; data: TData; error: null };This state model allows clear tracking of data state.
QueryCache
QueryCache manages caching and state management for SSR.
// queryCache.ts
export interface QueryCache {
install: (app: App) => void;
caches: Map<string, UseQueryEntry>;
options: Required<QueryCacheOptions>;
create: <TData>(key: EntryKey, options: UseQueryOptionsWithDefaults | null, ...) => UseQueryEntry;
ensure: <TData>(key: EntryKey, options: UseQueryOptionsWithDefaults) => UseQueryEntry;
fetch: <TData>(entry: UseQueryEntry) => Promise<DataState>;
refresh: <TData>(entry: UseQueryEntry) => Promise<DataState>;
invalidate: (entry: UseQueryEntry) => void;
invalidateQueries: (key?: EntryKey) => void;
remove: (entry: UseQueryEntry) => void;
track: (entry: UseQueryEntry, effect: EffectScope | object | null) => void;
untrack: (entry: UseQueryEntry, effect: EffectScope | object | null) => void;
setQueryData: <TData>(key: EntryKey, data: TData) => void;
getQueryData: <TData>(key: EntryKey) => TData | undefined;
prefetchQuery: <TData>(key: EntryKey, queryFn: (ctx: QueryContext) => Promise<TData>, options?: Partial<UseQueryOptionsWithDefaults>) => Promise<void>;
isStale: (entry: UseQueryEntry) => boolean;
}Key Methods
ensure: Get or create an entryfetch: Execute the query (always executes)refresh: Refresh the query (only executes if stale or error)invalidate: Invalidate an entry (mark as stale)invalidateQueries: Invalidate entries matching a keytrack/untrack: Track component dependenciessetQueryData/getQueryData: Direct cache data manipulationprefetchQuery: Prefetch data and store in cache
createQueryCache
import { createQueryCache } from "chibivue-fetch";
const queryCache = createQueryCache({
staleTime: 5000, // Default stale time (5 seconds)
gcTime: 300000, // Default GC time (5 minutes)
});
app.use(queryCache);useQuery
useQuery is a composable for data fetching.
// useQuery.ts
export interface UseQueryOptions<TData = unknown, TError = Error> {
key: EntryKey | EntryKeyFn;
query: (context: QueryContext) => Promise<TData>;
staleTime?: number;
gcTime?: number;
refetchOnMount?: boolean | "always";
initialData?: TData | (() => TData);
enabled?: boolean | Ref<boolean> | ComputedRef<boolean>;
retry?: number | boolean;
retryDelay?: number;
meta?: QueryMeta;
}
export interface UseQueryReturn<TData = unknown, TError = Error> {
state: ComputedRef<DataState<TData, TError>>;
asyncStatus: ComputedRef<AsyncStatus>;
data: ComputedRef<TData | undefined>;
error: ComputedRef<TError | null>;
status: ComputedRef<DataStateStatus>;
isPending: ComputedRef<boolean>;
isLoading: ComputedRef<boolean>;
isSuccess: ComputedRef<boolean>;
isError: ComputedRef<boolean>;
refresh: () => Promise<DataState<TData, TError>>;
refetch: () => Promise<DataState<TData, TError>>;
}Options
key: Unique key for the query (used as cache key)query: Async function to fetch data (receives{ signal })staleTime: Time until data becomes "stale"gcTime: Time to keep unused cache (garbage collection)enabled: Whether to enable the queryretry: Number of retries on errorinitialData: Initial data
States
status: Current status ("pending"|"error"|"success")asyncStatus: Async status ("idle"|"loading")isPending: No initial data yetisLoading: Initial fetching (isPendingandasyncStatus === "loading")isSuccess: Fetch succeededisError: Fetch failed
Difference between refresh and refetch
refresh(): Only fetches if stale or errorrefetch(): Always fetches (invalidates cache first)
Usage Example
import { useQuery } from "chibivue-fetch";
const { data, isLoading, error, refresh } = useQuery({
key: ["user", userId],
query: ({ signal }) => fetch(`/api/users/${userId}`, { signal }).then((res) => res.json()),
staleTime: 60000, // Use cache for 1 minute
});useMutation
useMutation is a composable for data mutations (POST, PUT, DELETE, etc.).
// useMutation.ts
export interface UseMutationOptions<TData, TError, TVariables, TContext> {
mutation: (variables: TVariables) => Promise<TData>;
onMutate?: (variables: TVariables) => TContext | Promise<TContext>;
onSuccess?: (data: TData, variables: TVariables, context: TContext | undefined) => void | Promise<void>;
onError?: (error: TError, variables: TVariables, context: TContext | undefined) => void | Promise<void>;
onSettled?: (data: TData | undefined, error: TError | null, variables: TVariables, context: TContext | undefined) => void | Promise<void>;
}
export interface UseMutationReturn<TData, TError, TVariables> {
state: ComputedRef<DataState<TData, TError>>;
asyncStatus: ComputedRef<AsyncStatus>;
data: ComputedRef<TData | undefined>;
error: ComputedRef<TError | null>;
status: ComputedRef<DataStateStatus>;
isPending: ComputedRef<boolean>;
isLoading: ComputedRef<boolean>;
isSuccess: ComputedRef<boolean>;
isError: ComputedRef<boolean>;
variables: ShallowRef<TVariables | undefined>;
mutate: (variables: TVariables) => void;
mutateAsync: (variables: TVariables) => Promise<TData>;
reset: () => void;
}Lifecycle Callbacks
onMutate: Called before mutation executes (returns context)onSuccess: Called on successonError: Called on erroronSettled: Called at the end regardless of success or error
Usage Example
import { useMutation } from "chibivue-fetch";
const { mutate, isLoading, isSuccess } = useMutation({
mutation: (newUser) => fetch("/api/users", {
method: "POST",
body: JSON.stringify(newUser),
}).then((res) => res.json()),
onSuccess: (data) => {
console.log("User created:", data);
// Invalidate cache to trigger refetch
queryCache.invalidateQueries(["users"]);
},
});
// Usage
mutate({ name: "John", email: "[email protected]" });How Caching Works
Entry Key
key is used as the cache key. Array format allows hierarchical keys:
// Simple key
key: ["users"]
// Hierarchical key
key: ["users", userId]
// Key with object
key: ["users", { status: "active", page: 1 }]Queries with the same key share the cache. Keys are serialized as sorted JSON, so object property order doesn't matter.
Stale Time and GC Time
← staleTime →|← refetch window →|← gcTime →|
fetch stale inactive gc
|-----------------|----------------------|-----|
data arrives data is stale data removed- staleTime: Period during which data is "fresh". Calling
refresh()during this time won't fetch - gcTime: Period to keep unused cache. After a component unmounts, cache is deleted after this period
// No refetch for 1 minute, keep cache for 5 minutes
useQuery({
key: ["users"],
query: fetchUsers,
staleTime: 60 * 1000, // 1 minute
gcTime: 5 * 60 * 1000, // 5 minutes
});Dependency Tracking
Similar to Pinia Colada, chibivue-fetch tracks which components are using each query entry:
// Track when component mounts
onMounted(() => {
queryCache.track(entry, currentInstance);
});
// Untrack when component unmounts
onUnmounted(() => {
queryCache.untrack(entry, currentInstance);
});When there are no more dependencies, the cache is garbage collected after gcTime.
SSR Support
chibivue-fetch supports Server-Side Rendering (SSR).
Server-Side: Serializing State
// server.ts
import { createApp } from "chibivue";
import { renderToString } from "@chibivue/server-renderer";
import { createQueryCache, serializeQueryCache } from "chibivue-fetch";
import App from "./App.vue";
export async function render() {
// Create new instances for each request
const queryCache = createQueryCache();
const app = createApp(App);
app.use(queryCache);
// Prefetch data on server
await queryCache.prefetchQuery(
["users"],
({ signal }) => fetch("http://api/users", { signal }).then((r) => r.json()),
);
const html = await renderToString(app);
// Serialize cache state
const queryState = JSON.stringify(serializeQueryCache(queryCache));
return { html, queryState };
}Serialization Format
Similar to Pinia Colada, we use relative timestamps for serialization:
// UseQueryEntryNodeSerialized = [data, error, when (relative), meta]
{
'["users"]': [
[{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }], // data
null, // error
0, // when (relative: now - fetchTime)
undefined // meta
]
}Relative timestamps handle time differences between server and client.
Embedding in HTML
<!DOCTYPE html>
<html>
<head>
<script>
window.__QUERY_STATE__ = ${queryState};
</script>
</head>
<body>
<div id="app">${html}</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>Client-Side: Hydrating State
// main.ts (client)
import { createApp } from "chibivue";
import { createQueryCache, hydrateQueryCache } from "chibivue-fetch";
import App from "./App.vue";
const queryCache = createQueryCache();
const app = createApp(App);
app.use(queryCache);
// Hydrate with server state
if (window.__QUERY_STATE__) {
hydrateQueryCache(queryCache, window.__QUERY_STATE__);
}
app.mount("#app");
In SSR, similar to Store, you must be aware of Cross-Request State Pollution. Call createQueryCache() inside the render() function and create new instances for each request.
Practical Examples
Reactive Query Key
import { ref, computed } from "chibivue";
import { useQuery } from "chibivue-fetch";
const page = ref(1);
const filters = ref({ status: "active" });
const { data, isLoading } = useQuery({
// Function format for dynamic keys
key: () => ["users", { page: page.value, ...filters.value }],
query: ({ signal }) => fetchUsers(page.value, filters.value, signal),
});
// Automatically refetches when page or filters change
function nextPage() {
page.value++;
}Conditional Query
const userId = ref<number | null>(null);
const { data: user } = useQuery({
key: () => ["user", userId.value],
query: ({ signal }) => fetchUser(userId.value!, signal),
// Query won't execute while userId is null
enabled: computed(() => userId.value !== null),
});Cache Update After Mutation
const queryCache = getActiveQueryCache();
const { mutate: createUser } = useMutation({
mutation: (newUser) => api.createUser(newUser),
onSuccess: (createdUser) => {
// Method 1: Invalidate cache and refetch
queryCache.invalidateQueries(["users"]);
// Method 2: Update cache directly (optimistic update)
const currentUsers = queryCache.getQueryData<User[]>(["users"]);
if (currentUsers) {
queryCache.setQueryData(["users"], [...currentUsers, createdUser]);
}
},
});Error Handling and Retry
const { data, error, refresh } = useQuery({
key: ["users"],
query: fetchUsers,
retry: 3, // Retry up to 3 times
retryDelay: 1000, // Retry after 1 second
});
// In component
if (error.value) {
// Show error and retry button
}Cancellation with AbortController
const { data } = useQuery({
key: ["users"],
query: async ({ signal }) => {
const response = await fetch("/api/users", { signal });
if (!response.ok) throw new Error("Failed to fetch");
return response.json();
},
});When a query is cancelled (e.g., when a new request starts), the signal is aborted.
Summary
chibivue-fetch implementation consists of the following elements:
- QueryCache: Centralized cache management and dependency tracking
- Data State Pattern: Three-state model of
pending | error | success - useQuery: Declarative data fetching API
- useMutation: Data mutation management with lifecycle callbacks
- Cache Strategy: Flexible control with staleTime / gcTime
- SSR Support: State transfer via
serializeQueryCache()/hydrateQueryCache() - Reactive Keys: Dynamic query key support
- Error Handling: Automatic retry and state management
- AbortController: Request cancellation support
By implementing the core features of Pinia Colada minimally, you can understand how data fetching works.
