I would like to forward some custom events from a parent React component to some children components, so that they can make a decision based on the event received without the parent having to directly manage the state of the children. This is a reduced example to show the idea that I was trying out.
ParentControl.tsx
export default function ParentControl() {
const divRef = useRef<HTMLDivElement>(null);
const dispatchEvent = (e: CustomEvent) => {
divRef.current.dispatchEvent(e);
};
return (
<div ref={divRef} >
<Child name="Child1" dispatcher={divRef} />
<Child name="Child2" dispatcher={divRef} />
<button onClick={() => dispatchEvent(new CustomEvent('prev', {detail: ''})) }>
Prev
</button>
<button onClick={() => dispatchEvent(new CustomEvent('next', {detail: ''})) }>
Next
</button>
</div>
);
}
Child.tsx
export type ChildProps = {
name: string;
dispatcher: RefObject<HTMLElement>;
};
export default function Child({ name, dispatcher }: ChildProps) {
const [index, setIndex] = useState(0);
const selectPrev = (event: CustomEvent) => {
console.log(`Received event: ${event.type}, index: ${index}`);
setIndex(index - 1);
};
const selectNext = (event: CustomEvent) => {
console.log(`Received event: ${event.type}, index: ${index}`);
setIndex(index + 1);
};
const printState = () => {
console.log(`printState(): index: ${index}`);
};
useEffect(() => {
const dispatch = dispatcher.current;
dispatch.addEventListener("next", selectNext);
dispatch.addEventListener("prev", selectPrev);
return () => {
dispatch.removeEventListener("next", selectNext);
dispatch.removeEventListener("prev", selectPrev);
};
}, []);
return (
<div>
<div>
{name} Index: {index}
</div>
<button onClick={() => printState()}>Print</button>
</div>
);
}
Running this I see that the selectNext()
and selectPrev()
methods are indeed called when the parent component dispatches an event, however these methods can only see the initial index state = 0 within the child, so they can only change the index to -1 or 1. How can these callbacks see the real current state of the child?
Is it possible to accomplish this somehow using events (that may later include multiple types) without having to use the React's Context API?
Your useEffect
deps is an empty array ([]
). It's generally an indication of a problem if a variable is referenced inside an effect and is not included in the deps array (selectNext
and selectPrev
). That's because any closures used inside the effect that aren't referenced in the deps can be stale and hold stale values. Currently, the selectPrev
and selectNext
from the initial render is held in memory, and those closures also reference the values as they were when the closure was created.
You can use the official ESLint plugin to catch these problems at compile time.
Your code can be fixed by adding them to the useEffect
deps. Note, since they would otherwise change on every render, I've also wrapped the methods in useCallback
and put index
in the deps array there. That means those closures are refreshed only when the index changes. This in turn will refresh the effect.
const selectPrev = useCallback(
(event: CustomEvent) => {
console.log(`Received event: ${event.type}, index: ${index}`);
setIndex(index - 1);
},
[index]
);
const selectNext = useCallback(
(event: CustomEvent) => {
console.log(`Received event: ${event.type}, index: ${index}`);
setIndex(index + 1);
},
[index]
);
const printState = () => {
console.log(`printState(): index: ${index}`);
};
useEffect(() => {
const dispatch = dispatcher.current;
dispatch.addEventListener("next", selectNext);
dispatch.addEventListener("prev", selectPrev);
return () => {
dispatch.removeEventListener("next", selectNext);
dispatch.removeEventListener("prev", selectPrev);
};
}, [selectPrev, selectNext]);
However this will deregister/register the event handlers each time. You can instead approach it by using the callback form of the state setter, which will always be supplied the latest value.
const selectPrev =
(event: CustomEvent) => {
console.log(`Received event: ${event.type}, index: ${index}`);
setIndex((index) => index - 1);
}
const selectNext =
(event: CustomEvent) => {
console.log(`Received event: ${event.type}, index: ${index}`);
setIndex((index) => index + 1);
}
const printState = () => {
console.log(`printState(): index: ${index}`);
};
useEffect(() => {
const dispatch = dispatcher.current;
// @ts-ignore
dispatch.addEventListener("next", selectNext);
// @ts-ignore
dispatch.addEventListener("prev", selectPrev);
return () => {
dispatch.removeEventListener("next", selectNext);
dispatch.removeEventListener("prev", selectPrev);
};
}, []);
However I have to stress what you are doing is considered a very odd pattern in React, for two primary reasons:
useImperativeHandle
.