DEV Community

Amos Isaila
Amos Isaila

Posted on • Originally published at codigotipado.com on

The Strategy Pattern in Angular: A Comprehensive Guide

Strategy Pattern stands out as a powerful tool for managing algorithms, behaviors, or processes that need to vary independently from clients that use them.

Strategy Pattern in Angular

You can read this post or you can watch it live here:

What is the Strategy Pattern?

The Strategy Pattern is a behavioral design pattern that enables selecting an algorithm at runtime. Instead of implementing a single algorithm directly, code receives run-time instructions as to which in a family of algorithms to use.

In simpler terms, the Strategy Pattern allows you to define a family of algorithms, encapsulate each one, and make them interchangeable. It lets the algorithm vary independently from clients that use it.

Why Do We Need the Strategy Pattern?

  1. Flexibility : it allows you to swap algorithms used inside an object at runtime.
  2. Isolation : it isolates the implementation details of an algorithm from the code that uses it.
  3. Interchangeability : strategies can be interchanged without the client code knowing about it.
  4. Open/Closed Principle : you can introduce new strategies without having to change the context.
  5. Eliminates Conditional Statements : it provides an alternative to using multiple conditional statements in your code.

Implementing the Strategy Pattern in Angular

The code im going to use here it can be found here. And it’s based on this videos:

Let’s walk through a practical example of implementing the Strategy Pattern in an Angular application. We’ll create a search functionality that can use different search algorithms based on the context.

The term context refers to the class that uses a strategy. It's essentially the client that needs to perform some operation, but wants to be flexible about how that operation is carried out. The context is where we "plug in" different strategies.

Think of the context as a socket, and the strategies as different plugs that can fit into that socket. The socket (context) doesn’t care which plug (strategy) is inserted, as long as it fits and provides the expected functionality.

Step 1: Define the Strategy Interface

First, we need to define an interface that all concrete strategies will implement:

