3

So I am aware there are many similar questions on here however all the solutions involve using useRef which I would rather not do.

I have the following situation (which I have simplified):

const Parent = () => {

    return (
        <div>
            <button>Click</button>
            <Child>
        </div>
    )
};

.

const Child = () => {
    const doThing = () => {
        console.log("I ran")
    }

    return (
        <div></div>
    )
};

In reality, my Parent Component contains a button and my child component contains folders and within those folders I have files. I want to collapse all of those folders by clicking on the Button within the parent. (Also with the way the components are laid out it wouldn't make sense to move the button into the child)

To achieve this without refs I know I can do the following:

const Parent = () => {
    const [wasTriggered, setWasTriggered] = useState(false)

    const clickFunction = () => {
        setWasTriggered(true)
    }

    const changeBackToFalse = () => {
        setWasTriggered(false)
    }

    return (
        <div>
            <button onClick={clickFunction} >Click</button>
            <Child wasTriggered = {wasTriggered} changeBackToFalse={changeBackToFalse}>
        </div>
    )
};

.

const Child = ({wasTriggered, changeBackToFalse}) => {
    const doThing = () => {
        console.log("I ran")
    }

    useEffect(()=>{
        if (wasTriggered) {
            doThing()
            changeBackToFalse()
        }
    },[wasTriggered])

    return (
        <div></div>
    )
};

But this is tedious and seems like I'm passing stuff back on forth simply to achieve what I want.

What I want is some way of triggering the method within the child from the parent. I apologise if this is either incredibly simple or impossible but with my limited knowledge of React, I'm not sure which it is, Thanks.

Oliver Brace
  • 393
  • 1
  • 4
  • 21
  • So, you don't want to use `ref` and pass the state from parent to child as a prop? – Lin Du Oct 19 '21 at 03:30
  • In essence yes. Ideally, I want to pass a prop that says `doThing` from the parent which then runs the `doThing` function in the child. – Oliver Brace Oct 19 '21 at 08:35
  • Without the change back to False then the value remains True. Because of this if I try to retrigger the event it won't happen as the useEffect won't get a "new" value. Therefor I need the value to go back to False so that I may retrigger it. – Oliver Brace Oct 25 '21 at 08:37
  • FYI: https://stackoverflow.com/questions/37949981/call-child-method-from-parent?rq=1 – Giorgi Moniava Oct 25 '21 at 20:13

5 Answers5

1

TLDR: if you have time, refactor Children components and lift their state up otherwise your solution is ok


Details

This feels tedious precisely because it goes against the "React" way of doing things. Props to you for seeing that something is wrong with the current approach :)

The state dictates everything in React. If you are unable to access the state to modify it must be lifted up so that you can modify it.

I assume that Child Folder and File components contain some kind of state. Before the change with the Parent button, we had a "single source of truth" (local state of components). After the change we now have 2 states which need to be kept in sync (and sync is not ideal):

  • parent flag must dictate children state
  • on children state change we must reset the flag

I am sorry, but the only "good" (in a long term) solution is to make Children components controlled and handle the state in the Parent.

If you can't afford to refactor your Children components then your solution is the next best thing.

1

Possible solutions include,

  1. Using reference of the child component and invoking the required method from the parent component (Ideal solution)
  2. Sending props as you have mentioned (Would be tedious)

Both these solutions work for your scenario and work in a React way. If you don't want to use these solutions, then there is another which is not exactly in a React way, rather in a normal web application way. We can use Window Custom Events

Parent component would dispatch Custom Events and Child component would listen to that event and perform actions on it

Child Component

import { useEffect } from "react";

const Child = (props) => {
  useEffect(() => {
    document.addEventListener('trigger_child', (e) => doThing(e.detail)); //adding event listener when the component mounts. When dispatch is called, the doThing method gets called.

    return () => {
      document.removeEventListener('trigger_child', doThing);
    }; //removing the listener when the component unmounts.
  }, []);

  const doThing = (data) => {
    console.log(data); //process using the data sent by the parent component.
  };

  return (
    <div>
      Child
    </div>
  );
};

export default Child;

Parent Component

const Parent = (props) => {
  const handleButtonClick = () => {
    document.dispatchEvent(new CustomEvent('on_trigger_child', { detail: {  } })); //dispatch an event on button click. Send optional data along with the event. Child component will receive the data and process it.
  };

  return (
    <div>
      <button onClick={handleButtonClick}>Trigger</button>
      <p>Parent</p>
    </div>
  );
};

export default Parent;

Note: You can write a wrapper around for those document accessing methods and expose those wrapper methods to your components

Links:

https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/removeEventListener https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/dispatchEvent https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent/CustomEvent

