はじめに
本記事はAngular Advent Calendar 2023 10日目の担当になります。
Angularを用いていて状態管理をしたいとなったとき、選択肢の一つとしてNgRxが挙がるかと思います。
しかしNgRxはメモリに乗ってしまうためブラウザの更新などに弱いです。
対策としてStorageに乗っける手法がありますが、Storeの更新が入るたびにStorageも合わせて更新する処理を挟むのは面倒です。
できれば勝手に同期して欲しい。
これを叶えるのがngrx-store-localstorageです。
本記事ではv16のstandaloneで実装を行います。
つい先月Angular v17が出たばかりではありますが、執筆時点現在、ngrx-store-localstorageのv17サポートはまだPRが出ている段階のためです。
環境
ng version
Angular CLI: 16.2.10
Node: 18.18.0
Package Manager: npm 9.8.1
Angular: 16.2.12
Install
ng add @ngrx/store
npm install ngrx-store-localstorage
UI周りを自力実装するのが面倒なのでAngular Materialに頼ります。
ng add @angular/material
App
特にコンポーネントを分ける理由がないので、UI周りは全て app.component.*
で完結させます。
app.compoent.html
<div class="main">
<mat-form-field>
<mat-label>ID</mat-label>
<input matInput [formControl]="idForm">
</mat-form-field>
<mat-form-field>
<mat-label>NAME</mat-label>
<input matInput [formControl]="nameForm">
</mat-form-field>
<mat-form-field>
<mat-label>AGE</mat-label>
<input
matInput
type="number"
[formControl]="ageForm"
[max]="200"
[min]="0"
>
</mat-form-field>
<mat-slide-toggle [formControl]="isAdminSelect">
isAdmin
</mat-slide-toggle>
</div>
app.compoent.scss
.main {
margin: 24px;
display: flex;
flex-direction: column;
gap: 16ox;
}
app.component.ts
import { Component, OnInit, OnDestroy, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import {
ReactiveFormsModule,
FormControl,
FormGroup
} from '@angular/forms';
import { Subscription } from 'rxjs';
import { Store } from '@ngrx/store';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { UserInfo, UserInfoInitial } from './app.interface';
import { AppActions } from './store/app.actions';
import { AppSelector } from './store/app.selector';
@Component({
selector: 'app-root',
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
MatFormFieldModule,
MatInputModule,
MatSlideToggleModule,
],
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss']
})
export class AppComponent implements OnInit, OnDestroy {
private subscription = new Subscription();
private store = inject(Store);
idForm = new FormControl<UserInfo['id']>(
UserInfoInitial.id,
{nonNullable: true},
);
nameForm = new FormControl<UserInfo['name']>(
UserInfoInitial.name,
{nonNullable: true},
);
ageForm = new FormControl<UserInfo['age']>(
UserInfoInitial.age,
{nonNullable: true},
);
isAdminSelect = new FormControl<UserInfo['isAdmin']>(
UserInfoInitial.isAdmin,
{nonNullable: true},
);
formGroup = new FormGroup({
id: this.idForm,
name: this.nameForm,
age: this.ageForm,
isAdmin: this.isAdminSelect,
});
ngOnInit(): void {
this.subscription.add(
this.store.select(AppSelector.selectUserInfo).subscribe((data) => {
this.idForm.setValue(data.id, {onlySelf: true});
this.nameForm.setValue(data.name, {onlySelf: true});
this.ageForm.setValue(data.age, {onlySelf: true});
this.isAdminSelect.setValue(data.isAdmin, {onlySelf: true});
})
);
this.subscription.add(
this.formGroup.valueChanges.subscribe((inputValue) => {
const param: UserInfo = {
id: inputValue.id ?? UserInfoInitial.id,
name: inputValue.name ?? UserInfoInitial.name,
age: inputValue.age ?? UserInfoInitial.age,
isAdmin: inputValue.isAdmin ?? UserInfoInitial.isAdmin,
};
this.store.dispatch(AppActions.updateForm({data: param}));
})
);
}
ngOnDestroy(): void {
this.subscription.unsubscribe();
}
}
app.interface.ts
export interface UserInfo {
id: string;
name: string;
age: number;
isAdmin: boolean;
}
export const UserInfoInitial: UserInfo = {
id: '',
name: '',
age: 0,
isAdmin: false,
};
Store
store/
に作成します。
app.actions.ts
import { createAction, props } from '@ngrx/store';
import { UserInfo } from '../app.interface';
export namespace AppActions {
export const updateForm = createAction(
'[App Page] Change Form Value',
props<{data: UserInfo}>(),
);
}
app.reducer.ts
import { createReducer, on } from '@ngrx/store';
import { UserInfo, UserInfoInitial } from '../app.interface';
import { AppActions } from './app.actions';
export interface State {
userInfo: UserInfo;
}
export const initialState: State = {
userInfo: UserInfoInitial,
};
export const appReducer = createReducer(
initialState,
on(
AppActions.updateForm,
(state, { data }) => ({
userInfo: {...data},
}),
),
);
export const AppFeatureKey = 'app';
app.selector.ts
import { createSelector, createFeatureSelector } from '@ngrx/store';
import { State, AppFeatureKey } from './app.reducer';
export const selectAppFeature = createFeatureSelector<State>(AppFeatureKey);
export namespace AppSelector {
export const selectUserInfo = createSelector(
selectAppFeature,
(state) => state.userInfo
);
}
config
ここが肝になります。
ngrx-store-localstorage#usageにある通り設定します。
import { ApplicationConfig } from '@angular/core';
import {
provideState,
provideStore,
ActionReducer,
MetaReducer,
Action
} from '@ngrx/store';
import { provideAnimations } from '@angular/platform-browser/animations';
import { localStorageSync } from 'ngrx-store-localstorage';
import { appReducer, AppFeatureKey, State } from './store/app.reducer';
const localStorageSyncReducer = (
reducer: ActionReducer<State, Action>
): ActionReducer<State, Action> =>
localStorageSync({
keys: ['userInfo'],
rehydrate: true,
storage: localStorage,
storageKeySerializer: (key) => `my_app_data_for_${key}`,
})(reducer);
const metaReducers: Array<MetaReducer<any, any>> = [localStorageSyncReducer];
export const appConfig: ApplicationConfig = {
providers: [
provideStore(),
provideAnimations(),
provideState(AppFeatureKey, appReducer, { metaReducers }),
],
};
ただドキュメントの例だとstandaloneには対応していません。
が、importする部分以外は大方同じなことがわかるので、それ以外はそのまま作ります。
const localStorageSyncReducer = (
reducer: ActionReducer<State, Action>
): ActionReducer<State, Action> =>
localStorageSync({
keys: ['userInfo'],
rehydrate: true,
storage: localStorage,
storageKeySerializer: (key) => `my_app_data_for_${key}`,
})(reducer);
const metaReducers: Array<MetaReducer<any, any>> = [localStorageSyncReducer];
バインディングするkeyを keys
に含めます。
今回はusetInfoしかないので脳死でキー名を記載します。
初回にlocalstorageと同期して欲しいので rehydrate = true
。
storageは localStorage
にしておきます。デフォルトが localStorage
なので書かなくてもいいんですけど SessionStorage
を使いたい場合の方が多そうなので、わかりやすさ重視で記載しました。
storageKeySerializerのkeyには keys
に記載した値が入ってきて、storageのキー名が返り値になります。
この場合は my_app_data_for_userInfo
となります。
ngrx-store-localstorage#usage-1を見ると複数登録ができると、例えば main
みたいな被りそうな名前でstoreのキー名をつけていた場合にprefixやsuffixをつけて一意性を担保したりと、そういった点で使えそうです。
あとは例をみるとmetaReducersに登録すれば良さげなので、ngrxのMeta-reducersを見てみます。
すると現在は下記のような記載しかなく、 provideStateでのやり方がわかりません。
StoreModule.forRoot(
reducers,
{metaReducers}
)
ただprovideStateのドキュメントを見ると、configに metaReducers が登録できそうです。
でもReducerのstandaloneの例にある下記のコードだとconfigを登録できませんでした。
provideStore({ [scoreboardFeatureKey]: scoreboardReducer })
なので下記のようにして登録します。
provideState(AppFeatureKey, appReducer, { metaReducers }),
これでできました!
フォームの値を変更するとlocalStorageが更新されていることが開発者ツールからも確認できます。
また、ブラウザをリロードしても前に入力していた値がフォームに入っています。
おわりに
読んで頂きありがとうございました。
storeの同期が簡単にできるようになりましたが、その分storageに詰める詰めないの取捨選択やkeyを分けて保存するかどうかについての考慮がstoreの責任になります。
それでも同期処理を自分で書く必要がなくなるのでやっぱり便利です。
以上、Angular Advent Calendar 2023 10日目でした。