Search code examples
reactjsreduxreact-hooksredux-toolkit

useSelector is not working, redux in react


When I use useSelector the variable is always holding its initial state. I have the feeling it is stored in some parallel galaxy and never updated. But when I retrieve the value with const store = useStore(); store.getState()... it gives the correct value (but lacks subscribtions). When I inspect the store in redux devtools I can see all the values are recorded in the store correctly. Values are just not retrieved from the store with useSelector.

What I wanted to achieve is to have some cache for user profiles, i.e. not fetch /api/profile/25 multiple times on the same page. I don't want to think of it as "caching" and make multiple requests just keeping in mind the requests are cached and are cheap but rather thinking of it as getting profiles from the store and keeping in mind profiles are fetched when needed, I mean some lazy update.

The implementation should look like a hook, i.e.

// use pattern
const client = useProfile(userId);
// I can also put console.log here to see if the component is getting updated
let outputProfileName;
if( client.state==='pending' ) {
    outputProfileName = 'loading...';
} else if( client.state==='succeeded' ) {
    outputProfileName = <span>{client.data.name}</span>
} // ... etc

so I placed my code in use-profile.js, having redux-toolkit slice in profile-slice.js

profile-slice.js

import {
    createSlice,
    //createAsyncThunk,
} from '@reduxjs/toolkit';


const entityInitialValue = {
    data: undefined,
    state: 'idle',
    error: null
};


export const slice = createSlice({
    name: 'profile',
    initialState: {entities:{}},
    reducers: {
        updateData: (state,action) => {
            // we received data, update the data and the status to 'succeeded'
            state.entities[action.payload.id] = {
                ...entityInitialValue,
                //...state.entities[action.payload.id],
                data: action.payload.data,
                state: 'succeeded',
                error: null
            };
            return; // I tried the other approach - return {...state,entities:{...state.entities,[action.payload.id]:{...}}} - both are updating the store, didn't notice any difference
        },
        dispatchPendStart: (state,action) => {
            // no data - indicates we started fetching
            state.entities[action.payload.id] = {
                ...entityInitialValue,
                //...state.entities[action.payload.id],
                data: null,
                state: 'pending',
                error: null
            };
            return; // I tried the other approach - return {...state,entities:{...state.entities,[action.payload.id]:{...}}} - both are updating the store, didn't notice any difference
        },
        dispatchError: (state,action) => {
            state.entities[action.payload.id] = {
                //...entityInitialValue,
                ...state.entities[action.payload.id],
                data: null,
                state: 'failed',
                error: action.payload.error
            };
            return; // I tried the other approach - return {...state,entities:{...state.entities,[action.payload.id]:{...}}} - both are updating the store, didn't notice any difference
        },
    },
    extraReducers: {
    }
});

export const {updateData,dispatchPendStart,dispatchError} = slice.actions;

// export const selectProfile... not used

export default slice.reducer;

use-profile.js

import React, { useState, useEffect } from 'react';
import { useDispatch, useSelector, useStore } from 'react-redux';
import {
    updateData as actionUpdateData,
    dispatchPendStart as actionDispatchPendStart,
    dispatchError as actionDispatchError,
} from './profile-slice';
//import api...

