NgRxのprovideStateを遅延ルートで登録する設計とレビュー観点

機能ごとの state を遅延ルートの providers に置く書き方は、NgRx の案内にも載っている。

遅延ルートに置いた機能 state
export const booksRoutes: Routes = [
  {
    path: '',
    providers: [provideState(booksFeature), provideEffects(BooksEffects)],
    children: [
      { path: '', component: BookListComponent },
      { path: ':id', component: BookDetailComponent },
    ],
  },
];

この形には、機能の state が機能の中に閉じているように見える効果がある。ルートに入るまで登録されず、出れば消える、と読める。

閉じているのは登録の時機だけである。@ngrx/store 22.0.1 の実装を読むと、登録は経路が照合された時点で走り、取り消す経路は用意されていない。state の寿命はアプリの寿命と同じになる。

登録が走る時機

provideState が返すのは環境プロバイダーである。

22.0.1 の provideState
function provideState(featureNameOrSlice, reducers, config = {}) {
    return makeEnvironmentProviders([
        ..._provideState(featureNameOrSlice, reducers, config),
        ENVIRONMENT_STATE_PROVIDER,
    ]);
}

末尾の ENVIRONMENT_STATE_PROVIDER が登録の本体を呼ぶ。

22.0.1 の ENVIRONMENT_STATE_PROVIDER と factory
function featureStateProviderFactory() {
    inject(ROOT_STORE_PROVIDER);
    const features = inject(_STORE_FEATURES);
    const featureReducers = inject(FEATURE_REDUCERS);
    const reducerManager = inject(ReducerManager);
    inject(_ACTION_TYPE_UNIQUENESS_CHECK, { optional: true });
    const feats = features.map((feature, index) => {
        const featureReducerCollection = featureReducers.shift();
        const reducers = featureReducerCollection[index];
        return {
            ...feature,
            reducers,
            initialState: _initialStateFactory(feature.initialState),
        };
    });
    reducerManager.addFeatures(feats);
}

const ENVIRONMENT_STATE_PROVIDER = [
    {
        provide: FEATURE_STATE_PROVIDER,
        useFactory: featureStateProviderFactory,
    },
    provideEnvironmentInitializer(() => inject(FEATURE_STATE_PROVIDER)),
];

環境イニシャライザーは注入器が作られた時点で走る。createEnvironmentInjector は runEnvironmentInitializers: true を渡し、R3Injector の構築直後に resolveInjectorInitializers() を呼ぶ。遅延に見えるかどうかは、この注入器がいつ作られるかで決まる。

ルートの注入器を作るのは router である。

@angular/router 22.2.1 の getOrCreateRouteInjectorIfNeeded
function getOrCreateRouteInjectorIfNeeded(route, currentInjector) {
  if (route.providers && !route._injector) {
    route._injector = createEnvironmentInjector(route.providers, currentInjector, `Route: ${route.path}`);
  }
  return route._injector ?? currentInjector;
}

route._injector に覚えるので、同じルート設定について作られるのは1回だけである。2度目の遷移では既にあるものが返り、イニシャライザーは走らない。登録は初回の遷移に1回で、その後は走らない。

呼び出し位置はもう1つ見ておく価値がある。

@angular/router 22.2.1 の matchWithChecks
function matchWithChecks(segmentGroup, route, segments, injector, urlSerializer, createSnapshot, abortSignal) {
  const result = match(segmentGroup, route, segments);
  if (!result.matched) {
    return of(result);
  }
  const currentSnapshot = createPreMatchRouteSnapshot(createSnapshot(result));
  injector = getOrCreateRouteInjectorIfNeeded(route, injector);
  return runCanMatchGuards(injector, route, segments, urlSerializer, currentSnapshot, abortSignal).pipe(map(v => v === true ? result : {
    ...noMatch
  }));
}

