DEV Community

Cover image for How to Use 🧨 Dynamic Titles from NgRx Selectors
David
David

Posted on

6 2

How to Use 🧨 Dynamic Titles from NgRx Selectors

When Angular 14 released and custom title strategies became a possibility, my mind immediately went to asking, "how can I dynamically set a page's title from a selector?"

I wrote an RFC in the NgRx community to pitch an API for doing this. I'd like to explain how it works, and how you can leverage it today (whether or not it joins an @ngrx/* package).

Example of configuring a dynamic title

All the code for the following example can be found in this StackBlitz demo.

Say I have a website where I can enter the name of an "action" in an input, and then "do" it by clicking a "Do Action" button.

Blank field titled

When I click "Do Action", the title of my page reflects how many seconds have passed since the "action" was done.

Webpage tab above the form says

In my Angular app routes, I have the route for the page configured using a tag function called ngrxTitle that allows me to inline selectors into a string.

const routes: Routes = [
  {
    path: '',
    component: AppComponent,
    title: ngrxTitle`${counterFeature.selectCount} Seconds Since ${counterFeature.selectEvent}`,
  },
];
Enter fullscreen mode Exit fullscreen mode

The selector counterFeature.selectCount selects the number of seconds since the button was clicked, while counterFeature.selectEvent selects the name of the action entered in the input when the button was clicked. Using ngrxTitle, I can templatize the title to include the latest results of multiple selectors like these.

ngrxTitle Implementation

ngrxTitle is a tag function that processes a template literal with selectors.

For every selector, it generates a unique ID and replaces the selector with the string 'NgRxTitleSelector${ID}'.

For example, when I ran my app, the title template literal was generated into the string 'NgRxTitleSelector${f35ace1e-28d8-4dc6-850a-f0900315ca8a} Seconds Since NgRxTitleSelector${40b2582b-832a-44f5-b6ce-f650518db278}'.

Angular 14 allows developers to implement custom "title strategies". A TitleStrategy is a class with an updateTitle method that is called each time the route changes. This gives us the opportunity to change the title any way desired.

That means we can process the title template generated by ngrxTitle and subscribe the selectors referenced by the template to produce a new title.

The NgRxTitleStrategy starts with this basic structure:

export class NgRxTitleStrategy extends TitleStrategy {

  private titleSubscription: Subscription | undefined;

  updateTitle(snapshot: RouterStateSnapshot): void {
    // Each time the route changes, cancel the last subscription
    this.titleSubscription?.unsubscribe();

    // Get the title using the base method
    // When using ngrxTitle, this will be the special template string
    const titleTemplate = this.buildTitle(snapshot);
    // Create an Observable of the title built from the template
    const title$ = this.selectTitleFromTemplate(titleTemplate);
    // Continuously update the title as the selectors emit new values
    this.titleSubscription = title$.subscribe((t) => this.title.setTitle(t));
  }
}
Enter fullscreen mode Exit fullscreen mode

In the app module, we can utilize the new title strategy in the providers.

