cr4z
cr4z

Reputation: 581

Function stored as state in a context provider is only updating once

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

Answers (1)

Drew Reese
Drew Reese

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>
  );
}

Edit function-stored-as-state-in-a-context-provider-is-only-updating-once

Side Note:

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

Related Questions