注入器を作るのが runCanMatchGuards の前である。canMatch が偽を返して経路が採用されなかった場合も、注入器はもう作られていて reducer は登録済みになる。canActivate は遷移のさらに後の段で走るので、こちらも登録を止められない。権限で弾いた画面の state も、URL を踏んだ時点でストアに現れる。

Comment
@Reviewer: `provideState(adminFeature)` を `canActivate: [adminGuard]` のルートに置いていますが、ルートの注入器は `canMatch` より前に作られるため、ガードが拒否しても reducer は登録されます。DevTools や永続化のメタリデューサーに管理画面の state が出るので、state 自体を見せたくないのであれば、登録を遷移の成否と結び付けない形にしてください。

登録を取り消す経路

登録の中身は ReducerManager の addFeatures である。

22.0.1 の ReducerManager(抜粋)
addFeatures(features) {
    const reducers = features.reduce((reducerDict, { reducers, reducerFactory, metaReducers, initialState, key }) => {
        const reducer = typeof reducers === 'function'
            ? createFeatureReducerFactory(metaReducers)(reducers, initialState)
            : createReducerFactory(reducerFactory, metaReducers)(reducers, initialState);
        reducerDict[key] = reducer;
        return reducerDict;
    }, {});
    this.addReducers(reducers);
}
removeFeatures(features) {
    this.removeReducers(features.map((p) => p.key));
}
addReducers(reducers) {
    this.reducers = { ...this.reducers, ...reducers };
    this.updateReducers(Object.keys(reducers));
}

removeFeatures はある。呼んでいるのは NgModule 版だけである。

22.0.1 の StoreFeatureModule(抜粋)
ngOnDestroy() {
    this.reducerManager.removeFeatures(this.features);
}

_provideState の側には DestroyRef も ngOnDestroy も無い。したがって StoreModule.forFeature を遅延 NgModule で読み込んだ構成では、モジュールの注入器が壊れるときに reducer が外れるが、provideState を使った構成では外れない。

ルートの注入器の破棄そのものも既定では起きない。22.2 で入った withAutoCleanupInjectors() を provideRouter に渡し、かつ RouteReuseStrategy.shouldDestroyInjector が真を返したときに限られる。条件の詳細はサービスに置いたsignalの共有範囲を提供位置からレビューするで読んだとおりである。仮にその条件を満たして注入器が壊れたとしても、removeFeatures を呼ぶものが無いので reducer は残る。

残ることの帰結は3つある。select が常に値を返すので、機能の外からも読める。メタリデューサーでの永続化や DevTools の記録に、離れた画面の state が入り続ける。そして combineReducers が毎 Action でその slice の reducer を呼ぶ。

再入で初期状態に戻らない理由

ルートから出て戻ってきたとき、state が初期値に戻ることを期待した実装がある。戻らない理由は combineReducers にある。

22.0.1 の combineReducers(抜粋)
return function combination(state, action) {
    state = state === undefined ? initialState : state;
    let hasChanged = false;
    const nextState = {};
    for (let i = 0; i < finalReducerKeys.length; i++) {
        const key = finalReducerKeys[i];
        const reducer = finalReducers[key];
        const previousStateForKey = state[key];
        const nextStateForKey = reducer(previousStateForKey, action);
        nextState[key] = nextStateForKey;
        hasChanged = hasChanged || nextStateForKey !== previousStateForKey;
    }
    return hasChanged ? nextState : state;
};

機能 reducer が受け取るのは state[key]、つまり前回そこに入っていた値である。reducer が初期値を返すのは引数が undefined のときだけで、slice が残っている限りその分岐には入らない。

前節のとおり slice は消えないので、再入したときに読めるのは前回の値である。一覧の検索条件、選択中の行、読み込み済みの一覧がそのまま出る。それが望みの挙動なら何もしなくてよいが、望みでないなら初期化する Action を自分で撒くことになる。

