Search code examples
angulartypescriptngrxngrx-store

Should we normalize the state in an angular app?


at our company we are working on our first Angular projects, including ngrx-store, and we came to the discussion if the state should be normalized or not - the meanings differed in that question.

Some argued that a nested store would be easier to maintain / to work with, because the api already sends nested data and in some cases the whole object should be cleared from the store.

Example: Let's say we have a feature store called customer. Inside this we store the customer-list and the selected customer. One point: The selected customer object has much more properties than the customer objects inside the customer-list.

We display the list of customers (model: CustomerList) from the store in a component, and if the user clicks on an entry he is redirected to the detail page where customer details (model: CustomerDetail) are displayed.

Inside the details page the user is able to create/edit/delete all the customer-sublists (like addresses, phones, faxes, etc.). If the detail page of a specific customer is closed and the user is back on the list, the store object of the customer should be cleared.

export interface CustomerState {
    customers: CustomerList[],
    customer: CustomerDetail
};

export interface CustomerList {
    customerId: number;
    name: string;
};

export interface CustomerDetail {
    customerId: number;
    firstName: string;
    lastName: string;
    addressList: CustomerDetailAddressList[];
    phoneList: CustomerDetailPhoneList[];
    emailList: CustomerDetailEmailList[];
    faxList: CustomerDetailFaxList[];
    /* ... */
};

If a user now, let's say creates a new address for a customer on the specific customers detail page, the new address is posted to the api and after a success response from the api the store refetches the new addressList from the api again and puts it into the addressList of the customer inside the store.

Some think that the biggest disadvantage of a not-normalized state is the following:

The deeper the nesting, the longer and more complex are the mappings inside the store to set or get data to/from the store.

The others argue, if we kind of normalize the state so that there will be no nesting inside the customer object how would we benefit from that, because in our special situation the customerList-objects and the customerDetail-objects differ from each other, otherwise if both objects would be the same, we simply could store the id of the selected customer inside the store and select the data from the customer list by id.

Another disadvantage they bring into the discussion is, that if a user leaves a customer detail page and the state is normalized, instead of just clearing the customer object, all the other lists refered to the customer need also be cleared.

export interface CustomerState {
    customers: CustomerList[],
    customer: CustomerDetail,
    customerAddressList: CustomerDetailAddressList[];
    customerPhoneList: CustomerDetailPhoneList[];
    customerEmailList: CustomerDetailEmailList[];
    customerFaxList: CustomerDetailFaxList[];
};

tl;dr

What do you guys think / how are your experiences with the store (normalized or not) and how can we get the best out of both worlds? Any response will be appreciated!

And please let me know if something is not quite clear or doesn't make sense at all - we are still learning - any help and / or advises are highly appreciated.


Solution

  • You have discussed the pros and cons of both approaches well within the question - it is indeed a toss up between the two options - the benefits of storing a single object mean that updates flow around all places where that object is referenced, but it makes your reducers more complex.

    In our complex application, we chose to go (mostly) for the nested option, but to help keep the reducer implementation cleaner, we wrote pure-function operators to update (for example) a Company:

    function applyCompanyEdit(state: CompanyState, compId: number, compUpdate: (company: CompanyFull) => CompanyFull): CompanyState {
        let tab = state.openCompanies.find((aTab) => aTab.compId == compId);
        if (!tab) return state;
        let newCompany = compUpdate(tab.details);
        // Set company edited if the new company returned by compUpdate are not the exact same
        // object as the previous state.
        let compEdited = tab.edited || tab.details != newCompany;
    
        return {
            ...state,
            openCompanies: state.openCompanies.map((ct) => {
                if (ct.compId == compId) return {
                    ...ct,
                    edited: compEdited,
                    details: newCompany
                };
                return ct;
            })
        };
    }
    

    ... which we then used in multiple reducer operations to update a single Company as follows:

    case CompanyStateService.UPDATE_SHORTNAME: 
        return applyCompanyEdit(state, action.payload.compId, (comp: CompanyFull) => {
            return {
                ...comp,
                shortName: action.payload.shortName
            }
        });
    case CompanyStateService.UPDATE_LONGNAME: 
        return applyCompanyEdit(state, action.payload.compId, (comp: CompanyFull) => {
            return {
                ...comp,
                longName: action.payload.longName
            }
        });
    

    This worked very well for us as the reducer operations were kept very clean and understandable, and we only had to write the awkward function for finding the right object to update once. In this case, the nesting was relatively shallow, but it could be extended to any arbitrarily deep structure within the applyCompanyEdit function, keeping the complexity in just that one place.