LoginSignup
7
0

NgRxとlocalStorageを同期する

Last updated at Posted at 2023-12-09

はじめに

本記事は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日目でした。

7
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
0