Skip to content
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
114 changes: 104 additions & 10 deletions docs/src/content/docs/utilities/Stores/create-effect.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,19 +26,87 @@ import { createEffect } from 'ngxtension/create-effect';
## Usage

```ts
@Injectable()
export class Store {
public readonly saveUser = createEffect<User>(_ => _.pipe(
exhaustMap((user) => this.userService.saveUser(user).pipe(
catchError(() => {
console.error('Failed to save user');
return EMPTY;
})
)),
));

public readonly stopTrackingWhileLoading = createEffect<boolean | undefined>(_ => _.pipe(
tap((loading) => {
if (loading) {
this.trackingService.stopTracking();
} else {
this.trackingService.startTracking();
}
}),
));

public readonly createUser = createEffect<Partial<User>>((_, callbacks) => _.pipe(
exhaustMap((user) => this.userService.createUser(user).pipe(
tap((newUser) => callbacks.success(newUser)),
catchError((e) => {
callbacks.error(e);
return EMPTY;
})
)),
));

public readonly getUsersFiltered = createEffect<string>(_ => _.pipe(
switchMap((filter) => this.userService.getUsersFiltered(filter))
));
}


@Component({})
export class Some {
log = createEffect<number>(
pipe(
map((value) => value * 2),
tap(console.log.bind(console, 'double is -->')),
),
);

ngOnInit() {
// start the effect
this.log(interval(1000));
}
private readonly store = inject(Store);

protected save() {
// If the user clicks the button multiple times, the effect
// will ignore all calls while saving, because we use `exhaustMap` in the effect.
this.store.saveUser(this.userForm.value);
// As you can see, you don’t need to worry about subscribing, unsubscribing,
// or even error handling (although it is absolutely possible to handle errors).
}

protected create() {
// Example of callback usage.
// Notice that the effect's implementation should call the callbacks,
// because only the effect can know when to call them.
this.store.createUser(this.userForm.value, () => {
onSuccess: () => this.toast.success('User created!');
onError: () => this.toast.error('Failed to create user.');
});
}

protected readonly isLoading = signal(false);

constructor() {
// You can just pass a `Signal`, an `Observable`, or a `Promise`
// to the effect - it will subscribe and unsubscribe automatically.
this.store.stopTrackingWhileLoading(this.isLoading);
// Note that we don’t read the signal value here - we pass the signal itself.
}

private readonly filter$ = new BehaviorSubject<string>('');

private readonly usersFiltered$ = this.filter$.pipe(
distintinctUntilChanged(),
debounceTime(300),
// Sometimes you might need to use the effect as an observable to compose
// it with other observables. And you can still pass arguments.
switchMap((filter) => this.store.getUsersFiltered.asObservable(filter)),
);

protected readonly filteredUsers = toSignal(this.usersFiltered$);

}
```

