Skip to content

Instantly share code, notes, and snippets.

@rodydavis
Last active March 11, 2025 07:10
Show Gist options
  • Save rodydavis/3b5266da2cc07f6574d425f5ce6e1e31 to your computer and use it in GitHub Desktop.
Save rodydavis/3b5266da2cc07f6574d425f5ce6e1e31 to your computer and use it in GitHub Desktop.
import { ReadonlySignal, computed, effect, signal } from "@preact/signals-core";
export class AsyncState<T> {
constructor() {}
get value(): T | null {
return null;
}
get requireValue(): T {
throw new Error("Value not set");
}
get error(): any {
return null;
}
get isLoading(): boolean {
return false;
}
get hasValue(): boolean {
return false;
}
get hasError(): boolean {
return false;
}
map<R>(builders: {
onLoading: () => R;
onError: (error: any) => R;
onData: (data: T) => R;
}): R {
if (this.hasError) {
return builders.onError(this.error);
}
if (this.hasValue) {
return builders.onData(this.requireValue);
}
return builders.onLoading();
}
}
export class AsyncData<T> extends AsyncState<T> {
private _value: T;
constructor(value: T) {
super();
this._value = value;
}
get requireValue(): T {
return this._value;
}
get hasValue(): boolean {
return true;
}
toString() {
return `AsyncData{${this._value}}`;
}
}
export class AsyncLoading<T> extends AsyncState<T> {
get value(): T | null {
return null;
}
get isLoading(): boolean {
return true;
}
toString() {
return `AsyncLoading{}`;
}
}
export class AsyncError<T> extends AsyncState<T> {
private _error: any;
constructor(error: any) {
super();
this._error = error;
}
get error(): any {
return this._error;
}
get hasError(): boolean {
return true;
}
toString() {
return `AsyncError{${this._error}}`;
}
}
export function asyncSignal<T>(
cb: () => Promise<T>
): ReadonlySignal<AsyncState<T>> {
const loading = new AsyncLoading<T>();
const reset = Symbol("reset");
const s = signal<AsyncState<T>>(loading);
const c = computed<Promise<T>>(cb);
let controller: AbortController | null;
let abortSignal: AbortSignal | null;
function execute(cb: Promise<T>, cancel: AbortSignal) {
(async () => {
s.value = loading;
try {
const result = await new Promise<T>(async (resolve, reject) => {
if (cancel.aborted) {
reject(cancel.reason);
}
cancel.addEventListener("abort", () => {
reject(cancel.reason);
});
try {
const result = await cb;
if (cancel.aborted) {
reject(cancel.reason);
return;
}
resolve(result);
} catch (error) {
reject(error);
}
});
s.value = new AsyncData<T>(result);
} catch (error) {
if (error === reset) {
s.value = loading;
} else {
s.value = new AsyncError<T>(error);
}
}
})();
}
effect(() => {
if (controller != null) {
controller.abort(reset);
}
controller = new AbortController();
abortSignal = controller.signal;
execute(c.value, abortSignal);
});
return s;
}
@rodydavis
Copy link
Author

Usage:

const s = signal(0);
const a = asyncSignal(async () => {
      const count = s.value;
      await new Promise((resolve) => setTimeout(resolve, 1000));
      return count;
});
effect(() => {
  console.log(a.value);
});
s.value++;

@rodydavis
Copy link
Author

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment