TheFactoEngineer - Musician - Game Enthusiast

Balancing State Locality with Context, Hooks, and Async Operations

(8 min read)

Before we begin, this post relies pretty heavily on two CodeSandbox examples that I’ve linked below. I recommend popping them open to use as a reference as you read through. Here we go…


How do I manage the balance between global and local state with React’s new hooks API and Context? This question has had me jumping from tutorials, blogs, and tweets in an effort to find the holy grail of state zen. I’ll be honest, though, it has left me scratching my head. I have yet to find my stride when it comes to managing state in its “proper” locality. So this post isn’t so much advice, but a musing, as I try to work through the following experiment:

  1. Context that owns “global” state that can be provided to consuming React components.
  2. React components that own their own local state that changes based on the state of the Context. In other words, local state that is reactive to changes in the provided Context.

In order to run this experiment, I concocted the following scenario.

Scenario

  • Display a list of people.

    • Each list item contains a delete action.
  • When deleting a person, you’re first prompted: “You Sure?“.

    • If yes, delete the person.
    • If no, don’t.
  • While deleting, show an indicator to the user that the delete is in progress.

    • Change the prompt title to “Pending…“.
  • If the delete is successful.

    • Close the prompt.
    • Delete the person from the list.
  • If the delete failed.

    • Change the prompt title to “Oops!“.
    • Change the prompt confirm text to “Retry”.

In this scenario, I want to use Context for managing people. This context will use the reducer pattern, since there could be several different types of actions that alter the people’s state. Right now, that’s just fetch and delete; nothing fancy there. From there, I want to create API functions on the context for fetchPeople() and deletePerson(). I then want a consuming React component which uses the context to display the list of people and provide the full delete workflow described above. Purely in terms of state, the People context will own the list of people and the Consumer should contain the state related to the delete workflow; I want to test the relationship between global and local state, afterall.

What resulted from this experiment were two different approaches that I creatively dubbed: First Attempt and Second Attempt. There are some parallels between the two, so to set the stage at a high level, the person context looked like this for both:

/* people.js */
export const PeopleContext = createContext({
    byId: {},
    allIds: []
});

const initialState = { byId: {}, allIds: []};

export const DeleteState = {
    NONE: 0,
    PENDING: 1,
    DELETED: 2,
    FAILED: 3
};

const fetchPeopleApi = async () => {
    await pauseTime(1000);
    return [
        { id: 1, name: "Scott Hansen", deleteState: DeleteState.NONE },
        { id: 2, name: "Porter Robinson", deleteState: DeleteState.NONE },
        { id: 3, name: "Madeon", deleteState: DeleteState.NONE }
    ];
};

export const PeopleProvider = ({ children }) => {
    const [state, dispatch] = useReducer(reducer, initialState);

    const fetchPeople = async () => {
        dispatch({ type: Actions.FETCH_PEOPLE });
        try {
            const people = await fetchPeopleApi();
            dispatch({ type: Actions.RECEIVE_PEOPLE, payload: people });
        } catch(err) {
            dispatch({ type: Actions.FETCH_PEOPLE_FAIL, payload: err });
        }
    };

    /* This changes between attempts */
    const deletePerson = () => {
    };

    const people = state.allIds
        .map(id => state.byId[id])
        .filter(person => person.deleteState !== DeleteState.DELETED);

    return (
        <PeopleContext.Provider
            value={{
                people,
                fetchPeople,
                deletePerson
            }}
        >
        </PeopleContext.Provider>
    );
};

Note: I’ll touch more on the concept of a “deleted” state later.

Then just to quickly cover the consumer:

/* consumer.js */
const Consumer = () => {
    const p = useContext(PeopleContext);

    useEffect(() => {
        p.fetchPeople();
    }, []);

    /* ... Render the list of people */
};

Okay, so that sets the stage for how I structured the experiment. To re-cap, I created a PeopleContext and PeopleProvider which manages the state of a list of people objects. Those people objects have a few fields, including a DeleteState. I then created a Consumer which uses the people context via useContext(PeopleContext) to display the list of people.

First Attempt

Edit vjxp3m8r57

Since I already covered the basic fetch flow of the consumer, I want to hone in on the delete workflow; that’s where we start balancing the mixture of global and local state. The consumer owns the following local state which is used to represent the status of the delete operation:

/* Which person id is being confirmed for deletion */
const [deleteConfirmId, setDeleteConfirmId] = useState(null);
/* Enum state for the dialog */
const [modalState, setModalState] = useState(ModalState.CONFIRM);
/* Count to track how many delete attempts are made */
const [attempts, setAttempts] = useState(0);