Expand Down Expand Up @@ -93,3 +161,29 @@ export class Example {
);
}
```

Note that when an observable passed to the effect throws, the effect will only re-subscribe to the handler, not to the passed observable. Re-subscribing to an observable that is in an error state might cause endless loops and unexpected behavior.
This means the effect will remain usable, but it will not endlessly re-subscribe until the passed observable is out of the error state - you will have to call the effect again.

```ts
@Injectable({providedIn: 'root'})
export class Store {

public readonly loadProducts = createEffect<string>(pipe(
// This is the effect’s handler.
// If `getProducts()` throws, the effect will continue working:
// the next call to `loadProducts()` will still be handled.
// Without re-subscribing, the effect would unsubscribe from the handler.
switchMap((id) => this.api.getProducts(id))
));

constructor() {
const id$ = new BehaviorSubject<string>('123');
this.loadProducts(id$);

id$.error('error');
// Now the effect will stop watching the `$id` - but
// the effect is still usable, you just need to call it again.
}
}
```
188 changes: 141 additions & 47 deletions libs/ngxtension/create-effect/src/create-effect.spec.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,39 @@
import { Component, signal } from '@angular/core';
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
import { EMPTY, Subject, interval, of, switchMap, tap, throwError } from 'rxjs';
import 'zone.js/testing';
import { Component, input, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { EMPTY, Subject, interval, of, switchMap, tap, throwError, isObservable } from 'rxjs';
import { createEffect } from './create-effect';

describe(createEffect.name, () => {
let effect: ReturnType<typeof createEffect<string>>;
let lastResult: string | undefined;
let lastError: string | undefined;

beforeEach(() => {
lastResult = undefined;

TestBed.runInInjectionContext(() => {
effect = createEffect<string>((_) =>
_.pipe(
tap((r) => (lastResult = r)),
switchMap((v) => {
if (v === 'error') {
lastError = v;
return throwError(() => 'err');
}
lastError = undefined;
return of(v);
}),
),
);
});
});
let effect: ReturnType<typeof createEffect<string>>;
let lastResult: string | undefined;
let lastError: string | undefined;
let handlerCalls = 0;


beforeEach(() => {
lastResult = undefined;
handlerCalls = 0;

TestBed.runInInjectionContext(() => {
effect = createEffect<string>((_, callbacks) => _.pipe(
tap((r) => {
lastResult = r;
handlerCalls++;
}),
switchMap((v) => {
if (v.startsWith('error')) {
lastError = v;
callbacks.error('error:' + v);
return throwError(() => 'err');
}
lastError = undefined;
callbacks.success('success:' + v);
return of(v);
}),
));
});
})

@Component({
standalone: true,
Expand All @@ -41,22 +48,23 @@ describe(createEffect.name, () => {
}
}

it('should run until component is destroyed', fakeAsync(() => {
const fixture = TestBed.createComponent(Foo);
const component = fixture.componentInstance;
fixture.detectChanges();
expect(component.count).toEqual(0);

tick(1000);
expect(component.count).toEqual(1);

tick(1000);
expect(component.count).toEqual(2);

fixture.destroy();
tick(1000);
expect(component.count).toEqual(2);
}));
// TODO: uncomment when https://github.com/ngxtension/ngxtension-platform/issues/599 is resolved.
// it('should run until component is destroyed', fakeAsync(() => {
// const fixture = TestBed.createComponent(Foo);
// const component = fixture.componentInstance;
// fixture.detectChanges();
// expect(component.count).toEqual(0);
//
// tick(1000);
// expect(component.count).toEqual(1);
//
// tick(1000);
// expect(component.count).toEqual(2);
//
// fixture.destroy();
// tick(1000);
// expect(component.count).toEqual(2);
// }));

it('should keep working when generator throws an error', () => {
expect(lastError).toEqual(undefined);
Expand Down Expand Up @@ -89,11 +97,17 @@ describe(createEffect.name, () => {
s.next('error');
expect(lastResult).toEqual('a');
s.next('b');
expect(lastResult).toEqual('b');

effect('next');
expect(lastResult).toEqual('next');
expect(lastError).toEqual(undefined);
// s has error and will not accept emissions anymore.
// {retryOnError} in effect's config should only affect
// the effect's event loop, not the observable that is
// passed as a value - resubscribing to that observable
// might cause unexpected behavior.
expect(lastResult).toEqual('a');

// but the effect's event loop should still work
effect('next');
expect(lastResult).toEqual('next');
expect(lastError).toEqual(undefined);
});

it('should keep working when value$ emits EMPTY', () => {
Expand Down Expand Up @@ -151,4 +165,84 @@ describe(createEffect.name, () => {
expect(lastResult).toEqual('c');
expect(lastError).toEqual(undefined);
});

it('should return an observable when getEffectFor() is called', () => {
const e = effect.asObservable('test');
expect(isObservable(e)).toEqual(true);
e.subscribe();
expect(lastResult).toEqual('test');
});

it('should run callbacks', () => {
let r = '';
let f = '';
effect('s', {
onSuccess: (v) => r = v as string,
onFinalize: () => f = 'finalized:success',
});
expect(r).toEqual('success:s');
expect(f).toEqual('finalized:success');

f = '';
effect('error1', {
onError: (v) => r = v as string,
onFinalize: () => f = 'finalized:error',
});
expect(r).toEqual('error:error1');
expect(f).toEqual('finalized:error');
});

it('should emit the initial value when a signal is passed', () => {
expect(handlerCalls).toEqual(0);
effect(signal('test'));
expect(lastResult).toEqual('test');
expect(handlerCalls).toEqual(1);
TestBed.flushEffects();
expect(handlerCalls).toEqual(1);
});

it('should emit the new value of a signal if it is different from the initial value', () => {
expect(handlerCalls).toEqual(0);
const s = signal('test');
effect(s);
expect(lastResult).toEqual('test');
expect(handlerCalls).toEqual(1);
s.set('test2');
TestBed.flushEffects();
expect(lastResult).toEqual('test2');
expect(handlerCalls).toEqual(2);
});

it('should skip the new value of a signal if it is equal to the initial value', () => {
expect(handlerCalls).toEqual(0);
const s = signal('test');
effect(s);
expect(lastResult).toEqual('test');
expect(handlerCalls).toEqual(1);
s.set('test');
TestBed.flushEffects();
expect(lastResult).toEqual('test');
expect(handlerCalls).toEqual(1);
});

it('should NOT emit the initial value when a required input without initial value is passed', () => {
expect(handlerCalls).toEqual(0);
TestBed.runInInjectionContext(() => {
effect(input.required());
expect(lastResult).not.toEqual('test');
expect(handlerCalls).toEqual(0);
TestBed.flushEffects();
expect(handlerCalls).toEqual(0);
});
});

it('should accept Promise as value', async () => {
expect(handlerCalls).toEqual(0);
effect(Promise.resolve('test'));
expect(lastResult).toEqual(undefined);
expect(handlerCalls).toEqual(0);
await Promise.resolve();
expect(lastResult).toEqual('test');
expect(handlerCalls).toEqual(1);
});
});
Loading
Loading