JBaczuk
JBaczuk

Reputation: 14619

How to change child state from another child without re-rendering parent

I want to change the content of a child component in response to a user event in another child component without causing the parent to re-render.

I've tried storing the child state setter to a variable in the parent but it is not defined by the time the children have all rendered, so it doesn't work.

Is there a minimal way to accomplish this (without installing a state management library)?

ChildToChild.tsx

import React, {
  Dispatch,
  SetStateAction,
  useEffect,
  useRef,
  useState,
} from "react";

export default function ChildToChild() {
  const renderCounter = useRef(0);
  renderCounter.current = renderCounter.current + 1;

  let setChildOneContent;
  const childOneContentController = (
    setter: Dispatch<SetStateAction<string>>
  ) => {
    setChildOneContent = setter;
  };

  return (
    <div>
      <h1>Don't re-render me please</h1>
      <p>No. Renders: {renderCounter.current}</p>
      <ChildOne childOneContentController={childOneContentController} />
      <ChildTwo setChildOneContent={setChildOneContent} />
    </div>
  );
}

function ChildOne({
  childOneContentController,
}: {
  childOneContentController: (setter: Dispatch<SetStateAction<string>>) => void;
}) {
  const [content, setContent] = useState("original content");

  useEffect(() => {
    childOneContentController(setContent);
  }, [childOneContentController, setContent]);

  return (
    <div>
      <h2>Child One</h2>
      <p>{content}</p>
    </div>
  );
}

function ChildTwo({
  setChildOneContent,
}: {
  setChildOneContent: Dispatch<SetStateAction<string>> | undefined;
}) {
  return (
    <div>
      <h2>Child Two</h2>
      <button
        onClick={() => {
          if (setChildOneContent) setChildOneContent("content changed");
        }}
      >
        Change Child One Content
      </button>
    </div>
  );
}

Upvotes: 2

Views: 1694

Answers (5)

Snowbldr
Snowbldr

Reputation: 786

You can create an object, store the hook on that object, and use the hook from the object on the children. Not sure why the output count increments twice...

import { useState } from "react";

let parentRenderCount = 0;
let child1RenderCount = 0;
let child2RenderCount = 0;

const Child1 = ( { stateHolder } ) => {
    const [count, setCount] = useState( 0 )
    console.log('child1 render', ++child1RenderCount)
    stateHolder.count = count
    stateHolder.setCount = setCount
    return <div>
        {count}
    </div>
}

const Child2 = ( { stateHolder } ) => {
    console.log('child2 render', ++child2RenderCount)
    return <button onClick={() => stateHolder.setCount( stateHolder.count + 1 )}>
        clickme
    </button>
}


function App() {
    const stateHolder = { }
    console.log('parent render', ++parentRenderCount)
    return (
        <div className="App">
            <Child1 stateHolder={stateHolder} />
            <Child2 stateHolder={stateHolder} />
        </div>
    );
}

export default App;

Upvotes: 1

jsejcksn
jsejcksn

Reputation: 33749

You can store the Dispatch<SetStateAction<T>> function in a ref in the parent:

import ReactDOM from 'react-dom';

import {
  Dispatch,
  MutableRefObject,
  ReactElement,
  SetStateAction,
  useEffect,
  useRef,
  useState,
} from 'react';

type ChildProps = {
  setValueRef: MutableRefObject<Dispatch<SetStateAction<string>> | undefined>;
};

function Child1 (props: ChildProps): ReactElement {
  const [dateString, setDateString] = useState(new Date().toISOString());

  useEffect(() => {
    props.setValueRef.current = setDateString;
  }, [props.setValueRef, setDateString]);

  return <div>{dateString}</div>;
}

function Child2 (props: ChildProps): ReactElement {
  const handleClick = (): void => {
    props.setValueRef.current?.(new Date().toISOString());
  };

  return <button onClick={handleClick}>Update</button>;
}

function Parent (): ReactElement {
  const setValueRef = useRef<Dispatch<SetStateAction<string>> | undefined>();
  const renderCountRef = useRef(0);
  renderCountRef.current += 1;

  return (
    <div>
      <div>Render count: {renderCountRef.current}</div>
      <Child1 {...{setValueRef}} />
      <Child2 {...{setValueRef}} />
    </div>
  );
}

ReactDOM.render(<Parent />, document.getElementById('root'));

