Intro
In my previous article, I demonstrated how to:
- Build a simple Angular Material theme switcher
- Add Angular Material Modules to your application
- And build custom Angular Material themes
One thing was missing though: The functionality for persisting the preferred theme through a page reload. In this article, I’ll explore three different strategies for persisting a user’s theme preference including getting the theme preference set by the user in their operating system. If you’d like to follow along, check out the Github repo here.
Strategy 1: Local Storage
For this strategy, I created a new UserPrefsService
. At the top of the file, above the Angular @Injectable
decorator, I added an enum
and a few const
s which help with typing and eliminate the use of “magic strings”.
export enum ThemeOption { LIGHT = 'light', DARK = 'dark' } const DEFAULT = ThemeOption.LIGHT; const KEY = 'theme-preference'; const LIGHT_THEME = 'light-theme'; const DARK_THEME = 'dark-theme';
Inside the service class, I created a getter and a setter for the application’s preferredTheme
:
get preferredTheme(): ThemeOption { return (localStorage.getItem(KEY) as ThemeOption) || DEFAULT; }
The getter returns the value of the localStorage
'theme-preference'
key if it exists, otherwise, the default theme (light) is used.
set preferredTheme(value: ThemeOption) { localStorage.setItem(KEY, value); if (value === ThemeOption.DARK) { this._renderer.addClass(document.body, DARK_THEME); this._renderer.removeClass(document.body, LIGHT_THEME); } else { this._renderer.addClass(document.body, LIGHT_THEME); this._renderer.removeClass(document.body, DARK_THEME); } }
The setter sets the localStorage
key/value pair and toggles the active theme using the _renderer
instance.
constructor(rendererFactory: RendererFactory2) { this._renderer = rendererFactory.createRenderer('body', null); this.preferredTheme = this.preferredTheme; }
I moved the Renderer2
injection from the component to the service in this strategy. When injecting Renderer2
in a service, I found that it is necessary to inject RendererFactory2
and set up a renderer instance within the constructor
. The createRenderer
method takes a hostElement
and a type
. Here I set the hostElement
as 'body'
and the type
as null
.
I also set this.preferredTheme
to this.preferredTheme
, which triggers the initial theme setting or change.
In the AppComponent
, I inject the UserPrefsService
in the constructor
:
constructor(private userPrefsService: UserPrefsService) {} ngOnDestroy(): void { this.destroy$.next(); this.destroy$.complete(); } ngOnInit(): void { this.toggleTheme.patchValue( this.userPrefsService.preferredTheme === ThemeOption.DARK ); this.toggleTheme.valueChanges .pipe(takeUntil(this.destroy$)) .subscribe((toggleValue: boolean) => { this.userPrefsService.preferredTheme = toggleValue ? ThemeOption.DARK : ThemeOption.LIGHT; }); }
Inside the patchValue
of the toggleTheme
FormControl
, I passed in a conditional statement based on the userPrefsService
preferredTheme
getter. If the preferredTheme
is dark, the slide toggle is set to true. I also subscribe to the toggleTheme
FormControl
‘s valueChanges
and update the preferredTheme
using the userPrefsService
preferredTheme
setter.
Strategy 2: API Request
The API Request strategy is very similar to the strategy for Local Storage. In fact, I created a branch off of the local-storage feature branch and made the necessary modifications. For the purpose of this demo, I set up a json-server and db.json
file.
constructor(private http: HttpClient, rendererFactory: RendererFactory2) { this._renderer = rendererFactory.createRenderer('body', null); }
I removed the line where I set this.preferredTheme
to this.preferredTheme
. This initialization now occurs in the AppComponent
.
getPreferredTheme(): Observable,[object Object]
Rather than a getter, the getPreferredTheme
is now a method that calls an API via the HttpClient
(You need to import the HttpClientModule
in your AppModule
for this to work). The getPreferredTheme
method returns a value from the ThemeOption
enum
.
Rather than a setter, setPreferredTheme
is also now a method which calls an API via the HttpClient
:
setPreferredTheme(value: ThemeOption): void { const demoUser = { id: 1, themePreference: value }; this.http .put,[object Object]
In the this.http.put()
, I passed in demoUser
which has a themePreference
property. For this version, I extracted the renderer functionality into individual methods, which are called from a ternary inside an RxJS tap
operator. Personally, I like how much cleaner this makes the setPreferredTheme
.
Back in the app.component.ts
, rather than the getter and setter, I call the service methods getPreferredTheme
and setPreferredTheme
from the ngOnInit
.
ngOnInit(): void { this.userPrefsService .getPreferredTheme() .subscribe(theme => this.toggleTheme.patchValue(theme === ThemeOption.DARK) ); this.toggleTheme.valueChanges .pipe(takeUntil(this.destroy$)) .subscribe((toggleValue: boolean) => { this.userPrefsService.setPreferredTheme( toggleValue ? ThemeOption.DARK : ThemeOption.LIGHT ); }); }
Note that I am using the takeUntil
RxJS operator on the valueChanges
observable, but not on getPreferredTheme
. That’s because Http calls auto-complete, however, the observable on the FormControl
does not, potentially causing memory leaks and unintended side-effects. this.destroy$
is an RxJS Subject
, which completes in the ngOnDestroy
:
ngOnDestroy(): void { this.destroy$.next(); this.destroy$.complete(); }
Strategy 3: OS Theme Preference
I have to say, this strategy was the most fun to learn about and probably my favorite out of the three. The fact that the user doesn’t have to do anything to set their theme preference is a big win in my book.
const matchMediaPreferDark = window.matchMedia("(prefers-color-scheme: dark)");
Here, I call a method called matchMedia
on the TypeScript interface Window
. This method takes a query which is a string of a JSON key-value pair: '{key: value}'
. The key-value pair I need is '{prefers-color-scheme: dark}'
. The method returns a MediaQueryList
, an object which contains the following properties:
media
matches
onchange
// Checking to see if addEventListener is available in the browser (it's not in Safari) if (matchMediaPreferDark.addEventListener && matchMediaPreferDark.addEventListener instanceof Function) { matchMediaPreferDark.addEventListener("change", event => { handleMatchEvent(event); }); } else { matchMediaPreferDark.addListener(handleMatchEvent); }
Next, I added a listener, but because Safari doesn’t support the addEventListener
method, I check the browser’s compatibility first. If addEventListener
exists, and it is a Function
, I add the method passing in a "change"
type event listener with a callback function as parameters. If addEventListener
does not exist or it’s not a Function
, I use the addListener
method which, unfortunately, is deprecated but necessary for Safari. This method takes the callback function as its only parameter.
const handleMatchEvent = (matchEvent: MediaQueryList | MediaQueryListEvent) => { this.toggleTheme.patchValue(!!matchEvent.matches); }; handleMatchEvent(matchMediaDark);
To handle the matchMedia
event, I created this handleMatchEvent
function which takes a matchEvent
: either a MediaQueryList
or MediaQueryListEvent
. Both of these types have the matches
property on them. I then pass !!matchEvent.matches
to the FormControl
patchValue
method, which adjusts the slide toggle as necessary.
Recap
In this article, I showed how to persist a user’s theme preference in three different ways:
- Using Local Storage
- Requesting data from an API
- Accessing the user’s operating system preferences through their browser
Hopefully, the next time you write a theme switcher one of (or a combination of) these strategies is a great fit for your application. Thank you for reading!
To see the code and run the demo, check out the Github repo.
For more info on Angular Material, check out material.angular.io.
For help choosing theme colors, check out the official material palette generator.
Author: Anthony Jones, Sr. Enterprise Software Engineer