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
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
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:
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.
Your issue has to do with your
useEffect()
inTemplate
only running once on mount. As it defines theonClick
on-mount, it references the originalsetCurrentSlot
from the original render rather than the new/latestsetCurrentSlot
function created by subsequent renders (which know about the latest state).Each time your state within
TabProvider
changes your component rerenders. This means thatTabProvider
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 theTabProvider
function again, recreating theTabProvider
function scope and the variables that live within that, including the statestabComponents
,currentTab
,currentSlot
which are set to the new/latest state values. Along with the state variables being recreated, so is thesetCurrentSlotHelper
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 withTabProvider
.Your problem is that within
Template
you’re using auseEffect()
with an empty dependency array[]
. That means that youruseEffect()
will run on-mount only. At the time that youruseEffect()
runs, your state values fromTabProvider
are in their initial states still, and the functionsetCurrentSlot
is thesetCurrentSlotHelper
function from the initial render (ie: first snapshot) of theTemplate
function. That means thatsetCurrentSlot
only knows about the initial state variables from the initial render/snapshot. Technically speaking, youruseEffect
callback function here has formed a closure over the initialsetCurrentSlot
function, and yoursetCurrentSlot
function has formed a closure over the initial state variable values.As your state then changes in
TabProvider
, you create new "snapshots" and newsetCurrentSlotHelper
for each rerender, however, since youruseEffect()
function will not run again (since it only runs on initial mount due), the reference to the newsetCurrentSlotHelper
won’t reestablish, and instead, youruseEffect()
callback will continue to look at the initialsetCurrentSlotHelper
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 theonClick
and event-handler logic to the JSX rather than setting it up in youruseEffect()
. You may also find that addingsetCurrentSlot
(andsetSidebarComponents
) as dependencies to youruseEffect()
is another way around this (if you do that, then you would want to look at memoizing these functions withuseCallback()
).