Async Handling
Modern applications rely heavily on asynchronous operations. Anchor provides powerful primitives for managing async operations with built-in reactivity, cancellation support, and automatic status tracking.
The Problem
Traditional async operation handling in JavaScript requires manual orchestration:
- Status Tracking: You must manually track loading, success, and error states.
- Cancellation: Implementing request cancellation requires boilerplate with AbortController.
- Race Conditions: Multiple concurrent requests can overwrite each other's results.
- Reactivity Gap: Async results don't automatically trigger UI updates without additional wiring.
The Solution
Anchor provides a single, unified approach for async operations: query().
The query() function creates a reactive container for any async operation. It works with any Promise-returning function, including fetch or IRPC client calls, and returns a reactive state object that automatically notifies your UI when data arrives, errors occur, or status changes.
Basic Usage
import { query } from '@anchorlib/react';
// Define an async operation with structural initial data
const userQuery = query(
async (signal) => {
const response = await fetch('/api/user', { signal });
return response.json();
},
{ name: '', email: '' }
);
// Access the state safely immediately
console.log(userQuery.status); // 'pending'
console.log(userQuery.data.name); // '' initiallyimport { query } from '@anchorlib/solid';
// Define an async operation with structural initial data
const userQuery = query(
async (signal) => {
const response = await fetch('/api/user', { signal });
return response.json();
},
{ name: '', email: '' }
);
// Access the state safely immediately
console.log(userQuery.status); // 'pending'
console.log(userQuery.data.name); // '' initiallyExecution Environment
Queries only start automatically when executed in the browser. During Server-Side Rendering (SSR), queries do not automatically execute and will simply return a 'pending' state to prevent memory leaks and server-side blocking.
The state object contains the following reactive properties:
data: The result of the async operation (matches initial data shape until resolved)status: Current status ('idle','pending','success', or'error')error: Error object if the operation failedpromise: A Promise that resolves when the current operation completes (or a resolved promise if idle)start(): Method to manually trigger the operationabort(): Method to cancel the ongoing operation
Deferred Execution
By default, queries execute immediately. Use the deferred option to control when execution starts:
const userQuery = query(
async (signal) => {
const response = await fetch('/api/user', { signal });
return response.json();
},
{ name: '', email: '' },
{ deferred: true }
);
// Later, when ready
userQuery.start();Cancellation
Every query receives an AbortSignal that you can pass to fetch or other cancellable APIs:
const searchQuery = query(
async (signal) => {
const response = await fetch(`/api/search?q=${term}`, { signal });
return response.json();
},
[] // Initial empty array for lists
);
// Cancel the request
searchQuery.abort();When aborted, the query's status becomes 'error' and the error contains the abort reason.
Re-fetching
Call start() to re-execute the query. If a request is already pending, it will be automatically cancelled:
const dataQuery = query(
async (signal) => {
const response = await fetch('/api/data', { signal });
return response.json();
},
{ items: [], total: 0 }
);
// Refresh the data
function refresh() {
dataQuery.start();
}You can also pass new initial data when re-starting:
dataQuery.start({ items: [], total: 0, newFetch: true });Status Management
All async state objects use a consistent status lifecycle:
type FetchStatus = 'idle' | 'pending' | 'success' | 'error';idle: Initial state when usingdeferred: truepending: Operation in progresssuccess: Operation completed successfullyerror: Operation failed or was aborted
Reactive Status
Because status is reactive, your UI automatically updates:
import { setup, render, query } from '@anchorlib/react';
export const UserProfile = setup(() => {
const user = query(
async (signal) => {
const res = await fetch('/api/user', { signal });
return res.json();
},
{ name: '' }
);
return render(() => (
<div>
{user.status === 'pending' && <p>Loading...</p>}
{user.status === 'error' && <p>Error: {user.error?.message}</p>}
{user.status === 'success' && <p>Hello, {user.data.name}!</p>}
</div>
));
});import { setup, query } from '@anchorlib/solid';
import { Show, Switch, Match } from 'solid-js';
export const UserProfile = setup(() => {
const user = query(
async (signal) => {
const res = await fetch('/api/user', { signal });
return res.json();
},
{ name: '' }
);
return (
<div>
<Switch>
<Match when={user.status === 'pending'}>
<p>Loading...</p>
</Match>
<Match when={user.status === 'error'}>
<p>Error: {user.error?.message}</p>
</Match>
<Match when={user.status === 'success'}>
<p>Hello, {user.data.name}!</p>
</Match>
</Switch>
</div>
);
});Converting to Promises
The query() function exposes a .promise property that returns a Promise for use with async/await.
import { query } from '@anchorlib/react';
// E.g., inside an IRPC Handler or Route Provider
export async function fetchUserData() {
const userQuery = query(
async (signal) => {
const res = await fetch('/api/user', { signal });
return res.json();
},
{ name: '', email: '' }
);
// Safely wait for the query to resolve before returning
await userQuery.promise;
return userQuery.data;
}import { query } from '@anchorlib/solid';
// E.g., inside an IRPC Handler or Route Provider
export async function fetchUserData() {
const userQuery = query(
async (signal) => {
const res = await fetch('/api/user', { signal });
return res.json();
},
{ name: '', email: '' }
);
// Safely wait for the query to resolve before returning
await userQuery.promise;
return userQuery.data;
}The promise property is a getter that:
- Returns the active promise if an operation is currently running
- Returns
Promise.resolve(undefined)if no operation is active (idle state) - Allows seamless integration with async/await patterns
This is useful for:
- Server-side rendering where you need to wait for data (e.g., in a route loader or server action)
- Sequential operations that depend on previous results
- Integration with existing Promise-based code
Best Practices
Use Structural Initial Data
Always provide initial data that matches your expected structure instead of undefined or empty objects {}. This eliminates the need for optional chaining and makes your code more predictable.
// ✅ Safe to access immediately
const todos = query(
async (signal) => {
const res = await fetch('/api/todos', { signal });
return res.json();
},
[] // Initial empty array for lists
);
// No need for optional chaining
console.log(todos.data.length); // Always works
// ✅ Safe object access
const user = query(
async (signal) => {
const res = await fetch('/api/user', { signal });
return res.json();
},
{ name: '', email: '' } // Structural initial shape
);
console.log(user.data.name); // Guaranteed to workLeverage Automatic Cancellation
When you call start() on a pending query, it automatically cancels the previous request. Use effect() to automatically re-fetch when dependencies change.
const term = mutable('');
const search = query(
async (signal) => {
const res = await fetch(`/api/search?q=${term.value}`, { signal });
return res.json();
},
[],
{ deferred: true }
);
// Automatically re-fetch when term changes
effect(() => {
if (term.value) {
search.start(); // Cancels previous request automatically
}
});
// Just update the term, effect handles the rest
term.value = 'new query';The query automatically cancels pending requests when start() is called again. Combined with effect(), you get automatic search-as-you-type with built-in cancellation.
Direct Mutation of Async State
Because async state objects are mutable, you can update them directly when needed:
const dataQuery = query(fetchData, { items: [] });
// Direct mutation works
dataQuery.data.items.push(newItem);
// Update nested properties
dataQuery.data.items[0].name = 'Updated';
// All mutations trigger fine-grained UI updatesThis is particularly useful when you need to optimistically update the UI before a mutation completes.
Combine Queries with Computed Properties
Use JavaScript getters to derive values from async state:
const store = mutable({
usersQuery: query(fetchUsers, []),
postsQuery: query(fetchPosts, []),
// Automatically recomputes when either query updates
get isLoading() {
return this.usersQuery.status === 'pending' ||
this.postsQuery.status === 'pending';
},
get hasErrors() {
return this.usersQuery.status === 'error' ||
this.postsQuery.status === 'error';
},
get allData() {
return {
users: this.usersQuery.data,
posts: this.postsQuery.data
};
}
});These getters are automatically reactive—no dependency arrays needed.
Parallel Queries for Independent Data
Start independent queries simultaneously to avoid request waterfalls:
export const Dashboard = setup(() => {
const store = mutable({
user: query(fetchUser, { name: '' }),
stats: query(fetchStats, { total: 0 }),
notifications: query(fetchNotifications, []),
get isReady() {
return this.user.status === 'success' &&
this.stats.status === 'success' &&
this.notifications.status === 'success';
}
});
return render(() => (
<div>
{store.isReady ? <Content data={store} /> : <Loading />}
</div>
));
});export const Dashboard = setup(() => {
const store = mutable({
user: query(fetchUser, { name: '' }),
stats: query(fetchStats, { total: 0 }),
notifications: query(fetchNotifications, []),
get isReady() {
return this.user.status === 'success' &&
this.stats.status === 'success' &&
this.notifications.status === 'success';
}
});
return (
<div>
<Show when={store.isReady} fallback={<Loading />}>
<Content data={store} />
</Show>
</div>
);
});Sequential Queries with Dependencies
When one query depends on another, use effect() or direct function calls:
const store = mutable({
userId: 1,
userQuery: query(
async (signal) => {
const res = await fetch(`/api/users/${store.userId}`, { signal });
return res.json();
},
{ id: 0, name: '' },
{ deferred: true }
),
postsQuery: query(fetchPosts, [], { deferred: true }),
async loadUserAndPosts() {
// Sequential: wait for user first
await this.userQuery.promise;
// Then load posts with user ID
if (this.userQuery.status === 'success') {
this.postsQuery.start();
}
}
});
// Or use effect for automatic re-fetching
effect(() => {
if (store.userQuery.status === 'success') {
store.postsQuery.start();
}
});