@NgModule({
  declarations: [AppComponent],
  providers: [{
    provide: TitleStrategy,
    useClass: NgRxTitleStrategy,
  }],
  imports: [
    /* ... */
  ],
  bootstrap: [AppComponent],
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

Full implementation

See the gist below for the full implementation.

// View full implementation and usage on StackBlitz: https://stackblitz.com/edit/angular-ivy-pbyvne?file=src%2Fapp%2Fapp.component.ts,src%2Fapp%2Fapp-routing.module.ts,src%2Fapp%2Fapp.module.ts,src%2Fapp%2Fcounter.ts,src%2Fapp%2Ftitle-strategy.ts
import { Injectable } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { RouterStateSnapshot, TitleStrategy } from '@angular/router';
import { createSelector, MemoizedSelector, Store } from '@ngrx/store';
import { combineLatest, map, Observable, Subscription } from 'rxjs';
type TitleSegmentSelector =
| MemoizedSelector<object, string>
| MemoizedSelector<object, number>;
const _createSegmentTemplate = (
selectorOrStringish: string | number | TitleSegmentSelector
): string => {
let segmentTemplate: string;
if (
typeof selectorOrStringish !== 'string' &&
typeof selectorOrStringish !== 'number'
) {
const uuid = NgRxTitleStrategy.getUuid(selectorOrStringish);
segmentTemplate = `${NgRxTitleStrategy.selectorTemplatePrefix}${uuid}${NgRxTitleStrategy.selectorTemplateSuffix}`;
} else {
segmentTemplate = String(selectorOrStringish);
}
return segmentTemplate;
};
export const ngrxTitle = (
plainSegments: TemplateStringsArray,
...templatedSegments: Array<TitleSegmentSelector | string | number>
): string => {
const segmentTemplates = templatedSegments.map(_createSegmentTemplate);
return plainSegments.reduce(
(template, segment, i) => template + segment + (segmentTemplates[i] ?? ''),
''
);
};
@Injectable({ providedIn: 'root' })
export class NgRxTitleStrategy extends TitleStrategy {
constructor(private readonly store: Store, private readonly title: Title) {
super();
}
static selectorTemplatePrefix = 'NgRxTitleSelector${';
static selectorTemplateSuffix = '}';
private static selectorTemplatePrefixRegExp =
NgRxTitleStrategy.selectorTemplatePrefix.replace('$', '\\$');
private static selectorTemplateRegExp = new RegExp(
`(${NgRxTitleStrategy.selectorTemplatePrefixRegExp}.*?${NgRxTitleStrategy.selectorTemplateSuffix})`
);
private static selectorRefsByUuid = new Map<string, TitleSegmentSelector>();
private static uuidsBySelectorRef = new WeakMap<
TitleSegmentSelector,
string
>();
private titleSubscription: Subscription | undefined;
updateTitle(snapshot: RouterStateSnapshot): void {
this.titleSubscription?.unsubscribe();
const titleTemplate = this.buildTitle(snapshot);
const title$ = this.selectTitleFromTemplate(titleTemplate);
this.titleSubscription = title$.subscribe((t) => this.title.setTitle(t));
}
public static getUuid(selector: TitleSegmentSelector): string {
const cachedUuid = NgRxTitleStrategy.uuidsBySelectorRef.get(selector);
const uuid = cachedUuid ?? self.crypto.randomUUID();
NgRxTitleStrategy.selectorRefsByUuid.set(uuid, selector);
NgRxTitleStrategy.uuidsBySelectorRef.set(selector, uuid);
return uuid;
}
public static getSelector(uuid: string): TitleSegmentSelector {
return (
NgRxTitleStrategy.selectorRefsByUuid.get(uuid) ??
createSelector(
(state) => state,
() => ''
)
);
}
private selectTitleFromTemplate(titleTemplate?: string): Observable<string> {
const segmentSelectors = titleTemplate
? this.getTitleSegmentSelectors(titleTemplate)
: [];
const segments$ = this.selectSegments(segmentSelectors);
return segments$.pipe(map((segments) => segments.join('')));
}
private getTitleSegmentSelectors(title: string): Array<TitleSegmentSelector> {
return title
.split(NgRxTitleStrategy.selectorTemplateRegExp)
.map((s) => this.getTitleSegment(s));
}
private getTitleSegment(segmentTemplate: string): TitleSegmentSelector {
let segmentSelector: TitleSegmentSelector;
if (segmentTemplate.includes(NgRxTitleStrategy.selectorTemplatePrefix)) {
const selectorUuid = segmentTemplate
.replace(NgRxTitleStrategy.selectorTemplatePrefix, '')
.replace(NgRxTitleStrategy.selectorTemplateSuffix, '');
segmentSelector = NgRxTitleStrategy.getSelector(selectorUuid);
} else {
segmentSelector = createSelector(
(state) => state,
() => segmentTemplate
);
}
return segmentSelector;
}
private selectSegments(
segments: Array<TitleSegmentSelector>
): Observable<Array<string>> {
const s = segments.map((segment) =>
this.store.select(createSelector(segment, (s) => String(s)))
);
return combineLatest(s);
}
}
export const provideNgRxTitleStrategy = () => ({
provide: TitleStrategy,
useClass: NgRxTitleStrategy,
});
view raw title-strategy.ts hosted with ❤ by GitHub

Sentry image

Hands-on debugging session: instrument, monitor, and fix

Join Lazar for a hands-on session where you’ll build it, break it, debug it, and fix it. You’ll set up Sentry, track errors, use Session Replay and Tracing, and leverage some good ol’ AI to find and fix issues fast.

RSVP here →

Top comments (0)

Billboard image

The Next Generation Developer Platform

Coherence is the first Platform-as-a-Service you can control. Unlike "black-box" platforms that are opinionated about the infra you can deploy, Coherence is powered by CNC, the open-source IaC framework, which offers limitless customization.

Learn more

đź‘‹ Kindness is contagious

Please leave a ❤️ or a friendly comment on this post if you found it helpful!

Okay