Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/twelve-meals-tease.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@preact/signals": minor
---

Add `asyncComputed` primitive to have a first class util to handle (suspenseful) data fetching.
62 changes: 62 additions & 0 deletions packages/preact/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,68 @@ function Component() {
}
```

### `useAsyncComputed<T>(compute: () => Promise<T> | T, options?: AsyncComputedOptions)`

A Preact hook that creates a signal that computes its value asynchronously. This is particularly useful for handling async data fetching and other asynchronous operations in a reactive way.

> You can also import `asyncComputed` as a non-hook way

#### Parameters

- `compute`: A function that returns either a Promise or a direct value.
Using signals here will track them, when the signal changes it will re-execute `compute`.
- `options`: Configuration options
- `suspend?: boolean`: Whether to enable Suspense support (defaults to true)

#### Returns

An `AsyncComputed<T>` object with the following properties:

- `value: T | undefined`: The current value (undefined while loading)
- `error: Signal<unknown>`: Signal containing any error that occurred
- `running: Signal<boolean>`: Signal indicating if the computation is in progress

> When inputs to `compute` change the value and error will be retained but `running` will be `true`.

#### Example

```typescript
import { useAsyncComputed } from "@preact/signals/utils";

function UserProfile({ userId }: { userId: Signal<string> }) {
const userData = useAsyncComputed(
async () => {
const response = await fetch(`/api/users/${userId.value}`);
return response.json();
},
{ suspend: false }
);

if (userData.running.value) {
return <div>Loading...</div>;
}

if (userData.error.value) {
return <div>Error: {String(userData.error.value)}</div>;
}

return (
<div>
<h1>{userData.value?.name}</h1>
<p>{userData.value?.email}</p>
</div>
);
}
```

The hook will automatically:

- Recompute when dependencies change (e.g., when `userId` changes)
- Handle loading and error states
- Clean up subscriptions when the component unmounts
- Cache results between re-renders
- Support Suspense when `suspend: true`

## License

`MIT`, see the [LICENSE](../../LICENSE) file.
165 changes: 163 additions & 2 deletions packages/preact/utils/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { ReadonlySignal, Signal } from "@preact/signals-core";
import { ReadonlySignal, signal, Signal, effect } from "@preact/signals-core";
import { useSignal } from "@preact/signals";
import { Fragment, createElement, JSX } from "preact";
import { useMemo } from "preact/hooks";
import { useMemo, useRef, useEffect, useId } from "preact/hooks";

interface ShowProps<T = boolean> {
when: Signal<T> | ReadonlySignal<T>;
Expand Down Expand Up @@ -69,3 +69,164 @@ const refSignalProto = {
this.value = v;
},
};

/**
* Represents a Promise with optional value and error properties
*/
interface AugmentedPromise<T> extends Promise<T> {
value?: T;
error?: unknown;
}

/**
* Represents the state and behavior of an async computed value
*/
interface AsyncComputed<T> extends Signal<T> {
value: T;
error: Signal<unknown>;
running: Signal<boolean>;
pending?: AugmentedPromise<T> | null;
/** @internal */
_cleanup(): void;
}

/**
* Options for configuring async computed behavior
*/
interface AsyncComputedOptions {
/** Whether to throw pending promises for Suspense support */
suspend?: boolean;
}

/**
* Creates a signal that computes its value asynchronously
* @template T The type of the computed value
* @param compute Function that returns a Promise or value
* @returns AsyncComputed signal
*/
export function asyncComputed<T>(
compute: () => Promise<T> | T
): AsyncComputed<T | undefined> {
const out = signal<T | undefined>(undefined) as AsyncComputed<T | undefined>;
out.error = signal<unknown>(undefined);
out.running = signal<boolean>(false);

const applyResult = (value: T | undefined, error?: unknown) => {
if (out.running.value) {
out.running.value = false;
}

if (out.pending) {
out.pending.error = error;
out.pending.value = value;
out.pending = null;
}

if (out.error.peek() !== error) {
out.error.value = error;
}

if (out.peek() !== value) {
out.value = value;
}
};

let computeCounter = 0;

out._cleanup = effect(() => {
const currentId = ++computeCounter;

try {
const result = compute();

// Handle synchronous resolution
if (isPromise(result)) {
if ("error" in result) {
return applyResult(undefined, result.error);
}
if ("value" in result) {
return applyResult(result.value as T);
}

out.running.value = true;

// Handle async resolution
out.pending = result.then(
(value: T) => {
if (currentId === computeCounter) {
applyResult(value);
}
return value;
},
(error: unknown) => {
if (currentId === computeCounter) {
applyResult(undefined, error);
}
return undefined;
}
) as AugmentedPromise<T>;
} else {
out.running.value = false;
applyResult(result);
}
} catch (error) {
applyResult(undefined, error);
}
});

return out;
}

const ASYNC_COMPUTED_CACHE = new Map<string, AsyncComputed<any>>();

/**
* Hook for using async computed values with optional Suspense support
* @template T The type of the computed value
* @param compute Function that returns a Promise or value
* @param options Configuration options
* @returns AsyncComputed signal
*/
export function useAsyncComputed<T>(
compute: () => Promise<T> | T,
options: AsyncComputedOptions = {}
): AsyncComputed<T | undefined> {
const id = useId();
const computeRef = useRef(compute);
computeRef.current = compute;

const result = useMemo(() => {
const cached = ASYNC_COMPUTED_CACHE.get(id);
const incoming = asyncComputed(() => computeRef.current());

if (cached) {
incoming.running = cached.running;
incoming.value = cached.value;
incoming.error.value = cached.error.peek();
cached._cleanup();
}

if (options.suspend !== false) {
ASYNC_COMPUTED_CACHE.set(id, incoming);
}

return incoming;
}, []);

useEffect(() => result._cleanup, [result]);

if (
options.suspend !== false &&
result.pending &&
!result.value &&
!result.error.value
) {
throw result.pending;
}

ASYNC_COMPUTED_CACHE.delete(id);
return result;
}

function isPromise(obj: any): obj is Promise<any> {
return obj && "then" in obj;
}
124 changes: 123 additions & 1 deletion packages/preact/utils/test/browser/index.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { signal } from "@preact/signals";
import { For, Show, useSignalRef } from "@preact/signals/utils";
import {
For,
Show,
useAsyncComputed,
useSignalRef,
} from "@preact/signals/utils";
import { render, createElement } from "preact";
import { act } from "preact/test-utils";

Expand Down Expand Up @@ -81,4 +86,121 @@ describe("@preact/signals-utils", () => {
expect((ref as any).value instanceof HTMLSpanElement).to.eq(true);
});
});

describe("asyncComputed", () => {
let resolve: (value: { foo: string }) => void;
const fetchResult = (url: string): Promise<{ foo: string }> => {
// eslint-disable-next-line no-console
console.log("fetching", url);
return new Promise(res => {
resolve = res;
});
};

it("Should reactively update when the promise resolves", async () => {
const AsyncComponent = (props: any) => {
const data = useAsyncComputed<{ foo: string }>(
async () => fetchResult(props.url.value),
{ suspend: false }
);
const hasData = data.value !== undefined;
return (
<p>
{data.pending ? "pending" : hasData ? data.value?.foo : "error"}
</p>
);
};
const url = signal("/api/foo?id=1");
act(() => {
render(<AsyncComponent url={url} />, scratch);
});
expect(scratch.innerHTML).to.eq("<p>pending</p>");

await act(async () => {
await resolve({ foo: "bar" });
await new Promise(resolve => setTimeout(resolve, 100));
});

expect(scratch.innerHTML).to.eq("<p>bar</p>");
});

it("Should fetch when the input changes", async () => {
const AsyncComponent = (props: any) => {
const data = useAsyncComputed<{ foo: string }>(
async () => fetchResult(props.url.value),
{ suspend: false }
);
const hasData = data.value !== undefined;
return (
<p>
{data.pending ? "pending" : hasData ? data.value?.foo : "error"}
</p>
);
};
const url = signal("/api/foo?id=1");
act(() => {
render(<AsyncComponent url={url} />, scratch);
});
expect(scratch.innerHTML).to.eq("<p>pending</p>");

await act(async () => {
await resolve({ foo: "bar" });
await new Promise(resolve => setTimeout(resolve));
});

expect(scratch.innerHTML).to.eq("<p>bar</p>");

act(() => {
url.value = "/api/foo?id=2";
});

await act(async () => {
await resolve({ foo: "baz" });
await new Promise(resolve => setTimeout(resolve));
});
expect(scratch.innerHTML).to.eq("<p>baz</p>");
});

it("Should apply the 'running' signal", async () => {
const AsyncComponent = (props: any) => {
const data = useAsyncComputed<{ foo: string }>(
async () => fetchResult(props.url.value),
{ suspend: false }
);
const hasData = data.value !== undefined;
return (
<p>
{data.running.value
? "running"
: hasData
? data.value?.foo
: "error"}
</p>
);
};
const url = signal("/api/foo?id=1");
act(() => {
render(<AsyncComponent url={url} />, scratch);
});
expect(scratch.innerHTML).to.eq("<p>running</p>");

await act(async () => {
await resolve({ foo: "bar" });
await new Promise(resolve => setTimeout(resolve));
});

expect(scratch.innerHTML).to.eq("<p>bar</p>");

act(() => {
url.value = "/api/foo?id=2";
});
expect(scratch.innerHTML).to.eq("<p>running</p>");

await act(async () => {
await resolve({ foo: "baz" });
await new Promise(resolve => setTimeout(resolve));
});
expect(scratch.innerHTML).to.eq("<p>baz</p>");
});
});
});