export interface SearchStrategy {
  filter(searchTerm: string): void;
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Implement Concrete Strategies

Now, let’s implement some concrete strategies:

// This is an example
@Injectable()
export class PhotoSearchStrategy implements SearchStrategy {
  private readonly _appStore = inject(AppStore);
  filter(searchTerm: string): void {
    if (!searchTerm) {
      this._appStore.setPhotosTotals(this._appStore.$photos().length);
      this._appStore.setItemsBeingFiltered(0);
      this._appStore.setFilteredPhotos([...this._appStore.$photos()]);
      return;
    }
    this._appStore.setFilteredPhotos([...this._appStore.$photos().filter(
      (p: Photo) => p.id.includes(searchTerm)
    )]);
    this._appStore.setItemsBeingFiltered(this._appStore.$filteredPhotos().length);
    this._appStore.setPhotosTotals(this._appStore.$photos().length);
  }
}

// You can implement others
@Injectable()
export class FavouriteSearchStrategy implements SearchStrategy {
  // other logic
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Create a Context

The context is the class that will use the strategy:

@Injectable()
export class SearchService {
  private _strategy!: SearchStrategy;
  setStrategy(strategy: SearchStrategy): void {
    this._strategy = strategy;
  }
  filter(searchTerm: string): void {
    if (!this._strategy) {
      return;
    }
    this._strategy.filter(searchTerm);
  }
}
Enter fullscreen mode Exit fullscreen mode

Every time we will access a route, we will change the strategy, this is awesome!

Step 4: Define resolvers

When we change the route, we must change our strategy with resolvers (you could use other technique):

export const photoStrategyResolver: ResolveFn<SearchStrategy | null> = () => {
  const appStore = inject(AppStore);
  appStore.setStrategy(new PhotoSearchStrategy());
  return appStore.$strategy();
};

export const favouriteStrategyResolver: ResolveFn<SearchStrategy | null> = () => {
  const appStore = inject(AppStore);
  appStore.setStrategy(new FavouriteSearchStrategy());
  return appStore.$strategy();
};
// routes.ts
{
  path: 'photos',
  loadComponent: () => import('../modules/components/photos/photos.component').then(
      (c) => c.PhotosComponent),
  resolve: { strategy: photoStrategyResolver }
},
{
  // others...
}
Enter fullscreen mode Exit fullscreen mode

Step 5: Change Strategy depending on Component

Now with the resolver we can get in our input the strategy and change it in our store.

@Component({
  selector: 'app-photos',
  templateUrl: './photos.component.html',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PhotosComponent implements OnInit {
  readonly appStore = inject(AppStore);
  // Strategy from resolver
  strategy = input.required<SearchStrategy>();
  ngOnInit(): void {
    // Change strategy
    this.appStore.setStrategy(this.strategy());
  }
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Step 6: Filter by the correct Strategy on SearchComponent

When you start typing on the search the filter strategy must follow the correct one set by the actual active component:

@Component({
  selector: 'app-search',
  templateUrl: './search.component.html',
  standalone: true,
  providers: [SearchService],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class SearchComponent {
  private readonly _searchService = inject(SearchService);
  private readonly _appStore = inject(AppStore);
  private readonly _destroy = inject(DestroyRef);
  search = signal('');
  searchTerm: Observable<string>;
  strategy = computed(() => this._appStore.$strategy());
  constructor() {
    this._appStore.$clearSearch.pipe(takeUntilDestroyed(this._destroy))
    .subscribe(() => this.search.set(''));
    // IMPORTANT!:
    // This component will remain always active, and we just pick the correct
    // strategy each time it changes
    this.searchTerm = toObservable(this.search).pipe(

      tap(() => this._searchService.setStrategy(
        this.strategy() as SearchStrategy)
      ),
      debounceTime(500),
      distinctUntilChanged(),
      tap((term: string) => this._searchService.filter(term))
    );
    this.searchTerm.subscribe();
  }
}
Enter fullscreen mode Exit fullscreen mode

Advanced Concepts

Using Abstract Classes

Instead of an interface, we could use an abstract class for our strategy:

export abstract class SearchStrategy {
  abstract filter(searchTerm: string): void;
  protected normalizeString(str: string): string {
    return str.toLowerCase().trim();
  }
}
Enter fullscreen mode Exit fullscreen mode

This allows us to include some shared functionality (like normalizeString) that all strategies can use.

I prefer using interfaces and avoid populating the constructor with unnecessary dependencies.

Dependency Injection with InjectionToken

We can use Angular’s dependency injection system to provide strategies:

import { InjectionToken } from '@angular/core';

export const SEARCH_STRATEGY = new InjectionToken<SearchStrategy<any>>('SearchStrategy');
@Injectable({
  providedIn: 'root',
  useFactory: () => new PhotosSearchStrategy()
})
export class DefaultSearchStrategy extends SimpleSearchStrategy<any> {}
@Component({
  // ...
  providers: [
    { provide: SEARCH_STRATEGY, useClass: DefaultSearchStrategy }
  ]
})
export class SearchComponent {
  constructor(
    @Inject(SEARCH_STRATEGY) private strategy: SearchStrategy<any>
  ) {}
}
Enter fullscreen mode Exit fullscreen mode

This approach allows us to easily switch strategies at the component level.

How to find Strategy Pattern inside Source code of Angular

ChangeDetectionStrategy

The Strategy pattern is implemented here with:

  1. An enum (ChangeDetectionStrategy) that defines the available strategies.
  2. Usage in component metadata to select the strategy.
  3. Integration with Angular’s change detection mechanism.

Key Components

1. ChangeDetectionStrategy Enum

export enum ChangeDetectionStrategy {
  OnPush = 0,
  Default = 1,
}
Enter fullscreen mode Exit fullscreen mode

This enum defines two strategies:

  • OnPush: Change detection is deactivated until explicitly invoked or an input reference changes.
  • Default: Change detection runs automatically after every event.

2. Usage in Component Decorator

@Component({
  // ...
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MyComponent { }
Enter fullscreen mode Exit fullscreen mode

Usage Example

@Component({
  selector: 'app-performance-critical',
  template: `<div>{{ expensiveComputation() }}</div>`,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class PerformanceCriticalComponent {
  @Input() data: any;

  expensiveComputation() {
    // This will only be called when `data` reference changes
    return heavyProcessing(this.data);
  }
}
Enter fullscreen mode Exit fullscreen mode

In this example, the expensive computation will only be re-run when the data input reference changes, not on every change detection cycle.

Async Pipe

The async pipe, it's used to handle different types of asynchronous data sources (Observables, Promises, etc.) uniformly.

Key Components

  1. SubscriptionStrategy Interface : this defines the contract for different strategies.
  2. Concrete Strategies : implementations for different types (Observable, Promise).
  3. AsyncPipe Class : the context that uses these strategies.

Implementation Details

1. Transform Method

transform<T>(obj: Observable<T> | Subscribable<T> | Promise<T> | null | undefined): T | null { ... }
Enter fullscreen mode Exit fullscreen mode

This is the main entry point. It handles:

  • Initial subscription
  • Resubscription if the source changes
  • Returning the latest value

2. Strategy Selection

private _selectStrategy(
    obj: Subscribable<any> | Promise<any> | EventEmitter<any>,
  ): SubscriptionStrategy {
    if (ɵisPromise(obj)) {
      return _promiseStrategy;
    }

    if (ɵisSubscribable(obj)) {
      return _subscribableStrategy;
    }

    throw invalidPipeArgumentError(AsyncPipe, obj);
}
Enter fullscreen mode Exit fullscreen mode

This method chooses the appropriate strategy based on the type of the input object.

3. Subscription

private _subscribe(obj: Subscribable<any> | Promise<any> | EventEmitter<any>): void {
  this._obj = obj;
  this._strategy = this._selectStrategy(obj);
  this._subscription = this._strategy.createSubscription(obj, (value: Object) =>
    this._updateLatestValue(obj, value),
  );
}
Enter fullscreen mode Exit fullscreen mode

This method:

  1. Selects the appropriate strategy
  2. Creates a subscription using the selected strategy
  3. Sets up value updating

4. Disposal

private _dispose(): void {
  this._strategy!.dispose(this._subscription!);
  // Reset state
}
Enter fullscreen mode Exit fullscreen mode

This method cleans up the subscription when needed, using the strategy’s dispose method.

TitleStrategy

Angular uses the Strategy pattern to manage page titles during navigation. This implementation allows for flexible and customizable title setting strategies. Let’s break down how this works:

  1. An abstract base class (TitleStrategy) that defines the interface.
  2. A default implementation (DefaultTitleStrategy, implied in the code).
  3. A custom implementation (ADevTitleStrategy).

Key Components

1. Abstract TitleStrategy Class

@Injectable({providedIn: 'root', useFactory: () => inject(DefaultTitleStrategy)})
export abstract class TitleStrategy {
  abstract updateTitle(snapshot: RouterStateSnapshot): void;

  buildTitle(snapshot: RouterStateSnapshot): string | undefined {
    // ... implementation ...
  }

  getResolvedTitleForRoute(snapshot: ActivatedRouteSnapshot) {
    return snapshot.data[RouteTitleKey];
  }
}
Enter fullscreen mode Exit fullscreen mode

This abstract class:

  • Defines the updateTitle method that concrete strategies must implement.
  • Provides a default buildTitle method that traverses the route tree to find the deepest primary route with a title.
  • Includes a helper method getResolvedTitleForRoute to extract the title from route data.

2. Custom ADevTitleStrategy Implementation

@Injectable({providedIn: 'root'})
export class ADevTitleStrategy extends TitleStrategy {
  constructor(private readonly title: Title) {
    super();
  }

  override updateTitle(routerState: RouterStateSnapshot) {
    const title = this.buildTitle(routerState);
    if (title !== undefined) {
      this.title.setTitle(title);
    }
  }

  override buildTitle(snapshot: RouterStateSnapshot): string {
    // ... custom implementation ...
  }
}
Enter fullscreen mode Exit fullscreen mode

This custom strategy:

  • Overrides updateTitle to set the document title using the Title service.
  • Provides a custom buildTitle implementation that includes additional logic for prefixes and suffixes.

How It Works

  1. Dependency Injection : angular’s DI system is used to provide the appropriate strategy. The default is set in the @Injectable decorator of the base class.
  2. Router Integration : the router uses the injected TitleStrategy to update the title after navigation.
  3. Customization : developers can create custom strategies (like ADevTitleStrategy) and provide them to override the default behavior.

Usage Example

To use a custom strategy:

@NgModule({
  // ...
  providers: [
    { provide: TitleStrategy, useClass: ADevTitleStrategy }
  ]
})
export class AppModule { }
Enter fullscreen mode Exit fullscreen mode

This will use the ADevTitleStrategy for the entire application, overriding the default.

RouteReuseStrategy

Angular employs the Strategy pattern for managing route reuse through the RouteReuseStrategy class. This implementation allows for flexible and customizable strategies for determining when and how routes should be reused. Let's examine how this works:

  • An abstract base class (RouteReuseStrategy) that defines the interface.
  • A base implementation (BaseRouteReuseStrategy) that provides default behavior.
  • A default concrete implementation (DefaultRouteReuseStrategy).
  • A custom implementation (ReuseTutorialsRouteStrategy).

Key Components

1. Abstract RouteReuseStrategy Class

@Injectable({providedIn: 'root', useFactory: () => inject(DefaultRouteReuseStrategy)})
export abstract class RouteReuseStrategy {
  abstract shouldDetach(route: ActivatedRouteSnapshot): boolean;
  abstract store(route: ActivatedRouteSnapshot, handle: DetachedRouteHandle | null): void;
  abstract shouldAttach(route: ActivatedRouteSnapshot): boolean;
  abstract retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle | null;
  abstract shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean;
}
Enter fullscreen mode Exit fullscreen mode

This abstract class defines the interface that all route reuse strategies must implement.

2. BaseRouteReuseStrategy Class

export abstract class BaseRouteReuseStrategy implements RouteReuseStrategy {
  shouldDetach(route: ActivatedRouteSnapshot): boolean {
    return false;
  }

  store(route: ActivatedRouteSnapshot, detachedTree: DetachedRouteHandle): void {}

  shouldAttach(route: ActivatedRouteSnapshot): boolean {
    return false;
  }

  retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle | null {
    return null;
  }

  shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean {
    return future.routeConfig === curr.routeConfig;
  }
}
Enter fullscreen mode Exit fullscreen mode

This base class provides a default implementation that only reuses routes when the matched router configs are identical.

3. DefaultRouteReuseStrategy Class

@Injectable({providedIn: 'root'})
export class DefaultRouteReuseStrategy extends BaseRouteReuseStrategy {}
Enter fullscreen mode Exit fullscreen mode

This is the default strategy used by Angular, which simply extends BaseRouteReuseStrategy.

4. Custom ReuseTutorialsRouteStrategy Implementation

export class ReuseTutorialsRouteStrategy extends BaseRouteReuseStrategy {
  override shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean {
    return (
      future.routeConfig === curr.routeConfig ||
      (this.isTutorialPage(this.getPathFromActivatedRouteSnapshot(future)) &&
        this.isTutorialPage(this.getPathFromActivatedRouteSnapshot(curr)))
    );
  }

  // ... helper methods ...
}
Enter fullscreen mode Exit fullscreen mode

This custom strategy extends the base strategy to reuse routes when navigating between tutorial pages.

PreloadingStrategy

The Strategy pattern is implemented here with:

  1. An abstract base class (PreloadingStrategy) that defines the interface.
  2. Concrete implementations:
  • PreloadAllModules: preloads all modules.
  • NoPreloading: doesn't preload any modules.
  • SelectivePreloadingStrategyService: a custom strategy for selective preloading.

Key Components

1. Abstract PreloadingStrategy Class

export abstract class PreloadingStrategy {
  abstract preload(route: Route, fn: () => Observable<any>): Observable<any>;
}
Enter fullscreen mode Exit fullscreen mode

This abstract class defines the interface that all preloading strategies must implement.

2. PreloadAllModules Strategy

@Injectable({providedIn: 'root'})
export class PreloadAllModules implements PreloadingStrategy {
  preload(route: Route, fn: () => Observable<any>): Observable<any> {
    return fn().pipe(catchError(() => of(null)));
  }
}
Enter fullscreen mode Exit fullscreen mode

This strategy preloads all modules as quickly as possible.

3. NoPreloading Strategy

@Injectable({providedIn: 'root'})
export class NoPreloading implements PreloadingStrategy {
  preload(route: Route, fn: () => Observable<any>): Observable<any> {
    return of(null);
  }
}
Enter fullscreen mode Exit fullscreen mode

This strategy doesn’t preload any modules and is enabled by default.

4. Custom SelectivePreloadingStrategyService

@Injectable({
  providedIn: 'root',
})
export class SelectivePreloadingStrategyService implements PreloadingStrategy {
  preloadedModules: string[] = [];

  preload(route: Route, load: () => Observable<any>): Observable<any> {
    if (route.canMatch === undefined && route.data?.['preload'] && route.path != null) {
      this.preloadedModules.push(route.path);
      console.log('Preloaded: ' + route.path);
      return load();
    } else {
      return of(null);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

This custom strategy selectively preloads routes based on a preload data property.

Usage Example

To use a custom strategy:

@NgModule({
  imports: [RouterModule.forRoot(ROUTES, {preloadingStrategy: SelectivePreloadingStrategyService})],
  // ...
})
export class AppModule { }
Enter fullscreen mode Exit fullscreen mode

Or using the standalone API:

bootstrapApplication(AppComponent, {
  providers: [
    provideRouter(appRoutes, withPreloading(SelectivePreloadingStrategyService))
  ]
});
Enter fullscreen mode Exit fullscreen mode

How It Works

  1. Dependency Injection : Angular’s DI system is used to provide the appropriate strategy. The default is set in the @Injectable decorator of the base class.
  2. Router Integration : The router uses the injected RouteReuseStrategy to determine when to reuse routes during navigation.
  3. Customization : Developers can create custom strategies (like ReuseTutorialsRouteStrategy) and provide them to override the default behavior.

Usage Example

To use a custom strategy:

@NgModule({
  // ...
  providers: [
    { provide: RouteReuseStrategy, useClass: ReuseTutorialsRouteStrategy }
  ]
})
export class AppModule { }
Enter fullscreen mode Exit fullscreen mode

This will use the ReuseTutorialsRouteStrategy for the entire application, overriding the default.

LocationStrategy

Angular employs the Strategy pattern for managing URL representation through the LocationStrategy class. This implementation allows for flexible and customizable strategies for representing application state in the browser's URL.

  • An abstract base class (LocationStrategy) that defines the interface.
  • Concrete implementations:
  • HashLocationStrategy: Represents state in the hash fragment of the URL.
  • PathLocationStrategy: Represents state in the path of the URL.

Key Components

1. Abstract LocationStrategy Class

@Injectable({providedIn: 'root', useFactory: () => inject(PathLocationStrategy)})
export abstract class LocationStrategy {
  abstract path(includeHash?: boolean): string;
  abstract prepareExternalUrl(internal: string): string;
  abstract getState(): unknown;
  abstract pushState(state: any, title: string, url: string, queryParams: string): void;
  abstract replaceState(state: any, title: string, url: string, queryParams: string): void;
  abstract forward(): void;
  abstract back(): void;
  abstract onPopState(fn: LocationChangeListener): void;
  abstract getBaseHref(): string;
  historyGo?(relativePosition: number): void {
    throw new Error(ngDevMode ? 'Not implemented' : '');
  }
}
Enter fullscreen mode Exit fullscreen mode

This abstract class defines the interface that all location strategies must implement.

2. HashLocationStrategy

@Injectable()
export class HashLocationStrategy extends LocationStrategy implements OnDestroy {
  // ... implementation details ...

  override path(includeHash: boolean = false): string {
    const path = this._platformLocation.hash ?? '#';
    return path.length > 0 ? path.substring(1) : path;
  }

  override prepareExternalUrl(internal: string): string {
    const url = joinWithSlash(this._baseHref, internal);
    return url.length > 0 ? '#' + url : url;
  }

  // ... other method implementations ...
}
Enter fullscreen mode Exit fullscreen mode

This strategy represents the application state in the hash fragment of the URL (e.g.,

http://example.com#/foo)..)

3. PathLocationStrategy

@Injectable({providedIn: 'root'})
export class PathLocationStrategy extends LocationStrategy implements OnDestroy {
  // ... implementation details ...

  override path(includeHash: boolean = false): string {
    const pathname =
      this._platformLocation.pathname + normalizeQueryParams(this._platformLocation.search);
    const hash = this._platformLocation.hash;
    return hash && includeHash ? `${pathname}${hash}` : pathname;
  }

  override prepareExternalUrl(internal: string): string {
    return joinWithSlash(this._baseHref, internal);
  }

  // ... other method implementations ...
}
Enter fullscreen mode Exit fullscreen mode

This strategy represents the application state in the path of the URL (e.g., http://example.com/foo)..)

How It Works

Strategy Selection : The location strategy is selected when configuring the application:

providers: [{provide: LocationStrategy, useClass: HashLocationStrategy}]
Enter fullscreen mode Exit fullscreen mode

By default, Angular uses PathLocationStrategy.

URL Manipulation : The selected strategy is used by the Location service to manipulate the browser's URL:

// In Location service
path(includeHash: boolean = false): string {
  return this.platformStrategy.path(includeHash);
}

prepareExternalUrl(url: string): string {
  return this.platformStrategy.prepareExternalUrl(url);
}
Enter fullscreen mode Exit fullscreen mode

History Management : The strategy also handles browser history management:

pushState(state: any, title: string, url: string, queryParams: string) {
  this.platformStrategy.pushState(state, title, url, queryParams);
}
Enter fullscreen mode Exit fullscreen mode

Usage Example

To use the hash location strategy:

@NgModule({
  // ...
  providers: [{provide: LocationStrategy, useClass: HashLocationStrategy}]
})
export class AppModule { }
Enter fullscreen mode Exit fullscreen mode

When to Use the Strategy Pattern

The Strategy Pattern is particularly useful when:

  1. You have multiple algorithms for a specific task and you want to switch between them at runtime.
  2. You have multiple variants of an algorithm.
  3. An algorithm uses data that clients shouldn’t know about.
  4. A class defines many behaviors that appear as multiple conditional statements in its methods.

In Angular applications, common use cases include:

  • Search algorithms (as in our example)
  • Sorting strategies
  • Payment processing methods
  • Form validation strategies
  • Data fetching strategies (e.g., from API, local storage, IndexedDB)

Advantages of Using the Strategy Pattern

  1. Cleaner Code : by encapsulating algorithms, your code becomes more organized and easier to understand.
  2. Flexibility : you can easily add new strategies without changing existing code.
  3. Testability : each strategy can be tested in isolation.
  4. Reusability : strategies can be reused across different contexts.

Potential Drawbacks

  1. Increased Number of Classes : each strategy is a separate class, which can increase the overall number of classes in your application.
  2. Client Must Be Aware of Strategies : the client must understand how strategies differ to choose the appropriate one.

Conclusion

The Strategy Pattern is a powerful tool in the Angular developer’s toolkit. It promotes clean, flexible, and maintainable code by encapsulating algorithms and making them interchangeable. By understanding and applying this pattern, you can create more robust and adaptable Angular applications.

Remember, like all design patterns, the Strategy Pattern is not a silver bullet. Always consider your specific use case and whether the benefits outweigh the potential drawbacks before implementing it in your project.

Thanks for reading so far 🙏

I’d like to have your feedback so please leave a comment , clap or follow. 👏

Spread the Angular love! 💜

If you really liked it, share it among your community, tech bros and whoever you want! 🚀👥

Don’t forget to follow me and stay updated: 📱

Thanks for being part of this Angular journey! 👋😁

Originally published at https://www.codigotipado.com.

Top comments (0)