Lowtrux
Lowtrux

Reputation: 208

Computed property lack of reactivity for deep nested object

EDIT01: I'm using "nuxt": "^2.16.0" with "@nuxtjs/composition-api": "^0.29.2".

I'm have been trying for days to figure this out but still dealing with this problem. Basically I have a tab navigation where users can add or remove items (images, videos) the state of these actions has to be reflected in the DOM in other tabs.

I have a vuex module where I do the regular operations when dealing with state: fetch data, set state and because I need flags so the other tabs are aware that I have added or remove items from the state I also have "stale" and "fresh" mutations on my store.

The problem arises when I add a Vuex GETTER in order to flag that a deep nested array in my state has change. This is my vuex store:

import Vue from 'vue';
import { ActionTree, MutationTree, GetterTree } from 'vuex';

import { RootState } from '~/store';
import { AsyncData } from '~/utils/needsToFetch';
import { fetchStore } from '~/utils/fetchStore';

import { featuredItems } from '~/api/featuredItems';

type State = { [key: number]: AsyncData<FeaturedItems, ListMeta> };

export const state = (): State => ({});

export const actions: ActionTree<State, RootState> = {
    fetch(actionContext, { options, userID, page }) {
        return fetchStore({
            actionContext,
            callFunc: () => {
                return featuredItems(options, userID, page);
            },
            payloadProcessor: (data: AsyncData<FeaturedItems>) => {
                let newData = [];

                // If fetching the first page, replace the state
                if (data.meta?.current_page === 1) {
                    newData = [...data.data.featuredItems];
                } else if (actionContext.state[userID]?.data?.featuredItems) {
                    // Otherwise, append new items to the existing state
                    newData.push(...actionContext.state[userID]?.data?.featuredItems, ...data.data.featuredItems);
                }

                return {
                    data: {
                        ...data.data,
                        featuredItems: newData,
                    },
                };
            },

            key: userID,
        });
    },

    // New action to mark data as stale
    markAsStale(context, userID) {
        context.commit('markAsStale', userID);
    },
    // New action to mark data as fresh
    markAsFresh(context, userID) {
        context.commit('markAsFresh', userID);
    },
};

export const getters: GetterTree<State, RootState> = {
    getFeaturedItems(state) {
        return (userID: number) => {
            return state[userID];
        };
    },
    isFeaturedItemsEmpty(state) {
        return (userID: number) => {
            const featuredItems = state[userID]?.data?.featuredItems || [];
            return featuredItems.length === 0;
        };
    },
};

export const mutations: MutationTree<State> = {
    setState(state, { key, payload }) {
        if (state[key]) {
            Object.assign(state[key], payload);
            // Replace array reference explicitly
            if (payload?.data?.featuredItems) {
                Vue.set(state[key].data, 'featuredItems', [...payload.data.featuredItems]); // Replace array
            }
        } else {
            Vue.set(state, key, { ...payload });
        }
    },

    // New mutation to mark data as stale
    markAsStale(state, key) {
        // The data might not reflect the latest state from the server or backend
        if (state[key]) {
            state[key].stale = true;
        }
    },

    // The data reflect the latest state from the BE
    markAsFresh(state, key) {
        if (state[key]) {
            state[key].stale = false;
        }
    },
};

As you can see I don't have explicitly mutations/actions to add or remove elements from the state, this is accomplished by refetching the data whenever I operate within the array. For you to have an idea how deep is the array (featuredItems) I'm dealing with this is the state of this vuex module after fetching data:

{
    "status": 2,
    "stale": true,
    "data": {
        **"featuredItems": [{
            "id": 11,
            "image": {
                "id": 16191745,
                "name": "pexels-rdne-6182469.jpg",
                "width": 1600,
                "height": 1067,
                "is_loved": false
            }
        }]**
    },
    "meta": {
        "current_page": 1,
        "from": 1,
        "per_page": 10,
        "to": 1
    },
    "links": {
        "last": null,
        "prev": null,
        "next": null
    }
}

As you can see I'm already using vue.set for that array, but nothing seems to work. For some reason I never get reactivity from that deep nested Array. I even try to avoid the getter entirely on my child component:

<template>
    <section
        class="feature-tab-actions column is-12-mobile is-12-tablet is-flex is-justify-content-space-between"
        v-if="!isLoading && featuredItems.data && isFetched(featuredItems.data.featuredItems, true) && props.profile"
    >
        <div class="toggle-default-tab is-flex is-justify-content-flex-start">
            <p v-html="$t('profile.featured.toggle_default_tab')" class="is-hidden-mobile"></p>
            <FormSwitchField
                :disabled="isFeaturedToggleDisabled"
                :value="isFeaturedTabDefault"
                @input="makeFeaturedTabDefault"
                class="switch-component"
                name="make_featured_tab_default"
            />
            <BTooltip
                position="is-top"
                type="is-black"
                class="tooltip-content"
                :multilined="true"
                :label="$t('profile.featured.info')"
            >
                <IconHelpCircle class="has-text-grey-light" />
            </BTooltip>
        </div>
        <h2>{{ isFeaturedToggleDisabled }}</h2>
        <ReorderButton v-if="isReorderButtonDisabled" @emit-reorder="reorder" />
    </section>
