Beyond Synchronous State: Mastering Async operations with Signals in React

Async operations with Signals in React

If you’ve read my previous article, you know that Signals are incredible for granular, high-performance reactivity. They are the modern, efficient way to manage state in React, allowing components to update only when necessary.

As your application scales, Signals allow you to decouple business logic and move it outside of the React component lifecycle. This is where true architectural power lies.

However, the power and elegance of Signals come with a significant architectural caveat: Signals are synchronous primitives, but the real world is asynchronous.

When we build modern applications, we don't just read simple counters; we fetch complex data, handle Promises, and manage server state. Dealing with values that don't exist yet—values that only arrive later—is the biggest conceptual hurdle when adopting Signals for large-scale applications.

This post tackles that fundamental mismatch: Bridging the gap between a synchronous Signal and an asynchronous Promise.

💡 The Core Dilemma: Promises vs. Signals

Let's start with simple definitions:

  • Signal: Holds a value right now. (Example: count(5). The value is 5.)
  • Promise: Represents a value later. It is a placeholder for a future result. (Example: fetch('/api/data'). We don't have the data yet; we only have the promise that we will get it.)

If you naively try to assign a Promise to a Signal, you end up with a Signal<Promise<T>>.

If we just expose the Promise, our component only knows one thing: "Something is happening."

But to build a good user experience, we need more than just the data; we need to know the entire state machine of the request.

The Signal State Machine: Is the data still loading? (isLoading: true) Did the request fail? (error: MyErrorMessage) Is the data finally here? (data: TheActualObject)

The goal is to "unwrap" the Promise and transform that single asynchronous event into three separate, reactive Signals.

🛠️ The Resource Utility

For simple use cases, especially those involving fetching data from an API, a Resource Utility is the perfect pattern.

This utility wraps the entire fetching logic, handling errors and managing the state machine for us. It uses two critical browser features:

  • AbortController: This allows us to cancel old, stale requests when the component re-renders or the user initiates a new fetch. This prevents unnecessary network calls and potential state corruption.
  • Reactive Batching: When we update multiple signals (e.g., setting isLoading to false, setting error to null, and setting data), we must ensure they happen together in a single, controlled update cycle to prevent UI flickering.

Here is a simplified look at the utility structure:

import { signal, effect, batch } from '@preact/signals-react';

/**
 * Wraps an async function into reactive signals.
 * Automatically aborts the in-flight request on re-run or dispose.
 */
export function resource<T, TError = unknown>(
  promiseFunction: (abortSignal: AbortSignal) => Promise<T>
) {
  const data = signal<T | undefined>(undefined);
  const isLoading = signal<boolean>(true);
  const error = signal<TError | undefined>(undefined);

  const effectCleanup = effect(() => {
    const abortController = new AbortController();
    const { signal: abortSignal } = abortController;

    async function run() {
      try {
        const result = await promiseFunction(abortSignal);
        if (abortSignal.aborted) return;
        batch(() => {
          data.value = result;
          error.value = undefined;
        });
      } catch (err) {
        if (abortSignal.aborted) return;
        error.value = err as TError;
      } finally {
        if (!abortSignal.aborted) {
          isLoading.value = false;
        }
      }
    }

    run();

    return () => abortController.abort();
  });

  const dispose = () => effectCleanup();

  return { data, isLoading, error, dispose };
}

⚛️ Integrating in React

A raw utility function is powerful, but to make it fully integrated into a React application, we need a strategy for consuming that state. The most robust approach is to keep the core state management (the resource instance) outside of the component, and only read the reactive state inside the component.

type User = {
    id: string;
    name: string;
    email: string;
}
// state.ts
const userId = signal('john.doe');

// userByIdQuery
const userByIdQuery = resource<User>(() => {
    // fetch will be re-executed when userId changes
    return fetch(`/api/users/${userId.value}`).then((res) => res.json())
});

const App = () => {

    // read the signal value
    const user = userByIdQuery.data.value;

    if(userByIdQuery.isLoading.value){
        return 'Loading...';
    }

    if (userByIdQuery.error.value) 
        return <p class="error">Could not fetch user</p>;

    if (!user) return <p>No user found.</p>;

    return <h1>{user.name}</h1>;
}

Note: This assumes you have the correct setup for signals. Alternatively, you could wrap this logic in a custom hook for consumption.

Example how the code could look like with custom hook.

const App = () => {

    // assign the signal value in local variable
    const user = useSignalValue(userByIdQuery.data);
    const isLoading = useSignalValue(userByIdQuery.isLoading);
    const error = useSignalValue(userByIdQuery.error);

    // read the signal value directly
    if(isLoading){
    return 'Loading...';
    }

    if (error) return <p class="error">Could not fetch user</p>;

    // and re-use it
    if (!user) return <p>No user found.</p>;

    return <h1>{user.name}</h1>;
}

🧪 Testing the Async Logic

Testing the Resource Utility is crucial because it handles complex state transitions. By using a testing library that allows mocking Promises, we can simulate successful, failed, and aborted network responses reliably.

it('should initialize and resolve correctly (promise)', async () => {
    vi.useFakeTimers();
    const userId = signal('john.doe');
    // simulate fetch call
    const queryResult = resource(() => Promise.resolve(userId.value));

    expect(queryResult.isLoading.value).toBe(true);
    expect(queryResult.data.value).toEqual(undefined);
    expect(queryResult.error.value).toBeUndefined();

    await vi.advanceTimersToNextTimerAsync();

    expect(queryResult.isLoading.value).toBe(false);
    expect(queryResult.data.value).toEqual('john.doe');
    expect(queryResult.error.value).toBeUndefined();

    // should refetch when some of the dependencies is updated
    userId.value = 'john-doe';

    expect(queryResult.isLoading.value).toBe(true);
    // should not clear the old value while fetching
    expect(queryResult.data.value).toEqual('john.doe');
    expect(queryResult.error.value).toBeUndefined();

    await vi.advanceTimersToNextTimerAsync();

    expect(queryResult.isLoading.value).toBe(false);
    expect(queryResult.data.value).toEqual('john-doe');
    expect(queryResult.error.value).toBeUndefined();
});

Final Thoughts

Signals are not just for local counters. When paired with utilities like the resource pattern, they become a powerful engine for managing complex, asynchronous server state.

The best thing is that, after adopting this pattern, you can manage state—including asynchronous state—all in one centralized place.

Don't feel pressured to move your entire app to Signals overnight. Replace a single shared piece of state with a Signal and then expand only where it adds value. However, maintain architectural discipline. Managing async operations, side effects, and derived state requires care; Without established architectural patterns, mixing these paradigms can easily lead to a fragmented and highly confusing codebase.

Last updated on Tue May 05 2026

Enjoyed this article?

Get one like it in your inbox every week — practical patterns, real code, no filler.

No spam. Unsubscribe anytime.