Moo
Moo

Reputation: 3675

ngrx forms 8 reducer does not update state

I'm using ngrx-forms (specifically the ngrx 8 syntax) to sync my form and state store. The form state updating actions are successfully dispatched e.g.

{
  controlId: 'adForm.location.community',
  value: 'wst',
  type: 'ngrx/forms/SET_VALUE'
}

However, the state always stays the same, the above produces no diff in the Redux dev tools.

My reducer looks as follows:

export const reducer = createReducer(
  initialAdFormStoreState,
  onNgrxForms(),
  // Other action handlers
  // ...
)

export const adFormReducer = wrapReducerWithFormStateUpdate(
  reducer,
  (s) => s.data.form,
  adFormValidators,
)

The initial state is created as follows:

const initialAdFormStoreState: AdFormStoreState = {
  data: {
    form: createFormGroupState<AdForm>('adForm', { /* ... */ })
    // ...
  },
  // ...
}

I've checked the updating state documentation countless times and everything looks correct.

Upvotes: 3

Views: 1375

Answers (2)

Evgeni Islamov
Evgeni Islamov

Reputation: 46

As Moo said, unfortunately, onNgrxForms() checks only the top level of the state and you need to write your own implementation.

I had a similar problem, my task was to implement some kind of virtual tabs, that can be stored and displayed independently.

I got two implementations: straight implementation that checks only two-level state (the one that I use in my production project) and more generic implementation that works recursively.

The code is based on ngrx-forms implementation of onNgrxForms(), see links below.

https://github.com/MrWolfZ/ngrx-forms/blob/master/src/reducer.ts https://github.com/MrWolfZ/ngrx-forms/blob/master/src/state.ts

State

export interface MyObject {
    property: string;
}

export interface MyObjectFormValue {
    property: string;
}

export interface Substate {
    data: MyObject;
    form: FormGroupState<MyObjectFormValue>;
}

export interface State {
    [id: number]: Substate;
    commonData1: any[];
    commonData2: boolean;
}

As you can see, the State interface contains some Substates, that contain FormGroupState. Substate can be accessed via indexer but you can write your implementation without indexer, using just your field data.

If you want, you can add some common data (fields commonData1, commonData2) shared between Substates.

Straight approach

import { ALL_NGRX_FORMS_ACTION_TYPES, formStateReducer } from 'ngrx-forms';

function isSubstate(object: any): object is Substate {
    return (
        Boolean(object) &&
        Object.prototype.hasOwnProperty.call(object, 'data') &&
        Object.prototype.hasOwnProperty.call(object, 'form')
    );
}

export function onNgrxFormsDeep(): { reducer: ActionReducer<State>; types: string[] } {
    return {
        reducer: (state, action) =>
            Object.entries(state)
                .filter(([_, value]) => isSubstate(value))
                .reduce(
                    (accum, [key, subState]) => ({
                        ...accum,
                        [key]: {
                            ...subState,
                            form: formStateReducer(subState.form, action)
                        }
                    }),
                    state
                ),
        types: ALL_NGRX_FORMS_ACTION_TYPES
    };
}

I use Object.entries because I don't know what Substates are stored inside State, but if the keys of State are predefined, you can just simply access them.

Generic approach

import { ActionReducer } from '@ngrx/store';
import { produce } from 'immer';
import { ALL_NGRX_FORMS_ACTION_TYPES, FormState, formStateReducer } from 'ngrx-forms';

interface Match {
    parentObjectPath: string[];
    formStateKey: string;
}

export function onNgrxFormsDeep(): {
    reducer: ActionReducer<any>;
    types: string[];
} {
    return {
        reducer: (state, action) => {
            if (isFormState(state)) {
                return formStateReducer(state, action);
            }

            const matches = searchForms(state);

            if (matches.length) {
                return produce(state, (draftState) => {
                    matches.forEach((match) => {
                        const parent = match.parentObjectPath.reduce((acc, key) => acc[key], draftState);
                        const form = match.parentObjectPath.reduce((acc, key) => acc[key], state)[match.formStateKey];
                        parent[match.formStateKey] = formStateReducer(form, action);
                    });
                });
            }

            return state;
        },
        types: ALL_NGRX_FORMS_ACTION_TYPES
    };
}

function searchForms(state: any): Match[] {
    const matches: Match[] = [];
    const visits = new Set<any>();
    const parentObjectPath = [];
    searchFormsImpl(null, state);

    return matches;

    function searchFormsImpl(key: string, object: any): void {
        if (visits.has(object)) {
            return;
        }
        visits.add(object);

        if (isFormState(object)) {
            matches.push({ parentObjectPath: [...parentObjectPath], formStateKey: key });
        } else if (isObject(object) || Array.isArray(object)) {
            if (key) {
                parentObjectPath.push(key);
            }
            Object.entries(object).forEach(([k, v]) => {
                searchFormsImpl(k, v);
            });
            if (key) {
                parentObjectPath.pop();
            }
        }
    }
}

function isFormState<TValue = any>(state: any): state is FormState<TValue> {
    return (
        Boolean(state) &&
        Object.prototype.hasOwnProperty.call(state, 'id') &&
        Object.prototype.hasOwnProperty.call(state, 'value') &&
        Object.prototype.hasOwnProperty.call(state, 'errors')
    );
}

function isObject(objValue: any): boolean {
    return (
        Boolean(objValue) &&
        typeof objValue === 'object' &&
        (objValue.constructor === Object || objValue.constructor.toString().startsWith('class'))
    );
}

This version uses the library immer to update state immutably.

Unfortunately, the function isFormState is not in public API of the library, so I copied it.

I added a set visits to avoid cirricular references problem, because some fields can contain their parents, without it the code will fail with stack overflow error.

Besides that, I wrote the function isObject because some properties (of type number, string, function, etc) should not be checked.

Upvotes: 1

Moo
Moo

Reputation: 3675

onNgrxForms() only checks for forms at the top level of the feature state object. It wasn't updating my state because I had the form at data.form since we split state into ui/data. I had to hoist it up a level. Alternatively you can write your own implementation of onNgrxForms().

Upvotes: 2

Related Questions