Search code examples
reactjsevents

Sending events in React from a parent to a child component


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?


Solution

  • 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:

    • The general goal of wanting components to manage their own state, but sync that state based on control logic in some parent is usually solved by hoisting the state. That is the most idiomatic solution. It would be good to know about your use case and driver for not doing that. Possibly there is a legit reason, hence the "usually". This doesn't necessarily need to be context. You could just have a custom hook that has the state in and have that provide the props to spread to each child. This answer can be improved drastically if we can understand the reasoning behind needing this mechanism.
    • Dispatching events on the DOM element is a bit of an abuse of that mechanism. That generally isn't supposed to be used as a "free event bus". For one, it has semantics/heuristics that don't make sense as a general purpose event bus. You should probably pass down a simple event bus impl that has nothing to do with the DOM. Also, what you are trying to do might be better served by useImperativeHandle.