Here's the same code in a runnable snippet (with types erased and import statements converted to use the UMD version of React):

<script src="https://unpkg.com/[email protected]/umd/react.development.js"></script>
<script src="https://unpkg.com/[email protected]/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/[email protected]/babel.min.js"></script>

<div id="root"></div>

<script type="text/babel" data-type="module" data-presets="react">

const {useEffect, useRef, useState} = React;

function Child1 (props) {
  const [dateString, setDateString] = useState(new Date().toISOString());

  useEffect(() => {
    props.setValueRef.current = setDateString;
  }, [props.setValueRef, setDateString]);

  return <div>{dateString}</div>;
}

function Child2 (props) {
  const handleClick = () => {
    props.setValueRef.current?.(new Date().toISOString());
  };

  return <button onClick={handleClick}>Update</button>;
}

function Parent () {
  const setValueRef = useRef();
  const renderCountRef = useRef(0);
  renderCountRef.current += 1;

  return (
    <div>
      <div>Render count: {renderCountRef.current}</div>
      <Child1 {...{setValueRef}} />
      <Child2 {...{setValueRef}} />
    </div>
  );
}

ReactDOM.render(<Parent />, document.getElementById('root'));

</script>

That being said, this is a strange pattern, and the scenario can be solved idiomatically using React.memo (this is the case it's designed for).

Upvotes: 0

Lubomir Jurecka
Lubomir Jurecka

Reputation: 336

The event could be propagated over the RxJS Subject, with value if it is needed.

// in parent

// const subject = new rxjs.Subject();
const subject = { // or create lightweight alternative
  nextHandlers: [],
  next: function(value) {
    this.nextHandlers.forEach(handler => handler(value));
  },
  subscribe: function(nextHandler) {
    this.nextHandlers.push(nextHandler);
    return { 
      unsubscribe: () => {
        this.nextHandlers.splice(
          this.nextHandlers.indexOf(nextHandler),
          1
        );
      }
    };
  }
};

// provide next method to the childOne
onClick={value => subject.next(value)}

// provide subscribe method to the childTwo
subscribeOnClick={(nextHandler) => subject.subscribe(nextHandler)}

// in childTwo
useEffect(() => {
  const subscription = subscribeOnClick(value => setContent(value));
  return () => subscription.unsubscribe();
}, [subscribeOnClick]);

Upvotes: 0

sid
sid

Reputation: 2027

You should be able to achieve by

  1. Using state in child components
  2. Using a callback function in the parent component that calls the setState function of the child component. This will trigger re-render of the child but not of itself (parent).

Demo: https://codesandbox.io/s/child-re-rendering-only-x37ol

Upvotes: 3

ejose19
ejose19

Reputation: 599

For an one-off, you could use the following (another ref to store & use the ChildOne setContent:

import React, {
  Dispatch,
  SetStateAction,
  useEffect,
  useRef,
  useState
} from "react";

function ChildOne({
  setChildOneRef
}: {
  setChildOneRef: React.MutableRefObject<React.Dispatch<
    React.SetStateAction<string>
  > | null>;
}) {
  const [content, setContent] = useState("original content");

  useEffect(() => {
    setChildOneRef.current = setContent;
  }, [setChildOneRef]);

  return (
    <div>
      <h2>Child One</h2>
      <p>{content}</p>
    </div>
  );
}

function ChildTwo({
  setChildOneRef
}: {
  setChildOneRef: React.MutableRefObject<React.Dispatch<
    React.SetStateAction<string>
  > | null>;
}) {
  return (
    <div>
      <h2>Child Two</h2>
      <button
        onClick={() => {
          setChildOneRef.current?.("content changed");
        }}
      >
        Change Child One Content
      </button>
    </div>
  );
}

export function ChildToChild() {
  const renderCounter = useRef(0);
  renderCounter.current = renderCounter.current + 1;

  const setChildOneRef = useRef<Dispatch<SetStateAction<string>> | null>(null);
  return (
    <div>
      <h1>Don't re-render me please</h1>
      <p>No. Renders: {renderCounter.current}</p>
      <ChildOne setChildOneRef={setChildOneRef} />
      <ChildTwo setChildOneRef={setChildOneRef} />
    </div>
  );
}

If this pattern is common in your code, you may still want to use state management library or evaluate if "childs" should be really separated.

Upvotes: 1

Related Questions