davidkomer
davidkomer

Reputation: 3098

Call setState outside of React

The following works as of React v18, but is it guaranteed to work correctly by React semantics?

The "rules of hooks" only talk about calling the hook itself in the component, there's no mention of whether or not it's okay to call the dispatcher returned from the useState hook outside of React

Note that the dispatcher here is setting the state itself, it is not taking a lazily evaluated function, in case that matters.

codesandbox

let dispatcher = null 

// Rendered via React
function MyComponent() {
  const [state, setState] = useState(1);

  useEffect(() => {
    // set the global var to this components setState
    dispatcher = setState;
    return () => {
      // on unmount, reset the global var to null
      dispatcher = null;
    }
  }, [setState]);

  return (
    <div className="App">
      <div>Count: {state}</div>
    </div>
  );
}

// Rendered outside of React
const elem = document.getElementById("button");
let clickCount = 1;
elem.onclick = () => {
  if(dispatcher) {
    // call the global var which is the React component's setState()
    dispatcher(++clickCount);
  }
};

Upvotes: 1

Views: 585

Answers (1)

Ori Drori
Ori Drori

Reputation: 192607

As suggest by vighnesh153's comment, you should use a pub/sub system, and register inside the component to the event.

This example dispatches the native browser CustomEvent with the current count on the document. The component listens to the event, and sets the state as needed:

const { useState, useEffect } = React;

const Demo = () => {
  const [state, setState] = useState(1);

  useEffect(() => {
    const handleEvent = e => setState(e.detail);
  
    document.addEventListener('setReactState', handleEvent);
    return () => {
      document.removeEventListener('setReactState', handleEvent);
    }
  }, []);

  return (
    <div className="App">
      <div>Count: {state}</div>
    </div>
  );
}

// Rendered outside of React
let clickCount = 1;
document
  .getElementById('inc')
  .addEventListener('click', () => {
    document.dispatchEvent(
      new CustomEvent('setReactState', { detail: ++clickCount })
    );
  });
  
ReactDOM
  .createRoot(root)
  .render(<Demo />);
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>

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

<hr>

<button id="inc">Increment from outside</div>

The main problem with your code is that you can't really reset (clear) from the global state, because the reference can be copied.

In the example, I'm assigning the reference in dispatcher to dispatcher2 after 1 second, and I clear dispatcher after 2. Wait for the "dispatcher cleared" message, and click the "Increment from outside" button, and it would still work.

const { useState, useEffect } = React;

let dispatcher = null;

const Demo = () => {
  const [state, setState] = useState(1);

  useEffect(() => {
    // set the global var to this components setState
    dispatcher = setState;
    
    setTimeout(() => {
      dispatcher = null;
      
      console.log('dispatcher cleared');
    }, 2000);
    
    return () => {
      // on unmount, reset the global var to null
      dispatcher = null;
    }
  }, [setState]);

  return (
    <div className="App">
      <div>Count: {state}</div>
    </div>
  );
}

// Rendered outside of React

setTimeout(() => {
  const dispatcher2 = dispatcher;
  let clickCount = 1;
  document
    .getElementById('inc')
    .addEventListener('click', () => {
      dispatcher2(++clickCount);
    });
}, 1000);
  
ReactDOM
  .createRoot(root)
  .render(<Demo />);
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>

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

<hr>

<button id="inc">Increment from outside</div>

Upvotes: 2

Related Questions