Handling Async Operations Like a Pro

Signals + TanStack Query: Handling Async Operations Like a Pro

If you've been deep into signals, you know their value. They are a powerful reactive primitive that grants state control and efficiency by decoupling state updates from the component lifecycle. You’ve replaced your useState with signal(), and your app feels cleaner and faster than before.

But when you move beyond the local component boundary, you hit the biggest architectural hurdle: Signals are synchronous.

Simple online tutorials show you how to build a counter. But building an actual, real-world application—one that is fetching data from API, background refetching, loading states, and user interaction is exponentially more complex.

If you try to wire this up manually (e.g., using native fetch and trying to pipe its promise result into a signal), you quickly run into severe plumbing issues. You are responsible for the entire state machine: handling the loading state, correctly managing errors, preventing race conditions (where a slow, old request resolves after a fast, new request), and crucially, manually implementing request cancellation logic.

You may have seen the resource utility pattern I posted previously to solve parts of this. While that approach is effective, for production and more complex applications, you want the battle-tested reliability and proven architectural patterns of a library like TanStack Query, seamlessly combined with the reactive primitives of signals.

Before you quit and go back to standard hooks, read this article. There is a better way.

The Solution: TanStack Query + Signals

In the React world, TanStack Query is the gold standard for server state. Developers love TanStack Query because it solves hard problems gracefully. It handles caching, retries, and background revalidation perfectly.

Instead of choosing between Signals and TanStack Query, we are going to combine them.

Bridging the Gap: Implementing Signal-Queries

To make this work, we need a small utility that "pipes" TanStack Query updates into a Signal. This utility takes your fetching configuration and wraps the resulting observable QueryObserver into a reactive Signal.

import { signal, computed, effect, Signal } from '@preact/signals-react';
import { 
  QueryClient, 
  QueryObserver, 
  QueryObserverOptions, 
  QueryObserverResult,
} from '@tanstack/query-core';

// Provide a default client or inject via parameters
const queryClient = new QueryClient();
type QueryResult<T> = Signal<QueryObserverResult<T>>;

export function query<T>(
  getQueryConfig: () => QueryObserverOptions<T>,
  client: QueryClient = queryClient
): QueryResult<T> {

  // Tracks any config changes (e.g. a signal-based query key)
  const queryConfig = computed(() => getQueryConfig());

  // reuse the observer for the lifetime of this query — never recreated
  const observer = new QueryObserver(client, queryConfig.value);

  const result = signal<QueryObserverResult<T>>(observer.getCurrentResult());

  // When queryConfig changes, update the observer
  // setOptions triggers a refetch internally if the query key changed
  effect(() => {
    observer.setOptions(queryConfig.value);
  });

  // subscribe to the observer and pipe the results into the signal
  const queryResultsSubscription = observer.subscribe((r) => {
    result.value = r;
  });

  return result;
}

Usage Example: Fetching Data

Once wrapped, using the state feels like using local Signals, but the underlying complexity is handled by TanStack Query.

Notice how the userQuery signal automatically handles the data fetching logic. If the userId changes, the computed signal rebuilds, triggering the QueryObserver to fetch new data, and the effect updates the userQuery Signal—all without manual dependency arrays or useEffect cleanup.

const userId = signal(1);

// The query automatically re-runs if userId.value changes
const userQuery = query(() => ({
  queryKey: ['user', userId.value],
  queryFn: () => fetch(`/api/users/${userId.value}`).then(res => res.json()),
}));

export function UserProfile() {
  const { data, isFetching } = userQuery.value;

  if (isFetching) return <div>Loading...</div>;

  return (
    <div>
      <h1>{data.name}</h1>
      <button onClick={() => userId.value++}>Next User</button>
    </div>
  );
}

Handling Writes: The mutation Utility

Write operations (like form submissions or deleting an item) are fundamentally different from reads. They involve immediate, irreversible actions. Our mutation utility tracks the status and result of these actions and exposes them as a Signal.

import { signal, computed, effect, Signal } from '@preact/signals-react';
import { 
  QueryClient, 
  MutationObserver,
  MutationObserverOptions,
  MutationObserverResult 
} from '@tanstack/query-core';

// Provide a default client or inject via parameters
const defaultQueryClient = new QueryClient();

type MutationResult<TData, TError = unknown, TVariables = void> = Signal<MutationObserverResult<TData, TError, TVariables>>;

/**
 * Wraps a TanStack Query mutation into a reactive signal.
 */
