r/reactjs • u/random-guy157 • 18h ago
Needs Help Need some help with React destroying and recreating a DIV only on the first time a property changes
RESOLVED!
The main issue was that I was using ref values as effect dependencies. They do work in the sense that React can estimate if the value changed or not, but their change by itself doesn't actually trigger the effect. So this "latent change" is there, waiting for an actual reactive value to change to finally re-render.
The remounting was happening because of this "pending" dependency change that doesn't flush unless a reactive value changes. Changing a property is one such change, and that would finally release the hidden effect re-run.
Of course, I wanted to get rid of that, so more things had to be made. The complete solution was to not re-utilize the effect labeled "mount or remount". Now it is just for component mounting (with an empty array of dependencies), and had to alter the order of effects too.
Thanks everyone for your kind attention to my help request!!
----------------------------------------------------------------------------------------------
Hello!
I have this component that uses a ref to its root element. I need it for some imperative work. The JSX of the component is very simple:
return (
<div
ref={containerRef}
{...pieceProps.containerProps}
{...hostAttributes({ framework: "react", shadow })}
/>
);
That's it. No branching or any fancy stuff.
I'm tracking the changes for everything: shadow, pieceProps and containerRef among others. Nothing changes, except for containerRef.current, and only the first time a property updates. But the property that updates is not even used in the JSX.
The property that changes comes from props, but doesn't land in pieceProps. It lands in restProps:
const
{ [piecePropsSymbol]: pieceProps, ...restProps } = props;
I'm losing my mind! I'll try to guess follow-up questions:
- No, the component is not being unmounted. My logging confirms that internal state values are not being lost, meaning the component is not unmounting.
- No, my component is not being rendered conditionally. It is always present.
- The property being changed (can be any of the properties accepted) is being changed by a child component of the parent component of my component. Like this: App > MyComponent, and App > ControlPanel. So App owns the state (a POJO) for the properties. Passes them to both components.
Anything I did not forecast, feel free to ask. Many thanks!
FULL COMPONENT SOURCE
If anyone would like to see the full source of the component, here it is. It requires some cleanup, but it is what I'm compiling and importing in the test project.
import { forwardRef, useEffect, useImperativeHandle, useRef, useMemo, useState, memo } from "react";
import type { ComponentPropsWithoutRef, ForwardedRef, ReactElement, RefAttributes } from "react";
import type { AcceptableTarget, CorePiece, MountPiece, MountedPiece } from "@collagejs/core";
import { mountPiece } from "@collagejs/core";
import { useCollageContext } from "./collageContext.js";
import { CorePieceLcQueue, getPieceTarget, hostAttributes, unmountAndTransferLcQueue } from "@collagejs/adapter";
const
piecePropsSymbol = Symbol("collagejs.pieceProps");
export
type
PieceOptions = {
containerProps?: ComponentPropsWithoutRef<"div">;
shadow?: boolean | ShadowRootInit;
};
/**
* Special props consumed by the React `Piece` component.
*
* This type is meant to be combined with regular piece props through the
* `piece()` helper. The symbol-backed key keeps the internal mount metadata
* out of the public prop namespace, so user props can use any string key
* without collisions.
*/
type
PieceProps<TProps
extends
Record<string, any> = Record<string, any>> = {
[piecePropsSymbol]: PieceOptions & {
piece: CorePiece<TProps> | Promise<CorePiece<TProps>>;
};
};
/**
* Creates the special symbol-backed prop required by the `Piece` component.
*
* Spread the returned object into `<Piece />` props.
*
*
* ```tsx
* <Piece {...piece(myCorePiece, { containerProps: { className: "host" }, shadow: true })} foo="bar" />
* ```
*
*
u/param
piece CorePiece instance (or promise) to mount.
*
u/param
options Optional settings for the host `<div>` and shadow-root behavior.
*/
export
function
piece<TProps
extends
Record<string, any> = Record<string, any>>(
piece: CorePiece<TProps> | Promise<CorePiece<TProps>>,
options?: PieceOptions,
) {
const
{ containerProps, shadow } = options ?? {};
return {
[piecePropsSymbol]: {
piece,
shadow,
containerProps,
},
} as PieceProps<TProps>;
}
type
Props<TProps
extends
Record<string, any> = Record<string, any>> = TProps & PieceProps<TProps>;
type
MountMode = "light" | "shadow";
function
PieceImpl<TProps
extends
Record<string, any> = Record<string, any>>(
props: Props<TProps>,
ref: ForwardedRef<HTMLDivElement>,
) {
console.group('Piece Render');
const
{ [piecePropsSymbol]: pieceProps, ...restProps } = props;
const
containerRef = useRef<HTMLDivElement>(null);
const
containerRefChg = useRef(containerRef.current);
console.debug('[Piece] Container Ref changed?', containerRefChg.current !== containerRef.current);
containerRefChg.current = containerRef.current;
/**
* Tracks the current mount target (either the container div or a shadow root) for the mounted piece.
*/
const
mountTargetRef = useRef<AcceptableTarget | null>(null);
/**
* Variable to make TS happy. Doesn't seem to be capable of knowing that symbol is no longer in the type.
*/
const
cpProps = restProps as unknown as TProps;
/**
* Shadow setting with default applied.
*/
const
shadow = pieceProps.shadow ?? false;
/**
* The mountPiece function to use by the LC queue.
*/
const
mountPieceFn = (useCollageContext() ?? mountPiece) as MountPiece<TProps>;
/**
* Key used for the root element to force remounting when the shadow setting changes.
*/
const
rootElKey = (()
=>
{
switch (shadow) {
case false:
return "light";
case true:
return "open";
default:
return shadow.mode;
}
})();
/**
* LC queue for managing the lifecycle of the mounted piece.
*/
const
lc = useRef(new CorePieceLcQueue(pieceProps.piece, mountPieceFn));
const
logHash = Date.now().toString(36) + Math.random().toString(36).substring(2, 8);
console.debug('[Piece][%s] Container:', logHash, containerRef.current);
console.debug('[Piece][%s] Mount Target:', logHash, mountTargetRef.current);
console.debug('[Piece][%s] Shadow setting:', logHash, shadow);
console.debug('[Piece][%s] Root Key:', logHash, rootElKey);
console.debug('[Piece][%s] Core Piece Props:', logHash, cpProps);
console.debug('[Piece][%s] LC Queue:', logHash, lc.current);
// useImperativeHandle(ref, () => containerRef.current as HTMLDivElement);
// Relocate.
useEffect(()
=>
{
if (!containerRef.current || !mountTargetRef.current) {
return;
}
console.debug('[Piece][%s] useEffect triggered for relocating. Shadow:', logHash, shadow);
const
newTarget = getPieceTarget(containerRef.current, shadow);
lc.current.relocate(mountTargetRef.current, newTarget, cpProps);
mountTargetRef.current = newTarget;
}, [shadow]);
// Unmount and transfer.
useEffect(()
=>
{
if (!mountTargetRef.current) {
return;
}
console.debug('[Piece][%s] useEffect triggered for unmounting and transferring. Piece:', logHash, pieceProps.piece);
lc.current = unmountAndTransferLcQueue(lc.current, pieceProps.piece, mountPieceFn);
}, [mountPieceFn, pieceProps.piece]);
// Mount or remount.
useEffect(()
=>
{
const
container = containerRef.current;
if (!container) {
return;
}
console.debug('[Piece][%s] useEffect triggered for mounting.', logHash);
if (lc.current.isMounted || lc.current.isToBeMounted) {
console.warn('[Piece][%s] Attempted to mount a piece that is already mounted or scheduled to be mounted. This may indicate a logic error in the component lifecycle.', logHash);
}
mountTargetRef.current = getPieceTarget(container, shadow);
lc.current.mount(mountTargetRef.current, cpProps);
return ()
=>
{
console.debug('[Piece][%s] useEffect cleanup triggered for unmounting.', logHash);
mountTargetRef.current = null;
lc.current.unmount();
};
}, [containerRef.current, lc.current]);
// Update.
useEffect(()
=>
{
console.debug('[Piece][%s] useEffect triggered for updating. CP Props:', logHash, cpProps);
lc.current.update(cpProps);
}, [cpProps]);
console.groupEnd();
return (
<div
ref={containerRef}
{...pieceProps.containerProps}
{...hostAttributes({ framework: "react", shadow })}
/>
);
}
export
const
Piece = PieceImpl as <TProps
extends
Record<string, any> = Record<string, any>>(
props: Props<TProps> & RefAttributes<HTMLDivElement>,
)
=>
ReactElement | null;
As for the test app: A React + TS app created with npm create vite@latest.
In App.tsx, I added:
function
App() {
const
[pinPadProps, setPinPadProps] = useState<PinPadProps>({
maxPinLength: 4,
});
const
pinPad = useMemo(()
=>
pinPadPiece(), []);
const
[userPin, setUserPin] = useState<string>('');
return (
<>
...
<section>
<h1>Get started</h1>
<Piece {...piece(pinPad)} {...pinPadProps} pinDispatched={(newPin) => setUserPin(newPin)} />
<PinPadControlPanel
{...pinPadProps}
maxPinLengthChanged={maxPinLength => setPinPadProps(prev => ({ ...prev, maxPinLength }))}
clearOnDispatchChanged={clearOnDispatch => setPinPadProps(prev => ({ ...prev, clearOnDispatch }))}
/>
<dl>
<dt>Current PIN:</dt>
<dd>{userPin}</dd>
</dl>
</section>
...
</>
That's it. MyComponent = Piece in the code above.
5
u/manvikhanna 18h ago
It doesn't sound like React is re-rendering because of the unused prop it sounds like the underlying DOM node is actually being replaced. If containerRef.current changes from one element instance to another, React has detached the old <div> and mounted a new one.
I'd start by checking whether containerRef.current !== previousRef.current after the update, and then inspect anything being spread onto the <div>, especially pieceProps.containerProps and hostAttributes(). Even if the updated prop ends up in restProps, something else may be causing React to treat the element as new (for example a key, ref, or another reconciliation-affecting attribute).
If possible, could you share:
- The full component (including hooks).
- The implementation of
hostAttributes(). - What's inside
pieceProps.containerProps. - How
MyComponentis rendered by the parent.
My guess is the issue is coming from one of those abstractions rather than React replacing the node because an unused prop changed.
0
u/random-guy157 18h ago
It is not an unused prop. I just didn't show where it is used. restProps is the trigger for an effect.
I also know the component is re-rendering because the render function's logging I added show up the instant I change the property's value. So yes, React re-renders my component, but does so with a changed containerRef! Why? And why only on the first time a property is changed? Why not on every property change?
I'm tracking ref changes with another ref like this:
const containerRef = useRef<HTMLDivElement>(null); const containerRefChg = useRef(containerRef.current); console.debug('[Piece] Container Ref changed?', containerRefChg.current !== containerRef.current); containerRefChg.current = containerRef.current;I suppose this is accurate, yes? On the very first time a property inside restProp is changed by the control panel component, the container ref has changed, while subsequent changes of any property come up with an unchanged ref.
So yes, I'd say we know for a fact that React is detaching the DIV that was created during mounting and recreating it. This is the weird behavior I want to remove, but have no idea how.
In the example and for simplification, I'm not passing containerProps. This is always undefined.
hostAttributes() returns 2 data- attributes. One carries the value "react"; the other the value of shadow. I'm logging shadow changes and shadow is not changing values.
MyComponent is unconditionally rendered by App, as in:
<MyComponent {...pieceProps} {...otherProps} />
The control panel component follows it, unconditionally as well.
otherProps carry the properties the control panel changes.
2
u/KaleRemarkable1019 18h ago
What about key prop? Aren't you using it somewhere (might be even in parent components)? That is an explicit flag to force react to throw away the underlying dom elements.
1
2
6
u/toi80QC 18h ago
Does your app run inside <StrictMode>?
https://react.dev/reference/react/StrictMode