Skip to content

Commit 6c7b789

Browse files
committed
Async computed running
1 parent 7a62c6d commit 6c7b789

File tree

3 files changed

+117
-1
lines changed

3 files changed

+117
-1
lines changed

packages/preact/README.md

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,68 @@ function Component() {
189189
}
190190
```
191191
192+
### `useAsyncComputed<T>(compute: () => Promise<T> | T, options?: AsyncComputedOptions)`
193+
194+
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.
195+
196+
> You can also import `asyncComputed` as a non-hook way
197+
198+
#### Parameters
199+
200+
- `compute`: A function that returns either a Promise or a direct value.
201+
Using signals here will track them, when the signal changes it will re-execute `compute`.
202+
- `options`: Configuration options
203+
- `suspend?: boolean`: Whether to enable Suspense support (defaults to true)
204+
205+
#### Returns
206+
207+
An `AsyncComputed<T>` object with the following properties:
208+
209+
- `value: T | undefined`: The current value (undefined while loading)
210+
- `error: Signal<unknown>`: Signal containing any error that occurred
211+
- `running: Signal<boolean>`: Signal indicating if the computation is in progress
212+
213+
> When inputs to `compute` change the value and error will be retained but `running` will be `true`.
214+
215+
#### Example
216+
217+
```typescript
218+
import { useAsyncComputed } from "@preact/signals/utils";
219+
220+
function UserProfile({ userId }: { userId: Signal<string> }) {
221+
const userData = useAsyncComputed(
222+
async () => {
223+
const response = await fetch(`/api/users/${userId.value}`);
224+
return response.json();
225+
},
226+
{ suspend: false }
227+
);
228+
229+
if (userData.running.value) {
230+
return <div>Loading...</div>;
231+
}
232+
233+
if (userData.error.value) {
234+
return <div>Error: {String(userData.error.value)}</div>;
235+
}
236+
237+
return (
238+
<div>
239+
<h1>{userData.value?.name}</h1>
240+
<p>{userData.value?.email}</p>
241+
</div>
242+
);
243+
}
244+
```
245+
246+
The hook will automatically:
247+
248+
- Recompute when dependencies change (e.g., when `userId` changes)
249+
- Handle loading and error states
250+
- Clean up subscriptions when the component unmounts
251+
- Cache results between re-renders
252+
- Support Suspense when `suspend: true`
253+
192254
## License
193255
194256
`MIT`, see the [LICENSE](../../LICENSE) file.

packages/preact/utils/src/index.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ interface AugmentedPromise<T> extends Promise<T> {
8484
interface AsyncComputed<T> extends Signal<T> {
8585
value: T;
8686
error: Signal<unknown>;
87+
running: Signal<boolean>;
8788
pending?: AugmentedPromise<T> | null;
8889
/** @internal */
8990
_cleanup(): void;
@@ -108,8 +109,13 @@ export function asyncComputed<T>(
108109
): AsyncComputed<T | undefined> {
109110
const out = signal<T | undefined>(undefined) as AsyncComputed<T | undefined>;
110111
out.error = signal<unknown>(undefined);
112+
out.running = signal<boolean>(false);
111113

112114
const applyResult = (value: T | undefined, error?: unknown) => {
115+
if (out.running.value) {
116+
out.running.value = false;
117+
}
118+
113119
if (out.pending) {
114120
out.pending.error = error;
115121
out.pending.value = value;
@@ -142,10 +148,14 @@ export function asyncComputed<T>(
142148
return applyResult(result.value as T);
143149
}
144150

151+
out.running.value = true;
152+
145153
// Handle async resolution
146154
out.pending = result.then(
147155
(value: T) => {
148-
applyResult(value);
156+
if (currentId === computeCounter) {
157+
applyResult(value);
158+
}
149159
return value;
150160
},
151161
(error: unknown) => {
@@ -156,6 +166,7 @@ export function asyncComputed<T>(
156166
}
157167
) as AugmentedPromise<T>;
158168
} else {
169+
out.running.value = false;
159170
applyResult(result);
160171
}
161172
} catch (error) {
@@ -188,6 +199,7 @@ export function useAsyncComputed<T>(
188199
const incoming = asyncComputed(() => computeRef.current());
189200

190201
if (cached) {
202+
incoming.running = cached.running;
191203
incoming.value = cached.value;
192204
incoming.error.value = cached.error.peek();
193205
cached._cleanup();

packages/preact/utils/test/browser/index.test.tsx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,5 +160,47 @@ describe("@preact/signals-utils", () => {
160160
});
161161
expect(scratch.innerHTML).to.eq("<p>baz</p>");
162162
});
163+
164+
it("Should apply the 'running' signal", async () => {
165+
const AsyncComponent = (props: any) => {
166+
const data = useAsyncComputed<{ foo: string }>(
167+
async () => fetchResult(props.url.value),
168+
{ suspend: false }
169+
);
170+
const hasData = data.value !== undefined;
171+
return (
172+
<p>
173+
{data.running.value
174+
? "running"
175+
: hasData
176+
? data.value?.foo
177+
: "error"}
178+
</p>
179+
);
180+
};
181+
const url = signal("/api/foo?id=1");
182+
act(() => {
183+
render(<AsyncComponent url={url} />, scratch);
184+
});
185+
expect(scratch.innerHTML).to.eq("<p>running</p>");
186+
187+
await act(async () => {
188+
await resolve({ foo: "bar" });
189+
await new Promise(resolve => setTimeout(resolve));
190+
});
191+
192+
expect(scratch.innerHTML).to.eq("<p>bar</p>");
193+
194+
act(() => {
195+
url.value = "/api/foo?id=2";
196+
});
197+
expect(scratch.innerHTML).to.eq("<p>running</p>");
198+
199+
await act(async () => {
200+
await resolve({ foo: "baz" });
201+
await new Promise(resolve => setTimeout(resolve));
202+
});
203+
expect(scratch.innerHTML).to.eq("<p>baz</p>");
204+
});
163205
});
164206
});

0 commit comments

Comments
 (0)