export function mutation<TData, TError = unknown, TVariables = void>(
  getMutationConfig: () => MutationObserverOptions<TData, TError, TVariables>,
  client: QueryClient = queryClient
): MutationResult<TData, TError, TVariables> {
  const mutationConfig = computed(() => getMutationConfig());
  const observer = new MutationObserver(client, mutationConfig.value);
  const result = signal(observer.getCurrentResult());

  effect(() => {
    const config = mutationConfig.value;
    observer.setOptions(config);
  });

  const mutationResultsSubscription = observer.subscribe((r) => {
    result.value = r;
  });

  return result;
}

Example 2: Submitting Data (Mutation)

The mutation signal tracks the success/failure of the write operation, allowing you to show toasts, disable buttons, and react to user feedback.


const deleteUserMutation = mutation(() => ({
  mutationFn: async (userId: number) => deleteUser(userId),
  // onSuccess, invalidate the cache
  onSuccess: (data) => {
    queryClient.invalidateQueries({ queryKey: ['users'] });
  }
}));

function UserDetailsPage() {

  const { userId } = useParams(); 
  const { mutate, isPending } = deleteUserMutation.value;

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    mutate(userId); 
  };

  return (
    <form onSubmit={handleSubmit} disabled={isPending}>
      {/* ... inputs ... */}
      <button type="submit" disabled={isPending}>
        {isPending ? 'Deleting...' : 'Delete User'}
      </button>
    </form>
  );
}

Lifecycle Mismatch and Memory Leaks

If the query lives for the entire app lifetime, there's nothing to tear down. If you want to control the livecycle of the queries, then you will need to initialize them in React.

When a component unmounts, its associated cleanup logic must run. If an API call is still running in the background and resolves after the component is gone, it will try to update a Signal that is no longer observed. This dangling update attempt can crash your app or, worse, create a memory leak by preventing resources from being garbage collected.

Extend the query util to export a dispose method.

type QueryResult<T> = Signal<QueryObserverResult<T>> & { dispose: () => void };

export function query<T>(
  getQueryConfig: () => QueryObserverOptions<T>,
  client: QueryClient = queryClient
): QueryResult<T> {

  // Tracks any config changes (e.g. a signal-based query key)
  const queryConfig = computed(() => getQueryConfig());

  // reuse the observer for the lifetime of this query — never recreated
  const observer = new QueryObserver(client, queryConfig.value);

  const result = signal<QueryObserverResult<T>>(observer.getCurrentResult());

  // When queryConfig changes, update the observer
  // setOptions triggers a refetch internally if the query key changed
  const observerEffectCleanup = effect(() => {
    observer.setOptions(queryConfig.value);
  });

  // subscribe to the observer and pipe the results into the signal
  const queryResultsSubscription = observer.subscribe((r) => {
    result.value = r;
  });

  const dispose = () => {
    observerEffectCleanup();  // stop reacting to config changes
    queryResultsSubscription();     // stop piping query results into the signal
    observer.destroy(); // release internal query cache subscriptions
  };

  // or just return the result if the queries are global
  return Object.assign(result, { dispose });
}

Then in your React component or hooks, add useEffect that will run once when the component is rendered and call the dispose method when unmounted.

let userQuery: QueryResult<User>;

const UserProfilePage = () => {
  const { userId } = useParams();
  const { data, isFetching } = userQuery?.value;

  // initialization hook
  // will run once when component is mounted
  useEffect(() => {
    userQuery = query(() => ({
      queryKey: ['user', userId],
      // if userId is a signal, it will re-run when signal value is changed
      queryFn: () => fetch(`/api/users/${userId}`).then(res => res.json()),
    }));

    // cleanup when unmounts
    return () => userQuery.dispose();

  }, [userId]);

  if (isFetching || !data) return <div>Loading...</div>;

  return (
    <div>
      <h1>User name:{data.name}</h1>
    </div>
  );
}

Final Thoughts

Signals are not just for local counters and toggles. When you pair them with a robust tool like TanStack Query, you get the best of both worlds: industry-standard data management and lightning-fast UI updates.

By adopting this approach, you are not just writing code; you are building a structured system that isolates the complexities of asynchronous data fetching from the simplicity of your reactive UI logic.

Thanks for reading! If this helped you, follow WebDevPlaybook for more content.

You can also support my work here:
https://buymeacoffee.com/webdevplaybook

Last updated on Mon May 11 2026