離脱時に初期化する
export const booksFeature = createFeature({
  name: 'books',
  reducer: createReducer(
    initialState,
    on(BooksPageActions.leave, () => initialState),
    // ...
  ),
});

初期化の契機をレビューで探すなら、provideState の有無ではなく、この on があるかどうかを見る。

Comment
@Reviewer: 一覧から詳細へ入り、ブラウザの戻るで一覧へ帰ると前回の検索条件が残ります。`provideState` はルートから出ても reducer を外さず、`combineReducers` は残った slice を reducer へ渡すため、初期値へは戻りません。離脱時の Action を1つ置いて、reducer で初期値へ戻してください。

キーが重なったときに起きること

addReducers は浅いマージである。

22.0.1 の addReducers
addReducers(reducers) {
    this.reducers = { ...this.reducers, ...reducers };
    this.updateReducers(Object.keys(reducers));
}

同じキーで2回登録すれば、後から来た reducer が前のものを置き換える。警告は無い。しかも slice の値は残っているので、新しい reducer は前の reducer が作った形の値を受け取る。形が違えば、そこから先の挙動は reducer の書き方次第になる。

キーの取り違えは createFeature を使っていても起きる。provideState はスライスを渡したときだけ name をキーにする。

22.0.1 の _provideState(抜粋)
{
    provide: STORE_FEATURES,
    multi: true,
    useValue: {
        key: featureNameOrSlice instanceof Object
            ? featureNameOrSlice.name
            : featureNameOrSlice,
        // ...
    },
},

provideState(booksFeature) ならキーは booksFeature.name になるが、provideState('book-list', booksFeature.reducer) と書けばキーは 'book-list' になる。createFeature が作った selector は name で引くので、別のキーで登録した state を selector が見つけられない。型は通る。

見つけられなかったときに出るのは開発モードの警告だけである。

22.0.1 の createFeatureSelector
function createFeatureSelector(featureName) {
    return createSelector((state) => {
        const featureState = state[featureName];
        if (!isNgrxMockEnvironment() && isDevMode() && !(featureName in state)) {
            console.warn(`@ngrx/store: The feature name "${featureName}" does ` +
                'not exist in the state, therefore createFeatureSelector ' +
                'cannot access it.  Be sure it is imported in a loaded module ' +
                `using StoreModule.forRoot('${featureName}', ...) or ` +
                `StoreModule.forFeature('${featureName}', ...).  If the default ` +
                'state is intended to be undefined, as is the case with router ' +
                'state, this development-only warning message can be ignored.');
        }
        return featureState;
    }, (featureState) => featureState);
}

isDevMode() の中なので本番ビルドでは検査が走らず、selector は undefined を返す。文面が案内しているのは NgModule 版の API のままなので、provideState を使っている構成では読み替えが要る。

この警告は、キーの取り違えだけでなく「登録より前に読んだ」場合にも出る。ヘッダーに置いた未読件数のように、遅延ルートの外にあるコンポーネントが機能 state の selector を使っている箇所がそれにあたる。初回の遷移が済むまで undefined が返るので、受け手側で既定値を決めることになる。

provideEffects を置く注入器

provideEffects の環境イニシャライザーは、state の登録を自分から引いている。

@ngrx/effects 22.0.1 の provideEffects(抜粋)
provideEnvironmentInitializer(() => {
    inject(ROOT_STORE_PROVIDER);
    inject(FEATURE_STATE_PROVIDER, { optional: true });
    const effectsRunner = inject(EffectsRunner);
    const effectSources = inject(EffectSources);
    const shouldInitEffects = !effectsRunner.isStarted;
    // ...
}),

FEATURE_STATE_PROVIDER を引くことで、同じ注入器にある provideState の登録が先に済む。providers の配列の中で provideEffects を先に書いても順序は保たれる。

