reactjs

State variable not getting newest state in a component function


I have a simple component with a state variable openInNewWindow. As a default value it is set to false:

const [openInNewWindow, setOpenInNewWindow] = useState<boolean>(false);

I have a function where I update it to true:

const onOpenInNewWindow = () => {
        if (openInNewWindow) return;

        setOpenInNewWindow(true);
        console.log("onOpen - openInNewWindow", openInNewWindow);

        window.open(
            `${window.location.pathname}editor/${broadcastChannelUUID}?value=${encodeURIComponent(reformatText(value))}&label=${label}${description ? `&description=${description}` : ""}`,
            "_blank",
            "width=800,height=700,left=200,top=200"
        );
    };

Here it logs false for the console.log("onOpen - openInNewWindow", openInNewWindow);, but it logs true outside of the function after it was called. The problem I have is that after I update the state variable, I do not get the newest variable state in function onTextChange:

const onTextChange = (text: string) => {
    onChange?.(text);

    console.log("onTextChange openInNewWindow", openInNewWindow);
    if (openInNewWindow) {
        channel.postMessage({ action: "textChange", value: reformatText(text) });
    }
};

Even though if I log the value of it in the component it shows true for it, in the function when it is called it shows false for it. Here is the whole component:

export function CustomTextareaEditor({
    name,
    value,
    label,
    description,
    className,
    resize,
    readOnly,
    onChange,
    withOpenInNewWindow,
}: {
    name;
    value?: string;
    label?: string;
    description?: string;
    className?: string;
    resize?: boolean;
    readOnly?: boolean;
    onChange?: (html: string) => void;
    withOpenInNewWindow?: boolean;
}) {
    const [openInNewWindow, setOpenInNewWindow] = useState<boolean>(false);
    const quillRef = useRef(null);
    const broadcastChannelUUID = useMemo(() => crypto.randomUUID(), []);
    const channel = useMemo(() => new BroadcastChannel(broadcastChannelUUID), []);

    console.log("openInNewWindow in component", openInNewWindow);

    useEffect(() => {
        const handleMessage = (event) => {
            switch (event.data.action) {
                case "textChange":
                    onChange?.(event.data.value);
                    break;
                case "componentUnmounted":
                    setOpenInNewWindow(false);
                    break;
            }
        };
        channel.onmessage = handleMessage;

        return () => channel.postMessage({ action: "componentUnmounted" });
    }, []);

    const onTextChange = (text: string) => {
        onChange?.(text);

        console.log("onTextChange openInNewWindow", openInNewWindow);
        if (openInNewWindow) {
            channel.postMessage({ action: "textChange", value: reformatText(text) });
        }
    };

    const reformatText = (text?: string) => {
        return text?.replace(new RegExp(String.fromCharCode(10), "g"), "<br>");
    };

    const onOpenInNewWindow = () => {
        if (openInNewWindow) return;

        setOpenInNewWindow(true);
        console.log("onOpen - openInNewWindow", openInNewWindow);
        window.open(
            `${window.location.pathname}begrunnelse/${broadcastChannelUUID}?value=${encodeURIComponent(reformatText(value))}&label=${label}${description ? `&description=${description}` : ""}`,
            "_blank",
            "width=800,height=700,left=200,top=200"
        );
    };

    return (
        <>
            <BodyLong size="small" as="div" className={className}>
                {label && (
                    <Label className="flex items-center gap-2" spacing size="small" htmlFor={name}>
                        {readOnly && <PadlockLockedFillIcon />} {label}{" "}
                        {!withOpenInNewWindow && (
                            <Button
                                size="xsmall"
                                variant="tertiary-neutral"
                                icon={<ExpandIcon title="Ny fane" />}
                                onClick={() => onOpenInNewWindow()}
                                type="button"
                            />
                        )}
                    </Label>
                )}

                {description && (
                    <BodyShort spacing textColor="subtle" size="small" className="max-w-[500px] mt-[-0.375rem]">
                        {description}
                    </BodyShort>
                )}
                <CustomQuillEditor
                    ref={quillRef}
                    resize={resize}
                    readOnly={readOnly}
                    defaultValue={reformatText(value)}
                    onTextChange={onTextChange}
                />
            </BodyLong>
        </>
    );
}

Not sure why I still get the old value for openInNewWindow in the function onTextChange, even after it gets updated?

Even if I wrap both onOpenInNewWindow and onTextChange with useCallback and add a dependency openInNewWindow, I get old value for openInNewWindow in onTextChange when it is called.

const onTextChange = useCallback(
    (text: string) => {
        onChange?.(text);

        console.log("onTextChange openInNewWindow", openInNewWindow);
        if (openInNewWindow) {
            channel.postMessage({ action: "textChange", value: reformatText(text) });
        }
    },
    [openInNewWindow]
);

const onOpenInNewWindow = useCallback(() => {
    if (openInNewWindow) return;

    setOpenInNewWindow(true);
    console.log("onOpen - openInNewWindow", openInNewWindow);
    window.open(
        `${window.location.pathname}begrunnelse/${broadcastChannelUUID}?value=${encodeURIComponent(reformatText(value))}&label=${label}${description ? `&description=${description}` : ""}`,
        "_blank",
        "width=800,height=700,left=200,top=200"
    );
}, [openInNewWindow]);

Here is the codesandox with all the code.

You can see that in CustomTextareaEditor component if you start writing text in the editor, onTextChange is called, and the variable openInNewWindow is still false, even after you open a new window by clicking on button with ExpandIcon. Not sure why editor is not rendered in the new window here in codesandbox, but locally it is.


Solution

  • The issue seems to be in the CustomQuillEditor.tsx, it's how closures work.

    If you add the onTextChange prop as a dependency to the useEffect that uses it it will work as expected, but this is NOT a good solution as it will recreate the Quill editor every time the effect runs.

    The way I see it, you can have an useEffect to initialize Quill and another one to set the on handler and add the onTextChange prop as a dependency to it. And don't forget to do quill.off in the cleanup function;

    So, to recap:

    1. Go to CustomQuillEditor.tsx
    2. Add a useEffect to initialize Quill - you'll probably want to store it in a useState rather than useRef, to avoid race conditions in the next effect;
    3. Add another useEffect that will add the on handler, with the onTextChange as a dependency - and quill.off as a cleanup function;
    4. Make sure to wrap onTextChange in a use callback;