javascriptreactjscallbackstateclosures

Cannot get update of React state variable from callback function to actually change the state to trigger re-rendering


The main problem is that setting a state variable from a callback function does not seem to successfully actually change the state, so re-rendering is not triggered, and when re-rendering happens anyway, the state has not changed as expected.

It works for me as far as that the various callbacks seem to be called when (and only when) the buttons are clicked. Logging indicates that all is ok in that regard.

But when clicking the Cancel button (or the Confirm button) and calling setShowConfirmDialog(prev => false) the dialog is expected to vanish, but it remains.

Adding a useEffect simply in order to log the expected value change indicates that it is never changed. I believe that there is some issue related to closure, or a fundamental misunderstanding of some aspect of the React rendering mechanism, or how I must be accessing a view of the state that is "stale".

I have tried to specify the involved callback functions in a variety of ways in order to try to understand why it seems like my code refers to a "stale" state variable. To no avail. The code below is my current minimal exexample that illustrates the issue.

I've tried with useCallback, I've tried setShowConfirmDialog(false), I've tried passing the state variable as a prop. It is clear that I have not fully understood what goes on here.

There are many questions with similar content, but even from the ones that are answered I cannot extract what is going on here or how to change it to work.

The "parent" is here in page.js

"use client"
import { useEffect, useState } from "react";
import { DeletableRow, EditableCell, ReadOnlyCell, SelectableCell } from "./Components/EditableCell";

export default function Home() {

  const [usersData, setUsersData] = useState([]);

  useEffect(() => {
    const dbrows =
      [
        { id: 11, name: "Dotty", email: "dotty@mail.com" },
        { id: 22, name: "Adam", email: "adam@mail.com" }
      ];
    setUsersData(dbrows);
  }, []);

  function handleDeleteUserCB(id) { console.log("Placeholder for parent CB: handleDeleteCB: id=", id) };

  let rows = usersData.map((d, i) => {
    let row =
      <DeletableRow id={d.id}
        printName={`${d.id} ${d.name} ${d.email}`}
        type="User"
        key={"users__" + i}
        parentHandleDeleteCB={handleDeleteUserCB}>
        <ReadOnlyCell value={i} />
        <ReadOnlyCell value={d.id} />
        <EditableCell value={d.name} prefix="user__name__" />
        <EditableCell value={d.email} prefix="user__email__" />
        <ReadOnlyCell value={"Read-only text"} prefix="user__email__" />
      </DeletableRow >
    return row;
  });

  console.debug("rowsToShow=", rows);
  return (
    <>
      <h1>User list</h1>
      <table >
        <tbody>
          {rows}
        </tbody>
      </table>
    </>

  );
}

The display:

EditableCell.js

"use client"
import React, { useCallback, useEffect, useRef, useState } from "react";

export function DeletableRow({ printName, id, children, prefix, parentHandleDeleteCB, parentHandleUpdateCB }) {
    const [deleteInitiated, setDeleteInitiated] = useState(false);

    console.log("(Re)Rendering <tr>", { printName, id, children, prefix, parentHandleDeleteCB, parentHandleUpdateCB });
    return (
        <tr key={prefix + id} >
            {/* For convenience, pass some props to all children*/
                React.Children.map(children, (child) => (
                    React.cloneElement(child, { id, parentHandleUpdateCB })
                ))}

            <DeleteButtonCell id={id} printName={printName}
                parentHandleDeleteCB={parentHandleDeleteCB}
            />
        </tr>
    );
}


export function EditableCell({ id, prefix, value, parentHandleUpdateCB }) {
    return (
        <td id={prefix + id} contentEditable={true} style={{ border: "1px solid yellow", padding: "10px" }}
            title={`Click to change ${value} to another value."`}
            suppressContentEditableWarning={true} >
            {value}
        </td>
    )
}

export function ReadOnlyCell({ value }) {
    return (
        <td contentEditable={false} style={{ border: "1px solid grey", padding: "10px" }}
            title="Read only value">
            {value}
        </td>
    )
}