</template>

<script lang="ts">
    import { defineComponent, computed, onMounted, ref, watch } from '@nuxtjs/composition-api';
    import { AsyncData, isFetched } from '~/utils/needsToFetch';
    import { updateDefaultTab } from '~/utils/updateDefaultTab';
    import useStore from '~/utils/useStore';

    export default defineComponent({
        props: {
            profile: {
                type: Object,
                required: true,
            },
        },

        setup(props, context) {

            const {
                state: featuredItemsState,
                actions: { fetch: fetchFeaturedItems },
            } = useStore<AsyncData<FeaturedItems, ListMeta>>(context, 'profile/featuredItems');

            const featuredItems = computed(() => {
                return featuredItemsState[props.profile.user_id] || {};
            });

            const isFeaturedToggleDisabled = computed(() => {
                const items = featuredItems.value?.data?.featuredItems || [];
                console.log('Evaluating isFeaturedToggleDisabled:', items.length);
                return items.length === 0;
            });    

            const isLoading = ref(true);

            onMounted(async () => {
                isLoading.value = true;
                await fetchFeaturedItems({
                    options: { context },
                    userID: props.profile.user_id,
                });
                isLoading.value = false;
            });    

            return {
                isLoading,
                featuredItems,
                featuredItemsState,
                isFetched,
                props,
            };
        },
    });
</script>

The result here is that I can see that the computed property featuredItems gets updated as soon as I add or remove elements BUT isFeaturedToggleDisabled never gets updated until I reload the page or navigate to another tab and then comes back, so basically the following code is just being re-computed when the component mounts or unmounts:

const isFeaturedToggleDisabled = computed(() => {
    const items = featuredItems.value?.data?.featuredItems || [];
    console.log('Evaluating isFeaturedToggleDisabled:', items.length);
    return items.length === 0;
});

I know the root of the problem is related to this issue but I have tried multiple solutions and is not working for me. Any help will be really appreciated.

EDIT 02 For more context, my vuex pattern is pretty particular (legacy code) the vuex module uses a utility function called fetchStore:

export const fetchStore = async <Data, Meta = any, R = ApiResponse<Data, Meta>>({
    actionContext: { state, commit },
    callFunc,
    key,
    payloadProcessor,
}: {
    actionContext: ActionContext<AsyncData<Data, Meta> | { [key: string]: AsyncData<Data, Meta> }, RootState>;
    callFunc: () => Promise<R>;
    key?: string;
    payloadProcessor?: (...args: any[]) => {};
}) => {
    if (
        // @ts-ignore
        (key && (!state[key] || state[key].status === Status.NONE || state[key].status === Status.ERROR)) ||
        (!key && (state.status === Status.NONE || state.status === Status.ERROR))
    ) {
        // First time or error state, set status to FETCHING
        commit('setState', {
            key,
            payload: {
                status: Status.FETCHING,
                stale: false, // Mark data as not stale when fetching starts
            },
        });
    } else {
        // Already fetched, set status to REFETCHING
        commit('setState', {
            key,
            payload: {
                status: Status.REFETCHING,
                stale: false,
                error: undefined,
            },
        });
    }

    try {
        // @ts-ignore
        const { data } = await callFunc();

        if (data.success) {
            let payload = {
                status: Status.FETCHED,
                data: data.data,
                meta: data.meta,
                links: data.links,
                stale: false,
            };

            if (payloadProcessor) {
                payload = {
                    ...payload,
                    ...payloadProcessor(data),
                };
            }

            commit('setState', {
                key,
                payload,
            });

            return data;
        } else {
            const error = new Error('Data fetch failed');
            // Attach data to the error object
            (error as any).data = data;
            throw error;
        }
    } catch (error) {
        // @ts-ignore
        const message = typeof error === 'string' ? error : error.data?.message;

        commit('setState', {
            key,
            payload: {
                status: Status.ERROR,
                error: message,
                stale: true,
            },
        });

        // @ts-ignore
        return error.data;
    }
};

Upvotes: 1

Views: 61

Answers (1)

Estus Flask
Estus Flask

Reputation: 222890

The keys that don't exist in initial state need to be set only with Vue.set to maintain reactivity, this is what is already done in setState. This part may cause problems, depending on payload keys:

Object.assign(state[key], payload)

This case is covered in the documentation. It's safer to do:

state[key] = Object.assign({}, state[key], payload)

Upvotes: 1

Related Questions