Skip to content

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

ts
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); // '' initially
ts
import { 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); // '' initially

Execution 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 failed
  • promise: A Promise that resolves when the current operation completes (or a resolved promise if idle)
  • start(): Method to manually trigger the operation
  • abort(): Method to cancel the ongoing operation

Deferred Execution

By default, queries execute immediately. Use the deferred option to control when execution starts:

ts
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:

ts
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:

ts
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:

ts
dataQuery.start({ items: [], total: 0, newFetch: true });

Status Management

All async state objects use a consistent status lifecycle:

ts
type FetchStatus = 'idle' | 'pending' | 'success' | 'error';
  • idle: Initial state when using deferred: true
  • pending: Operation in progress
  • success: Operation completed successfully
  • error: Operation failed or was aborted

Reactive Status

Because status is reactive, your UI automatically updates:

tsx
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>
  ));
});
tsx
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.

ts
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;
}
ts
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.

ts
// ✅ 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 work

Leverage 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.

ts
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:

ts
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 updates

This 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:

ts
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:

tsx
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>
  ));
});
tsx
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:

ts
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();
  }
});