Reputation: 581
I'm using Next.js and Typescript, but I don't think that's relevant to the problem here so I titled it as a general React problem. Also I've scoured Stack Overflow for hours on this, I'm certain it's not a duplicate of any other problems I've read pertaining to mine.
For context: I have a <Grid>
component that generates multiple <Cell>
components that holds images.
The premise is simple: cell gets clicked, modal opens and the user selects an image, then it updates the cell's image src prop.
Cell.tsx:
export default function Cell() {
const [src, setSrc] = useState<string>(`https://picsum.photos/seed/${Math.random()}/1000`);
const context: IModalContext = useContext(ModalContext);
function onSrcReceived(newSrc: string) {
setSrc(newSrc);
}
return (
<div
className={styles.cell}
onClick={() => {
context.openModal(onSrcReceived);
}}
>
{src && <Image layout="fill" quality={1} src={src} alt="random pic" />}
</div>
);
}
As you see, the cell calls the context's openModal() function, passing its onSrcReceived() callback as a prop.
ModalContext.tsx:
function ModalContextWrapper({ children }: Props) {
const [showModal, setShowModal] = useState<boolean>(false);
const [providedCallback, setProvidedCallback] = useState<Function>();
function onAlbumSelected(src: string) {
if (!providedCallback) throw new Error("Callback not provided!");
providedCallback(src);
}
const value: IModalContext = {
openModal: (_cb: Function) => {
setShowModal(true);
setProvidedCallback(() => _cb);
},
};
return (
<ModalContext.Provider value={value}>
<AlbumPickerModal
showModal={showModal}
setShowModal={setShowModal}
onAlbumSelected={onAlbumSelected}
/>
{children}
</ModalContext.Provider>
);
}
My ModalContext wraps the entire application. In summary of what it's doing: it receives the request to open the modal, then stores the cell's callback in local state. When the user selects an album cover from the <AlbumPickerModal>
, it fires the onAlbumSelected()
callback with the src as a prop.
The ModalContext's onAlbumSelected()
works as intended. It receives the src value and fires the cell's callback, stored in local state as providedCb
, with the new src as a prop. This all works correctly, but only for the first request. Every cell I attempt to update after that simply only updates the first updated cell. It's as though the setProvidedCb()
is only setting the callback ONCE, and not for any future updates.
I've been banging my head on this for hours. If I am implementing this modal input mechanism entirely wrong, please enlighten me of an easier solution. Thank you in advance!
Upvotes: 2
Views: 434
Reputation: 202618
It appears the modal is holding on to a stale providedCallback
state reference. Clicking the "search" button then selecting a new image seems to resolve the reference issue, I suspect due to a rerender occurring with "capturing" the new callback.
Instead of storing the passed callback in state which triggers a rerender, use a React ref instead.
ContextWrapper.tsx
function ModalContextWrapper({ children }: Props) {
const [showModal, setShowModal] = useState<boolean>(false);
const providedCallbackRef = useRef<Function>(); // (1) <-- React ref
function onAlbumSelected(src: string) {
if (!providedCallbackRef.current) { // (3a) <-- check ref
throw new Error("Callback not provided!");
}
providedCallbackRef.current(src); // (3b) <-- invoke callback
}
const value: IModalContext = {
openModal: (_cb: Function) => {
setShowModal(true);
providedCallbackRef.current = _cb; // (2) <-- save callback reference
}
};
return (
<ModalContext.Provider value={value}>
<AlbumPickerModal
showModal={showModal}
setShowModal={setShowModal}
onAlbumSelected={onAlbumSelected}
/>
{children}
</ModalContext.Provider>
);
}
I noticed in several of your components you are saving JSX into your state. This is an anti-pattern that often leads to stale enclosures of state, among other issues. State should only store the data and the rendered JSX should be derived from the state. In other words, the rendered UI is a function of state
in React.
Example with Grid
:
Current:
export default function Grid({ cols, rows }: IProps) {
const total = cols * rows;
const [cells, setCells] = useState<JSX.Element[]>([]);
useEffect(() => {
// get array of new cells
const newCells = [];
for (let i = 0; i < total; i++) {
const x = <Cell key={i} />;
newCells.push(x);
}
setCells(newCells);
}, [rows, cols]);
const gridStyle = {
display: "grid",
gridTemplateColumns: `repeat(${cols}, auto)`,
width: "20rem"
};
return <div style={gridStyle}> {cells} </div>;
}
Correct:
export default function Grid({ cols, rows }: IProps) {
const [cells, setCells] = useState<number[]>(
Array.from({ length: cols * rows }, (_, i) => i)
);
const gridStyle = {
display: "grid",
gridTemplateColumns: `repeat(${cols}, auto)`,
width: "20rem"
};
return (
<div style={gridStyle}>
{cells.map((i) => (
<Cell key={i} />
))}
</div>
);
}
It is entirely possible that your original providedCallback
state implementation was correct, but was getting messed up by the modal or some component above it being stored in a React state and resulting in a stale enclosure as I described.
Upvotes: 1