NgRxとlocalStorageを同期する

# はじめに

本記事は[Angular Advent Calendar 2023](https://qiita.com/advent-calendar/2023/angular) 10日目の担当になります。

Angularを用いていて状態管理をしたいとなったとき、選択肢の一つとして[NgRx](https://ngrx.io/)が挙がるかと思います。
しかしNgRxはメモリに乗ってしまうためブラウザの更新などに弱いです。
対策としてStorageに乗っける手法がありますが、Storeの更新が入るたびにStorageも合わせて更新する処理を挟むのは面倒です。
できれば勝手に同期して欲しい。
これを叶えるのが[ngrx-store-localstorage](https://github.com/btroncone/ngrx-store-localstorage)です。

本記事ではv16のstandaloneで実装を行います。
つい先月[Angular v17](https://blog.angular.io/introducing-angular-v17-4d7033312e4b)が出たばかりではありますが、執筆時点現在、ngrx-store-localstorageのv17サポートはまだPRが出ている段階のためです。

- [ngrx-store-localstorage#260](https://github.com/btroncone/ngrx-store-localstorage/issues/260)

# 環境

```sh
ng version

Angular CLI: 16.2.10
Node: 18.18.0
Package Manager: npm 9.8.1
Angular: 16.2.12
```

# Install

```sh
ng add @ngrx/store
npm install ngrx-store-localstorage
```

UI周りを自力実装するのが面倒なので[Angular Material](https://material.angular.io/)に頼ります。

```sh
ng add @angular/material
```

# App

特にコンポーネントを分ける理由がないので、UI周りは全て `app.component.*` で完結させます。

## app.compoent.html

```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

```scss
.main {
  margin: 24px;
  display: flex;
  flex-direction: column;
  gap: 16ox;
}
```

## app.component.ts

```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

```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

```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

```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

```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](https://github.com/btroncone/ngrx-store-localstorage#usage)にある通り設定します。

```ts
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する部分以外は大方同じなことがわかるので、それ以外はそのまま作ります。

```ts
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](https://github.com/btroncone/ngrx-store-localstorage#usage-1)を見ると複数登録ができると、例えば `main` みたいな被りそうな名前でstoreのキー名をつけていた場合にprefixやsuffixをつけて一意性を担保したりと、そういった点で使えそうです。

あとは例をみるとmetaReducersに登録すれば良さげなので、[ngrxのMeta-reducers](https://ngrx.io/guide/store/metareducers)を見てみます。
すると現在は下記のような記載しかなく、 [provideState](https://ngrx.io/api/store/provideState)でのやり方がわかりません。

```ts
StoreModule.forRoot(
  reducers,
  {metaReducers}
)
```

ただ[provideState](https://ngrx.io/api/store/provideState)のドキュメントを見ると、configに [metaReducers](https://ngrx.io/api/store/StoreConfig#metaReducers) が登録できそうです。

でもReducerのstandaloneの例にある下記のコードだとconfigを登録できませんでした。

```ts
provideStore({ [scoreboardFeatureKey]: scoreboardReducer })
```

なので下記のようにして登録します。

```ts
provideState(AppFeatureKey, appReducer, { metaReducers }),
```

これでできました！

フォームの値を変更するとlocalStorageが更新されていることが開発者ツールからも確認できます。
また、ブラウザをリロードしても前に入力していた値がフォームに入っています。

# おわりに

読んで頂きありがとうございました。

storeの同期が簡単にできるようになりましたが、その分storageに詰める詰めないの取捨選択やkeyを分けて保存するかどうかについての考慮がstoreの責任になります。
それでも同期処理を自分で書く必要がなくなるのでやっぱり便利です。

以上、[Angular Advent Calendar 2023](https://qiita.com/advent-calendar/2023/angular) 10日目でした。