Then, when confirming the delete action, I make use of the context’s deletePerson() API call.

/* consumer.js */
const handleConfirmDelete = async id => {
    setAttempts(attempts + 1);
    setModalState(ModalState.PENDING);
    p.deletePerson(
        () => {
            setDeleteConfirmId(null);
        },
        () => {
            setModalState(ModalState.FAILED);
        }
    )(id);
};

In my first attempt, I added hooks into the deletePerson() context API that allows me to know what exactly is happening; I can make changes to my local state based on the flow of the async action. I could see this being a very common situation, especially if you need to handle failure situations.

The context API looks like this:

/* people.js */
const deletePerson = (onSuccess, onFail) => async id => {
    dispatch({ type: Actions.DELETE_PERSON_REQUEST, payload: id });
    try {
      await deletePersonApi(id);
      onSuccess(id);
      dispatch({ type: Actions.DELETE_PERSON, payload: id });
    } catch (err) {
      onFail(err, id);
      dispatch({ type: Actions.DELETE_PERSON_FAIL, payload: id });
    }
  };

Something about this just looks off to me. I need the context to handle the state (hence the dispatch calls). However, since my consumer(s) need to potentially know what’s happening with this API, I provide them callbacks. Is it okay to have a dispatch() followed by a callback or vice-versa? Is it good practice to provide consumers with detailed flow knowledge via callbacks?

Second Attempt

Edit 94oq2l0rjr

Given the fact that something felt off about having callbacks in my context API, I took another shot to see if I could get the same workflow, relying more on state than before.

In my new approach, my consumer relies very heavily on the DeleteState of the person.

/* consumer.js */
const p = useContext(PeopleContext);
const personInQuestion = p.people.find(p => p.id === deleteConfirmId);
const personDeleteState = personInQuestion
    ? personInQuestion.deleteState
    : null;

useEffect(() => {
    /* ... */
    switch(personDeleteState) {
        /* ... */
        case DeleteState.FAILED:
            setModalState(ModalState.FAILED);
    }
}, [attempts, personDeleteState]);

I’m sure I could clean up my state.

The difference between this and my first attempt, and maybe I’m lacking the terminology to describe it, is that I’m reacting to changes in the person state via an effect instead of callbacks into an action.

This allowed me to simplify the delete API. The tradeoff, however, is some of the state management in the consumer felt a bit more complex.

/* people.js */
const deletePerson = async id => {
    dispatch({ type: Actions.DELETE_PERSON_REQUEST, payload: id });
    try {
      await deletePersonApi(id);
      dispatch({ type: Actions.DELETE_PERSON, payload: id });
    } catch (err) {
      dispatch({ type: Actions.DELETE_PERSON_FAIL, payload: id });
    }
  };

Reviewing

With this little exercise, I had a few goals:

  1. Use a mixture of context, global state (via context), and local state.
  2. Mix in asynchronous actions and UI that’s reactive to changes.
  3. Determine any patterns that emerge and if I like them.

In the First Attempt, I update my localized state via callbacks into the asynchronous action. In this way, I know what’s happening and I can modify my local state as changes occur.

In the Second Attempt, I do not rely on information from the asynchronous action and instead respond entirely through the information in the context coming into the consumer. The action becomes more of a “fire and forget” approach. My gut is telling me that I like this approach more, but I just can’t articulate why. Anecdotally, I feel like going down this route could lead to more information finding its way into the state as your complexity increases.

Another point I want to touch on before wrapping up is the idea of DeleteState.DELETED. This is another place I wind up in. If I need to respond to the fact that something went from existing to not-existing, I may want to keep it around and just mark it as deleted. I don’t have a good example prepared for this, unfortunately, but it’s something to ponder as well. As far as I can tell, there’s not really a downside to keeping deleted things around aside from the fact that it does have a memory cost.

Questions

Based on this exercise, I’m left with a few questions that I still need to think through.

  1. Which approach for updating local state based on asynchronous actions do I prefer?

    1. Callbacks or try-catch on the API.
    2. Rely entirely on props/context information and use effects to react to changes.
  2. How do I recognize when to move state up into Context and juggle its relationship to local state elsewhere in the app?
  3. Is it good practice to keep things in state that have been deleted? (DeleteState.DELETED)

Well, that’s about all I have on this topic for now. What do you think?


TheFacto Copyright © 2022 - Present
night