export function DeleteButtonCell({ printName, id, parentHandleDeleteCB }) {
    const [showConfirmDialog, setShowConfirmDialog] = useState(undefined);
    const [itemIdToDelete, setItemIdToDelete] = useState(null);

    const showConfirmDialogRef = useRef(undefined);

    console.log("inline showConfirmDialog=", showConfirmDialog);
    console.log("inline itemIdToDelete=", itemIdToDelete);

    useEffect(() => {
        console.log("useEffect showConfirmDialog=", showConfirmDialog);
        console.log("useEffect itemIdToDelete=", itemIdToDelete);

    }, [showConfirmDialog, itemIdToDelete]);

    const handleDeleteClick = useCallback((itemId) => {
        setItemIdToDelete(itemId);
        setShowConfirmDialog(prev => true);
    }, []);


    const confirmDeleteCB = useCallback((id, parentHandleDeleteCB) => {
        // Call the deletion callback in the parent component
        parentHandleDeleteCB(id);
        console.log("After parent call, which would have deleted id=", id, " Removing dialog.")
        // Deletion complete, remove the dialog.
        setShowConfirmDialog(prev => false)
        console.log("delete complete - dialog should close");

    }, [parentHandleDeleteCB]);

    const cancelDeleteCB = useCallback(() => {
        console.log("setShowConfirmDialog(false)");
        setShowConfirmDialog(prev => false),
            console.log("delete cancelled - dialog should close");
    }, []);

    console.log("Rendering DeleteButtonCell", { showConfirmDialog, printName, id });

    return (
        <td onClick={() => handleDeleteClick(id)}
            title={`Delete ${printName}?`}><button>&nbsp;❌&nbsp;</button>
            {showConfirmDialog && <DeleteConfirmationDialog
                // setShowConfirmDialog={setShowConfirmDialog}
                printName={printName} id={id}
                cancelCB={cancelDeleteCB}
                confirmCB={() => confirmDeleteCB(itemIdToDelete, parentHandleDeleteCB)} />
            }
        </td>)
}


export function DeleteConfirmationDialog({ printName, id, cancelCB, confirmCB, parentHandleDeleteCB }) {
    const onCancel = () => { console.log("onCancel"); cancelCB() };
    const onUserX = () => { console.log("onX"); cancelCB() };
    const onConfirm = () => { console.log("onConfirm"); confirmCB() };

    console.log("Rendering DeleteConfirmationDialog", { printName, id, cancelCB, confirmCB });
    console.log("printName=[", printName, "]");
    return (
        <div >
            <div style={{ border: "4px solid red" }} >
                <div title="Cancel - X" onClick={() => {
                    onUserX();
                }}>X</div>
                <h3 >Confirm Deletion of {printName} </h3>
                <p>Are you sure you want to delete <br /><strong>{printName}</strong>?</p>
                <button title="Confirm" onClick={onConfirm}>Confirm</button>
                <button title="Cancel" onClick={onCancel}>Cancel</button>
            </div>
        </div>

    )
}


Solution

  • It looks to be an event bubbling issue.

    You are rendering buttons as children of a td (<td onClick={() => handleDeleteClick(id)}) where both elements have onClick handlers that do opposing things (one sets to true the other false) this is causing the state updates to cancel out and therefore there is no re-render.

    Even though your buttons are in a Dialog, they are children to the table cell so they propagate up through it.

    One quick and dirty solution would be to stop the propagation on the buttons. For example your cancel function would be modified to accept the event like this (e) => {e.stopPropagation();...}.

    However, I believe the better solution would be to render your Dialog in an alternative way so that this is a non-issue, and make this pattern a habit for dialogs going forward.

    return (
      <>
        <td
          onClick={() => handleDeleteClick(id)}
          title={`Delete ${printName}?`}><button>&nbsp;❌&nbsp;</button>
        </td>
        {showConfirmDialog && <DeleteConfirmationDialog
           // setShowConfirmDialog={setShowConfirmDialog}
           printName={printName} id={id}
           cancelCB={cancelDeleteCB}
           confirmCB={() => confirmDeleteCB(itemIdToDelete, parentHandleDeleteCB)} 
          />
        }
      </>
    )
    

    Here is some relevant documentation from React on event propagation (https://react.dev/learn/responding-to-events#event-propagation)