0

I have a React component with a state variable that needs specific actions. For example, consider a component that shows a list of user profiles, and the user can switch to another profile or create a new one. The state variable is a list of user profiles, and a second variable is the currently selected profile; the component can add a new profile (which is more specific than just "setting" a new list of profiles), or it can change the currently selected profile.

My first idea was to have two useState hooks, one for the list and one for the current profile. However, one problem with that is that I would like to store the current profile's id, which refers to one of the profiles in the list, which means that the two state variables are inter-dependent. Another issue is that having a generic setProfiles state change function is a bit too "open" for my taste: the add logic may be very specific and I would like to encapsulate it.

So I came up with this solution: a custom hook managing the two state variables and their setters, that would expose the two values (list and current id) and their appropriate actions (add new profile and select profile).

This is the code of the hook:

export const useProfileData = () => {
    const [ profiles, setProfiles ] = useState([]);
    const [ currentProfileID, setCurrentProfileID ] = useState(null);
    const [ currentProfile, setCurrentProfile ] = useState(null);

    useEffect(() => {
        // This is actually a lazy deferred data fetch, but I'm simplifying for the sake of brevity
        setProfiles(DataManager.getProfiles() || [])
    }, [])

    useEffect(() => {
        if (!profiles) {
            setCurrentProfile(null);
            return;
        }
        const cp = profiles.find(p => p.ID === currentProfileID);
        setCurrentProfile(cp);
    }, [ currentProfileID, profiles ])

    return { 
        currentProfile: currentProfile, 
        profiles: profiles,
        setCurrentProfileID: i_id => setCurrentProfileID(i_id),
        addNewProfile: i_profile => {
            profiles.push(i_profile);
            setProfiles(profiles);
            DataManager.addNewProfile(i_profile); // this could be fire-and-forget
        },
    };
};

Three states are used: the list, the current profile id and the current profile (as an object). The list is retrieved at mounting (the current id should be too, but I omitted that for brevity). The current profile is never set directly from the outside: the only way to change it is to change the id or the list, which is managed by the second useEffect. And the only way to change the id is through the exposed setCurrentProfileID function.

Adding a new profile is managed by an exposed addNewProfile function, that should add the new profile to the list in state, update the list in state, and add the new profile in the persistent DataManager.

My first question is: is it ok to design a hook like this? From a general software design point of view, this code gives encapsulation, separation of concerns, and a correct state management. What I'm not sure about if this is proper in a functional world like React.

My second question is: why is my component (that uses useProfileData) not updated when addNewProfile is called? For example:

const ProfileSelector = (props) => {
    const [ newProfileName, setNewProfileName ] = useState('');
    const { profiles, currentProfile, setCurrentProfileID, addNewProfile } = useProfileData();

    function createNewProfile() {
        addNewProfile({
            name: newProfileName,
        });
    }

    return (
        <div>
            <ProfilesList profiles={profiles} onProfileClick={pid => setCurrentProfileID(pid)} />
            <div>
                <input type="text" value={newProfileName} onChange={e => setNewProfileName(e.target.value)} />
                <Button label="New profile" onPress={() => createNewProfile()} />
            </div>
        </div>
    );
};

ProfilesList and Button are components defined elsewhere.

When I click on the Button, a new profile is added to the persistent DataManager, but profiles is not updated, and ProfilesList isn't either (of course).

I'm either implementing something wrong, or this is not a paradigm that can work in React. What can I do?

EDIT

As suggested by @thedude, I tried using a reducer. Here is the (stub) of my reducer:

const ProfilesReducer = (state, action) => {
    const newState = state;
    switch (action.type) {
        case 'addNewProfile':
            {
                const newProfile = action.newProfile;
                newState.profiles.push(newProfile);
                DataManager.addNewProfile(newProfile);
            }
            break;
        default:
            throw new Error('Unexpected action type: ' + action.type);
    }
    return newState;
}

After I invoke it (profilesDispatch({ type: 'addNewProfile', newProfile: { name: 'Test' } });), no change in profilesState.profiles is detected - or at least, a render is never triggered, nor an effect. However, the call to DataManager has done its job and the new profile has been persisted.

Simone
  • 1,260
  • 1
  • 16
  • 27
  • have you considered `useReducer`? – thedude Feb 16 '21 at 10:37
  • @thedude `useReducer` would give me a single state variable; I need two. While I could use a single object with my two variables as its properties, would the state update be notified when a property of the reducer's state changes, and not the whole reducer's state? – Simone Feb 16 '21 at 10:41
  • the point of `useReducer` is to have a central place for all state mutations, which is handy when dealing with interdependent data. Your state can have multiple values, and since all state changes go through the reducer you can manage it more easily – thedude Feb 16 '21 at 10:44
  • @thedude I'll try the reducer then, thank you! – Simone Feb 16 '21 at 11:04
  • @thedude I tried the reducer, but still no success. I've posted the reducer above: am I doing something wrong? – Simone Feb 16 '21 at 13:52

1 Answers1

1

You should never mutate your state, not even in a reducer function.

From the docs:

If you return the same value from a Reducer Hook as the current state, React will bail out without rendering the children or firing effects. (React uses the Object.is comparison algorithm.)

Change your reducer to return a new object:

const ProfilesReducer = (state, action) => {
    switch (action.type) {
        case 'addNewProfile':
            {
                const newProfile = action.newProfile;
                return {...state, profiles: [...state.profiles, newProfile]}
            }
            break;
        default:
            throw new Error('Unexpected action type: ' + action.type);
    }
    return state;
}

Also not that reducer should no have side effects, if you want to perform some action based on a state change, use a useEffect hook for that.

For example: DataManager.addNewProfile(newProfile) should not be called from the reducer

thedude
  • 9,388
  • 1
  • 29
  • 30
  • Would it be possible to do what the reducer does with an effect? Your solution means I have to use a reducer *and* an effect. Can I join them inside a custom hook? – Simone Feb 16 '21 at 15:37
  • One more thing: can the function I call in an event handler have side effects? Can I perform `DataManager.addNewProfile` inside the event handler? If not, I have to receive in an effect the new state (which includes the new profile at the end of the array - or maybe not: the array might have been changed in other ways) and detect the new profile and save it. – Simone Feb 16 '21 at 15:41
  • Final thought: how can I load my initial reducer value asynchronously? – Simone Feb 16 '21 at 15:48
  • 1
    yes, you can have side effects in an event-handler. See https://stackoverflow.com/questions/53146795/react-usereducer-async-data-fetch for your other question – thedude Feb 16 '21 at 16:04
  • Would it be ok to define a custom hook that returns an object with methods (like in my original post), some of which have side effects? This way, the calls to `dispatch` would actually be wrapped and encapsulated. – Simone Feb 16 '21 at 17:04