Marmicode
Blog Post
Younes Jaaidi

Your Angular Services Might Need Some Privacy

by Younes Jaaidi ā€¢ 
Jun 8, 2022 ā€¢ 9 minutes
Your Angular Services Might Need Some Privacy

@Component({
  providers: [MyStore]
})
class MyCmp {
  constructor(private _store: MyStore) {}
}

@Component({
  providers: [MyStore]
})
class MyCmp {
  constructor(@Self() private _store: MyStore) {}
}

// store.ts

export function provideStore() {
  return [MyStore];
}

export function injectStore() {
  return inject(MyStore, InjectFlags.Self);
}

// my.component.ts

@Component({
  providers: [provideStore()]
})
class MyCmp {
  store = injectStore();
}

export interface MyStore {
  ...
}

// implementation is not exported
class MyStoreImpl implements MyStore {
  ...
}

export function provideStore() {
  return [MyStoreImpl];
}

export function injectStore(): MyStore {
  return inject(MyStoreImpl, InjectFlags.Self);
}

export function injectStore() {
  const store = inject(MyStoreImpl, InjectFlags.Self | InjectFlags.Optional);
  if (store == null) {
    throw new Error(`ā˜¹ļø Oups! It seems that you forgot to provide a private store.
    Try adding "provideStore()" to your declarable's providers.
    Cf. LINK_TO_DOCS_HERE`);
  }
  return store;
}

// store.ts

export function describeStore<STATE extends Record<string, unknown>>(
  initialState: Partial<STATE>
) {
  @Injectable()
  class Store extends RxState<STATE> {
    constructor() {
      super();

      /* Initialize state. */
      this.set(initialState);
    }
  }

  return {
    provideStore() {
      return [Store];
    },
    injectStore() {
      // @todo handle the error as described above.
      return inject(Store, InjectFlags.Self);
    },
  };
}

// my.component.ts

const { provideStore, injectStore } = describeStore({counter: 0});

@Component({
  providers: [provideStore()]
})
class MyCmp {
  store = injectStore();

  reset() {
    this.store.set({counter: 0});
  }
}

export function describeReactiveStore<STATE extends Record<string, unknown>>(
  initialState: Partial<STATE>
) {
  @Injectable()
  class ReactiveStore extends RxState<STATE> {
    constructor(cdr: ChangeDetectorRef) {
      super();

      /* Initialize state. */
      this.set(initialState);

      /* Schedule change detection on state change. */
      this.hold(this.select(), () => cdr.markForCheck());
    }
  }

  return {
    provideStore() {
      ...
    },
    injectStore() {
      ...
    },
  };
}

const { provide: provideCounterStore, inject: injectCounterStore } =
  describeReactiveStore({
    counter: 0,
  });

@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `<div>
    <div>{{ store.counter }}</div>
    <button (click)="increment()">+</button>
  </div>`,
  providers: [provideCounterStore()]
})
export class CounterReactiveComponent {
  store = injectCounterStore();

  increment() {
    ++this.store.counter;
  }
}

// store.ts

export interface Store {...}         

export function describeStore(...) {
  @Injectable()
  class StoreImpl {
    ...
  }

  return {
    provideStore() {
      ...
    },
    provideTestingStore(
      provider:
        | { useClass: Type<Store> }
        | { useExisting: Type<Store> }
        | { useValue: Store }
        | { useFactory: () => Store }
      ) {
      return [
        {
          ...provider,
          provide: Store,
        }
      ];
    },
    injectStore() {
      ...
    },
  };
}

// my.component.spec.ts

TestBed.overrideProviders(MyComponent, {
  add: [provideTestingStore({useValue: mock})]
});
TestBed.createComponent(MyComponent);

const { provideFetcher, injectFetcher } = describeFetcher<Recipe, {keywords: string}>({
  paginationMode: 'cursor',
  limit: 10
});

@Component({
  providers: [provideFetcher()],
  template: `
    <mc-items [items]="fetcher.items$ | async"></mc-items> 
    <button (click)="fetcher.next()">NEXT</button>
  `
})
class MyCmp {
  fetcher = injectFetcher();

  constructor() {
    fetcher.setOffset(0); // TypeScript error because method is not present if paginationMode is 'cursor'.
  }

  setKeywords(keywords: string) {
    fetcher.setParams({keywords});
  }
}