Skip to content

Instantly share code, notes, and snippets.

@tomastrajan
Last active March 26, 2025 14:16
Show Gist options
  • Save tomastrajan/6433fbf973adfed0c40ad49ecac6a543 to your computer and use it in GitHub Desktop.
Save tomastrajan/6433fbf973adfed0c40ad49ecac6a543 to your computer and use it in GitHub Desktop.
Angular Crud Resource POC
import { HttpClient } from '@angular/common/http';
import { inject, DestroyRef, signal, computed } from '@angular/core';
import { takeUntilDestroyed, rxResource } from '@angular/core/rxjs-interop';
import {
Subject,
concatMap,
tap,
Observable,
mergeMap,
switchMap,
exhaustMap,
catchError,
map,
} from 'rxjs';
export function crudResource<T, ID>(
url: string,
options?: CrudResourceOptions<T, ID>,
) {
const http = inject(HttpClient);
const strategy = options?.strategy ?? 'pessimistic';
const loadingCreate = signal(false);
const loadingUpdate = signal(false);
const loadingRemove = signal(false);
const errorCreate = signal('');
const errorUpdate = signal('');
const errorRemove = signal('');
const resource = rxResource({
request: () => '' as ID,
loader: () => {
return http.get<T[]>(url);
},
});
const create = streamify<[T]>((stream) =>
stream.pipe(
tap(([item]) => {
loadingCreate.set(true);
if (
(options?.create?.strategy ?? strategy) === crudResource.OPTIMISTIC
) {
resource.update((prev) => [...(prev ?? []), item]);
}
}),
behaviorToOperator(options?.create?.behavior)(([item]) =>
http.post(url, item).pipe(
catchError((err) => {
errorCreate.set(err);
if (
(options?.create?.strategy ?? strategy) ===
crudResource.OPTIMISTIC
) {
resource.update((prev) =>
prev?.filter((prevItem) => prevItem !== item),
);
}
return [undefined];
}),
),
),
tap(() => {
if (
(options?.create?.strategy ?? strategy) !== crudResource.OPTIMISTIC
) {
resource.reload();
}
loadingCreate.set(false);
}),
),
);
const update = streamify<[ID, T]>((stream) =>
stream.pipe(
tap(([id, item]) => {
loadingUpdate.set(true);
if (
(options?.update?.strategy ?? strategy) === crudResource.OPTIMISTIC
) {
resource.update((prev) =>
prev?.map((prevItem) => {
const prevItemId =
options?.idSelector?.(prevItem) ??
(prevItem as unknown as { id: string }).id;
return prevItemId === id ? { ...prevItem, ...item } : prevItem;
}),
);
}
}),
behaviorToOperator(options?.update?.behavior)(([id, item]) =>
http.put(`${url}/${id}`, item).pipe(
catchError((err) => {
errorUpdate.set(err);
if (
(options?.update?.strategy ?? strategy) ===
crudResource.OPTIMISTIC
) {
resource.update((prev) =>
prev?.map((prevItem) => {
const prevItemId =
options?.idSelector?.(prevItem) ??
(prevItem as unknown as { id: string }).id;
return prevItemId === id ? item : prevItem;
}),
);
}
return [undefined];
}),
),
),
tap(() => {
if (
(options?.update?.strategy ?? strategy) !== crudResource.OPTIMISTIC
) {
resource.reload();
}
loadingUpdate.set(false);
}),
),
);
const remove = streamify<[ID]>((stream) =>
stream.pipe(
tap(() => loadingRemove.set(true)),
map(([id]) => {
const removedItem = resource.value()?.find((item) => {
if (options?.idSelector) {
return options.idSelector(item) === id;
} else {
return (item as unknown as { id: ID }).id === id;
}
});
if (
(options?.remove?.strategy ?? strategy) === crudResource.OPTIMISTIC &&
removedItem
) {
resource.update((prev) =>
prev?.filter((prevItem) => prevItem !== removedItem),
);
}
return { id, removedItem };
}),
behaviorToOperator(options?.remove?.behavior)(({ id, removedItem }) =>
http.delete(`${url}/${id}`).pipe(
catchError((err) => {
errorUpdate.set(err);
if ((options?.remove?.strategy ?? strategy) && removedItem) {
resource.update((prev) => [...(prev ?? []), removedItem]);
}
return [undefined];
}),
),
),
tap(() => {
if (
(options?.remove?.strategy ?? strategy) !== crudResource.OPTIMISTIC
) {
resource.reload();
}
loadingRemove.set(false);
}),
),
);
const loading = computed(
() =>
!loadingInitial() &&
(resource.isLoading() ||
loadingCreate() ||
loadingUpdate() ||
loadingRemove()),
);
const loadingInitial = computed(
() => !resource.value() && resource.isLoading(),
);
return {
loadingInitial,
loading,
loadingCreate,
loadingUpdate,
loadingRemove,
errorCreate,
errorUpdate,
errorRemove,
value: resource.value,
create,
update,
remove,
};
}
function behaviorToOperator(
behavior: crudResource.Behavior = crudResource.CONCAT,
) {
switch (behavior) {
case crudResource.CONCAT:
return concatMap;
case crudResource.MERGE:
return mergeMap;
case crudResource.SWITCH:
return switchMap;
case crudResource.EXHAUST:
return exhaustMap;
}
}
function streamify<T extends unknown[]>(
impl: (stream: Observable<T>) => Observable<unknown>,
) {
const destroyRef = inject(DestroyRef);
const subject = new Subject<T>();
impl(subject).pipe(takeUntilDestroyed(destroyRef)).subscribe();
return (...args: T) => subject.next(args);
}
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace crudResource {
export const CONCAT = 1;
export const MERGE = 2;
export const SWITCH = 3;
export const EXHAUST = 4;
export type Behavior =
| typeof CONCAT
| typeof MERGE
| typeof SWITCH
| typeof EXHAUST;
export const OPTIMISTIC = 'optimistic';
export const PESSIMISTIC = 'pessimistic';
export type Strategy = typeof OPTIMISTIC | typeof PESSIMISTIC;
}
export interface CrudResourceOptions<T, ID> {
idSelector?: (item: T) => ID;
strategy?: crudResource.Strategy;
create?: {
behavior: crudResource.Behavior;
strategy?: crudResource.Strategy;
};
update?: {
behavior: crudResource.Behavior;
strategy?: crudResource.Strategy;
};
remove?: {
behavior: crudResource.Behavior;
strategy?: crudResource.Strategy;
};
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment