Issue
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 (<></>)
};
Solution
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()
).
Answered By - Nick Parsons
0 comments:
Post a Comment
Note: Only a member of this blog may post a comment.