function useProfile(userId) {

    const dispatch = useDispatch();
    const actionFunction = async () => {
        const response = await client.get(`... api endpoint`);
        return response;
    };

    const store = useStore();
    // versionControl is a dummy variable added for testing to make sure the component is updated;
    // it is updated: I tried adding console.log to my component function (where I have const client = useProfile(clientId)...)
    const [versionControl,setVersionControl] = useState(0);
    const updateVersion = () => setVersionControl(versionControl+1);

    // TODO: useSelector not working

    const updateData   = newVal => { dispatch(actionUpdateData({id:userId,data:newVal})); updateVersion(); };
    const dispatchPendStart  = newVal => { dispatch(actionDispatchPendStart({id:userId})); updateVersion(); };
    const dispatchError  = newVal => { dispatch(actionDispatchError({id:userId,error:newVal})); updateVersion(); };

    const [
        getDataFromStoreGetter,
        getLoadingStateFromStoreGetter,
        getLoadingErrorFromStoreGetter,
    ] = [
        () => (store.getState().profile.entities[userId]||{}).data,
        () => (store.getState().profile.entities[userId]||{}).state,
        () => (store.getState().profile.entities[userId]||{}).error,
    ];

    const [
        dataFromUseSelector,
        loadingStateFromUseSelector,
        loadingErrorFromUseSelector,
    ] = [
        useSelector( state => !!state.profile.entities[userId] ? state.profile.entities[userId].data : undefined ),
        useSelector( state => !!state.profile.entities[userId] ? state.profile.entities[userId].loadingState : 'idle' ),
        useSelector( state => !!state.profile.entities[userId] ? state.profile.entities[userId].loadingError : undefined ),
    ];

    useEffect( async () => {
        if( !(['pending','succeeded','failed'].includes(getLoadingStateFromStoreGetter())) ) {
            // if(requestOverflowCounter>100) { // TODO: protect against infinite loop of calls
            dispatchPendStart();
            try {
                const result = await actionFunction();
                updateData(result);
            } catch(e) {
                dispatchError(e);
                throw e;
            }
        }
    })

    return {
        versionControl, // "versionControl" is an approach to force component to update;
        //      it is updating, I added console.log to the component function and it runs, but the values
        //      from useSelector are the same all the time, never updated; the problem is somewhere else; useSelector is just not working
        // get data() { return getDataFromStoreGetter(); }, // TODO: useSelector not working; but I need subscribtions
        // get loadingState() { return getLoadingStateFromStoreGetter(); },
        // get loadingError() { return getLoadingErrorFromStoreGetter(); },
        data: dataFromUseSelector,
        loadingState: loadingStateFromUseSelector,
        loadingError: loadingErrorFromUseSelector,
    };
}


export default useProfile;

store.js

import { configureStore,combineReducers } from '@reduxjs/toolkit';

import profileReducer from '../features/profile/profile-slice';
// import other reducers

export default configureStore({
    reducer: {
        profile: profileReducer,
        // ... other reducers
    },
});

component.js - actually see the use pattern above, there's nothing interesting besides the lines posted.

So

When I export loading state (I mean last lines in use-profile.js; I can suppress last three lines and uncomment the other three). So, if I use getLoadingStateFromStoreGetter (values retrieved via store.getState()...), then some profile names are displaying names that were fetched and some are holding "loading..." and are stuck forever. It makes sense. The correct data is retrieved from redux store and we have no subscribtions.

When I export the other version, created with useSelector, I always get its initial state. I never receive any user name or the value indicating "loading".

I have read many answers on StackOverflow. Some common mistakes include:

  • Some are saying your component is not getting updated. It's not the case, I tested it placing console.log to the code and adding the versionControl variable (see in the code) to make sure it updates.

  • Some answers are saying you don't update the store with reducers correctly and it still holds the same object. It's not the case, I tried both approaches, to return a fresh new object {...state,entities:{...state.entities...etc...}} and mutating the existing proxy object - both way my reducers should provide a new object and redux should notify changes.

  • Sometimes multiple store instances are created and things are messed. It's definitely not the case, I have a single call to configureStore() and a single component.

  • Also I don't see hook rules violation in my code. I have an if statement inside the useSelector fn but the useSelector hook itself is called unconditionally.

I have no idea what other reasons are causing useSelect to simply not work. Could anyone help me understand?


Solution

  • Ops, as usual, very simple typo is the reason. So many hours spent. Very sorry to those who have spent time trying to look at this and thanks for your time.

    useSelector( state => !!state.profile.entities[userId] ? state.profile.entities[userId].loadingState : 'idle' )
    

    There should be not .loadingState but .state. That's it.