Skip to content

Add async computed #686

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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>");
});
});
});