0

A solution that I found (SO answer) is to change the component's key attribute. useEffect() is called only on component load and any dynamic changed from Parent won't be reflected in Child component until we change key attribute.

Sandbox example

Parent.js

import { useState } from "react";
import Child from "./Child";

const Parent = () => {
  const [wasTriggered, setWasTriggered] = useState(false);

  return (
    <div>
      <button onClick={() => setWasTriggered(false)}>Set FALSE</button>
      <Child wasTriggered={wasTriggered} />
      <Child wasTriggered={wasTriggered} />
      <Child wasTriggered={wasTriggered} />
      <button onClick={() => setWasTriggered(true)}>Set TRUE</button>
    </div>
  );
};

export default Parent;

Child.js

import { useEffect } from "react";

const Child = (props) => {
  const doThing = () => {
    console.log("I ran");
  };

  useEffect(() => {
    doThing();
  }, []);

  return (
    <div
      key={props.wasTriggered}
      style={{ color: props.wasTriggered ? "red" : "green" }}
    >
      Child
    </div>
  );
};

export default Child;
Getsumi3
  • 211
  • 2
  • 10
0

Call child method from parent has a solution that does everything I want.

@Lukáš Novák give the answer that I'm most happy with (a few other people suggesting it but it's the first one I saw)

It's possible to save functions in useStates.

So I can do the following:

Parent Component

const Parent = () => {
    const [theSavedFunction, setTheSavedFunction] = useState()

    return (
        <div>
            <button onClick={theSavedFunction} >Click</button>
            <Child savedFunction={(f) => {setTheSavedFunction(f)}}>
        </div>
    )
};
.

Child Component

const Child = ({savedFunction}) => {
    const doThing = () => {
        console.log("I ran")
    }

    useEffect(() => {
        savedFunction(() => doThing);
    }, []);


    return (
        <div></div>
    )
};

Now when I click the button it triggers the doThing function. (If doThing contains props that might change then they needed to be added in the useEffect)

It is possible to pass variables to the function by changing:

savedFunction(() => doThing);

to

savedFunction(() => (thing) => doThing(thing));

If anyone knows why this wasn't suggested or if this is bad practice please let me know.

Oliver Brace
  • 393
  • 1
  • 4
  • 21
-1

You can use context to solve that problem.

The core concept was to create an event-emitter or something subscribable and provide the manager through React.Context and Child components subscribe to that manager.

Note: my examples were all in Typescript.

import React, { createContext, useCallback, useContext, useEffect, useMemo } from 'react';

interface Emitter {
  emit: (type: string) => void;
  // returns unsubscribe fn
  on: (type: string, handler: () => void) => () => void;
}

const Context = createContext<Emitter>({} as any);

const Parent = () => {
  const emitter = useMemo<Emitter>(() => {
    // implement emitter here. Or just use https://github.com/primus/eventemitter3
    return {} as any;
  }, []);
  return (
    <Context.Provider value={emitter}>
      <button onClick={() => emitter.emit('trigger')}>trigger</button>
      <Child />
    </Context.Provider>
  );
};

const Child = () => {
  const ctx = useContext(Context);
  const doThing = useCallback(() => {}, []);
  useEffect(() => {
    return ctx.on('trigger', doThing);
  }, [doThing, ctx]);
  return null;
};

But:

  1. I strongly recommend you not do such thing as "trigger an event from parent to child", instead, I recommend you to lift the logic up from child to parent. (the reason was the same as why you should avoid useImperativeHandle unless necessary, you can easily find the answer everywhere)
  2. I don't recommend using useMemo to keep an instance, because react might forget what has been memoed, according to the official doc https://reactjs.org/docs/hooks-faq.html#how-to-memoize-calculations.

If you really want to create an "instance" in Component, try this:

export function useFactory<T>(factory: () => T, deps: any[] = []): T {
  const prevDepsRef = useRef<any[]>(null as any);
  const valueRef = useRef<T>(null as any);
  const depsChanged = prevDepsRef.current === null || isDepsChanged(prevDepsRef.current, deps);
  if (depsChanged) {
    valueRef.current = factory();
  }
  prevDepsRef.current = deps;
  return valueRef.current;
}

function isDepsChanged(a: any[] = [], b: any[] = []): boolean {
  if (!a || !b) {
    return true;
  }
  if (a.length !== b.length) {
    return true;
  }
  if (a.length === 0 && b.length === 0) {
    return false;
  }
  let [left, right] = [a, b];
  if (left.length < right.length) {
    [left, right] = [right, left];
  }
  return left.some((item, idx) => item !== right[idx]);
}
user5474476
  • 141
  • 9