skip to Main Content

I have a mildly complex setup for tabs and tab slots, to make them modular and generic. I’ve decided to create a context and a provider for this and expose ways to manipulate tabs and slots (slots are what is being displayed in the current tab).

But what I found very weird is that my callback that you can call to change the slot, only on the first render is using the old state. Let me show

export const TabProvider: React.FC<PropsWithChildren<{}>> = ({ children }) => {
    const [tabComponents, setTabComponents] = useState<TabComponent[]>([]);
    const [currentTab, setCurrentTab] = useState("");
    const [currentSlot, setCurrentSlot] = useState<TabSlot>({ label: "", component: null, id: "" })

    useEffect(() => {

        console.log("Use Effect", currentTab, tabComponents, currentSlot)
    }, [currentSlot, tabComponents, currentTab])

    const setCurrentSlotHelper = (slotId: string) => {
        console.log("On Click", slotId, tabComponents, currentSlot, currentTab)
        tabComponents.forEach(component => {
            component.slots.forEach(slot => {
                if (slot.id == slotId) {
                    setCurrentSlot(slot)
                }
            })
        })
    }

If we look at the console

screenshot of console to showcase the empty output

We can see that the on click one does not have the correct state.

We can see the correct state in the devtools as well

[
  {
    "name": "State",
    "value": [
      "{id: "dashboard", label: "Dashboard", onCurrent: ƒ …}",
      "{id: "content", label: "Content", onCurrent: ƒ onCu…}",
      "{id: "studio", label: "Studio", onCurrent: ƒ onCurr…}"
    ],
    "subHooks": [],

]

I know that this can potentially occur if we use a closure and capture the state, but that is not what I am doing here, and it works if I manage navigate to another tab and back. So there is something really funky happening and I need a React wizard to help me decipher this.

The tab provider

const TabContext = createContext<TabContextProps>(defaultState);

export const useTab = () => useContext(TabContext);

export const TabProvider: React.FC<PropsWithChildren<{}>> = ({ children }) => {
    const [tabComponents, setTabComponents] = useState<TabComponent[]>([]);
    const [currentTab, setCurrentTab] = useState("");
    const [currentSlot, setCurrentSlot] = useState<TabSlot>({ label: "", component: null, id: "" })

    useEffect(() => {

        console.log("Use Effect", currentTab, tabComponents, currentSlot)
    }, [currentSlot, tabComponents, currentTab])

    const setCurrentSlotHelper = (slotId: string) => {
        console.log("On Click", slotId, tabComponents, currentSlot, currentTab)
        tabComponents.forEach(component => {
            component.slots.forEach(slot => {
                if (slot.id == slotId) {
                    setCurrentSlot(slot)
                }
            })
        })
    }

    const setTabComponentsHelper = (components: TabComponent[]) => ...

    const setCurrentTabHelper = (tabId: string) =>...

    return (
        <TabContext.Provider
            value={{
                tabComponents, setTabComponents: setTabComponentsHelper, currentTab, setCurrentTab: setCurrentTabHelper, currentSlot, setCurrentSlot: setCurrentSlotHelper
            }}
        >
            {children}
        </TabContext.Provider>
    );
};

The way its used

export const Template = () => {
    const { sidebarComponents, setSidebarComponents } = useSidebar()
    const { setTabComponents, currentSlot, setCurrentSlot } = useTab();

    useEffect(() => {
        let newTabs: TabComponent[] = [];
        // When the tab changes, update the sidebar components, and set default slot
        const onTabChange = (tab: TabComponent) => {
            const newSlotId = tab.slots[0].id
            const sidebarComponents: SidebarComponent[] = []
            tab.slots.forEach(slot => {
                const slotId = slot.label.toLowerCase()
                sidebarComponents.push({
                    id: slotId,
                    label: slot.label,
                    isCurrent: newSlotId == slotId,
                    onClick: () => setCurrentSlot(slotId)
                })
            })

            setCurrentSlot(newSlotId) // <- here we're calling it
            setSidebarComponents(sidebarComponents)
        }

        const tabs = ecommerceTabs;

        for (let i = 0; i < tabs.length; i++) {
            const tab = tabs[i]
            const tabComponent: TabComponent = {
                id: tab.label.toLowerCase(),
                label: tab.label,
                slots: tab.slots.map(slot => {
                    return {
                        ...slot, id: slot.label.toLowerCase()
                    }
                }),
                onCurrent: () => {
                    onTabChange(tabComponent)
                }
            }
            newTabs.push(tabComponent)
        }

        onTabChange(newTabs[0])

        setTabComponents(newTabs)
    }, [])

    // When the current slot changes, update the sidebar components
    useEffect(() => {
        if (sidebarComponents.length == 0) return;
        setSidebarComponents(
            sidebarComponents.map(component => {
                component.isCurrent = component.label.toLowerCase() == currentSlot.id
                return component
            })
        );
    }, [currentSlot])

    return (<></>)
};

2

Answers


  1. Since updating a state is an async task it takes some time to update the state and since youre setting state inside a loop it creates a lag. You can do something like this:

    const setCurrentSlotHelper = (slotId: string) => {
        let tempSlot = {...currentSlot};
        console.log("On Click", slotId, tabComponents, currentSlot, currentTab)
        tabComponents.forEach(component => {
            component.slots.forEach(slot => {
                if (slot.id == slotId) {
                    tempSlot = slot;
                }
            })
        })
        setCurrentSlot(tempSlot)
    }
    

    In this I am doing all the data manipulations in a local variable which is not async and doing the setting of state in the end once which saves us from slower renders and You will get proper console logs in your useEffect.

    Login or Signup to reply.
  2. Your issue has to do with your useEffect() in Template only running once on mount. As it defines the onClick on-mount, it references the original setCurrentSlot from the original render rather than the new/latest setCurrentSlot function created by subsequent renders (which know about the latest state).


    Each time your state within TabProvider changes your component rerenders. This means that TabProvider is called once on the initial render, and then potentially many more times for each rerender when your state changes. Every render that occurs calls the TabProvider function again, recreating the TabProvider function scope and the variables that live within that, including the states tabComponents, currentTab, currentSlot which are set to the new/latest state values. Along with the state variables being recreated, so is the setCurrentSlotHelper function, which now knows about the new state variables in its surrounding scope that it was declared in.

    You can think of each rerender of TabProvider as being "snapshots", each snapshot having its own state variables and functions defined with TabProvider.

    Your problem is that within Template you’re using a useEffect() with an empty dependency array []. That means that your useEffect() will run on-mount only. At the time that your useEffect() runs, your state values from TabProvider are in their initial states still, and the function setCurrentSlot is the setCurrentSlotHelper function from the initial render (ie: first snapshot) of the Template function. That means that setCurrentSlot only knows about the initial state variables from the initial render/snapshot. Technically speaking, your useEffect callback function here has formed a closure over the initial setCurrentSlot function, and your setCurrentSlot function has formed a closure over the initial state variable values.

    As your state then changes in TabProvider, you create new "snapshots" and new setCurrentSlotHelper for each rerender, however, since your useEffect() function will not run again (since it only runs on initial mount due), the reference to the new setCurrentSlotHelper won’t reestablish, and instead, your useEffect() callback will continue to look at the initial setCurrentSlotHelper from the first render/snapshot.


    There are usually a few different ways to fix this, but its a bit hard to tell what’s best to do in your scenario as it’s not too clear how tabs is being used. Normally you’d want to delegate the onClick and event-handler logic to the JSX rather than setting it up in your useEffect(). You may also find that adding setCurrentSlot (and setSidebarComponents) as dependencies to your useEffect() is another way around this (if you do that, then you would want to look at memoizing these functions with useCallback()).

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search