保たれるのは同じ注入器の中だけである。provideState をルートに置き、provideEffects をルートの providers に置いた場合は揃うが、provideEffects を bootstrapApplication の側に置くと、そこには FEATURE_STATE_PROVIDER が無いため optional が null を返す。Effect は reducer の登録前に走り始める。起動直後に Action を撒く Effect を持っていれば、その Action は該当する reducer が無い状態で流れる。

2つを別の注入器に分ける理由が無ければ、同じ providers に並べる。

Effect を同じ注入器へ2回登録した場合は、2つめが落ちる。

@ngrx/effects 22.0.1 の EffectSources.toActions(抜粋)
return this.pipe(groupBy((effectsInstance) => isClassInstance(effectsInstance)
    ? getSourceForInstance(effectsInstance)
    : effectsInstance), mergeMap((source$) => {
    return source$.pipe(groupBy(effectsInstance));
}), mergeMap((source$) => {
    const effect$ = source$.pipe(exhaustMap((sourceInstance) => {
        return resolveEffectSource(this.errorHandler, this.effectsErrorHandler)(sourceInstance);
    }),
    // ...

クラスと識別子で束ねたうえで exhaustMap を通すので、先に流れている購読が続いている間に来た同じ識別子のインスタンスは無視される。識別子は ngrxOnIdentifyEffects が無ければ空文字である。二重に走らない代わりに、同じクラスの2つめのインスタンスを意図して足した場合も無言で落ちる。

置ける場所の境界

provideState と provideEffects はどちらも makeEnvironmentProviders を通る。戻る型は EnvironmentProviders で、これを受け取れるのはルートの providers、bootstrapApplication の providers、createEnvironmentInjector の引数である。

コンポーネントの providers は受け取れない。

@angular/core 22.2.1 の Directive の options
providers?: Provider[];

Provider[] なので型エラーになる。SignalStore をコンポーネントの providers に置いて画面と寿命を揃える手は使えない。state の寿命をコンポーネントに合わせたいなら、Store ではなく SignalStore 側の提供位置で決めることになる。選び分けはNgRx SignalStoreの提供位置が状態の寿命を決めることをレビューで確かめるで扱った。

レビュー観点チェックリスト

機能 state の登録位置を確かめる項目
  • 遅延ルートに置いた provideState を、state の寿命が短くなる仕掛けとして説明していないか
  • ガードで拒否する画面の state が、URL を踏んだだけで登録されてよいか
  • 再入したときに初期値へ戻す必要があるなら、離脱時の Action と reducer の on があるか
  • 同じキーで2回 provideState していないか(後から来た reducer が黙って置き換える)
  • provideState(feature) と provideState('key', reducer) のキーが、selector の引くキーと一致しているか
  • 遅延ルートの外から機能 state の selector を使っている箇所に、undefined の扱いがあるか
  • provideState と provideEffects が同じ注入器の providers に並んでいるか
  • 起動直後に Action を撒く Effect が、対象の reducer より先に走る位置に無いか
  • 同じ Effect クラスを複数登録している箇所に ngrxOnIdentifyEffects があるか
  • state の寿命を画面に合わせたい箇所で、Store ではなく SignalStore を選べないか

おわりに

provideState はルートの注入器が作られた時点で reducer を登録する。注入器が作られるのは経路の照合の中で、canMatch が走る前である。初回の遷移で1回だけ走り、2度目は route._injector が返るので走らない。

取り消す経路は NgModule 版にしか無い。provideState には破棄時の処理が無く、ルートの注入器が壊れても reducer は残る。slice が残るので combineReducers は前回の値を reducer へ渡し、再入しても初期値には戻らない。

したがって遅延ルートの provideState が遅らせるのは、reducer の登録と、その reducer を含むコードの読み込みである。state の寿命は短くならない。寿命を画面に合わせる必要があるなら、初期化の Action を自分で置くか、置き場所を Store の外に移す。

ストアの導入境界の観点の全体はNgRx StoreのAction・Reducer・Effectをレビューする100観点の「ストアの導入境界」の群に並べてある。