Home > Software design >  Value of variable outside of useEffect hook has old data
Value of variable outside of useEffect hook has old data

Time:12-19

What the code does: It's performing a DOM search based on what's typed in an input (it's searching elements by text). All this is happening in a React component.

import { useEffect, useReducer } from "react";
let elements: any[] = [];

const App = () => {
  const initialState = { keyEvent: {}, value: "Initial state" };
  const [state, updateState] = useReducer(
    (state: any, updates: any) => ({ ...state, ...updates }),
    initialState
  );

  function handleInputChange(event: any) {
    updateState({ value: event.target.value });
  }

  function isCommand(event: KeyboardEvent) {
    return event.ctrlKey;
  }

  function handleDocumentKeyDown(event: any) {
    if (isCommand(event)) {
      updateState({ keyEvent: event });
    }
  }

  useEffect(() => {
    document.addEventListener("keydown", handleDocumentKeyDown);

    return () => {
      document.removeEventListener("keydown", handleDocumentKeyDown);
    };
  }, []);

  useEffect(() => {
    const selectors = "button";

    const pattern = new RegExp(state.value === "" ? "^$" : state.value);

    elements = Array.from(document.querySelectorAll(selectors)).filter(
      (element) => {
        if (element.childNodes) {
          const nodeWithText = Array.from(element.childNodes).find(
            (childNode) => childNode.nodeType === Node.TEXT_NODE
          );

          if (nodeWithText) {
            // The delay won't happenn if you comment out this conditional statement:
            if (nodeWithText.textContent?.match(pattern)) {
              return element;
            }
          }
        }
      }
    );

    console.log('elements 1:', elements)
  }, [state]);

  console.log('elemets 2:', elements)

  return (
    <div>
      <input
        id="input"
        type="text"
        onChange={handleInputChange}
        value={state.value}
      />
      <div id="count">{elements.length}</div>
      <button>a</button>
      <button>b</button>
      <button>c</button>
    </div>
  );
};

export default App;

The problem: The value of elements outside of useEffect is the old data. For example, if you type a in the input, console.log('elements 1:', elements) will log 1, and console.log('elements 2:', elements) will log 0. Note: there are 3 buttons, and one of them has the text a.

The strange thing is that the problem doesn't happen if you comment out this if-statement:

// The delay won't happenn if you comment out this conditional statement:
if (nodeWithText.textContent?.match(pattern)) {
  return element;
}

In this case, if you type anything (since the pattern matching has been commented out), console.log('elements 1:', elements) and console.log('elements 2:', elements) will log 3. Note: there are 3 buttons.

Question: What could be the problem, and how to fix it? I want to render the current length of elements.

Live code:

Edit useState same value rerender (forked)

CodePudding user response:

It's happening because of the elements variable is not a state, so it's not reactive. Create a state for the elements:

  const [elements, setElements] = useState<HTMLButtonElement[]>([])

And use this state to handle the elements.

import { useEffect, useReducer, useState } from "react";

const App = () => {
  const initialState = { keyEvent: {}, value: "Initial state" };
  const [state, updateState] = useReducer(
    (state: any, updates: any) => ({ ...state, ...updates }),
    initialState
  );
  const [elements, setElements] = useState<HTMLButtonElement[]>([])

  function handleInputChange(event: any) {
    updateState({ value: event.target.value });
  }

  function isCommand(event: KeyboardEvent) {
    return event.ctrlKey;
  }

  function handleDocumentKeyDown(event: any) {
    if (isCommand(event)) {
      updateState({ keyEvent: event });
    }
  }

  useEffect(() => {
    document.addEventListener("keydown", handleDocumentKeyDown);

    return () => {
      document.removeEventListener("keydown", handleDocumentKeyDown);
    };
  }, []);

  useEffect(() => {
    const selectors = "button";

    const pattern = new RegExp(state.value === "" ? "^$" : state.value);

    let newElements = Array.from(document.querySelectorAll(selectors)).filter(
      (element) => {
        if (element.childNodes) {
          const nodeWithText = Array.from(element.childNodes).find(
            (childNode) => childNode.nodeType === Node.TEXT_NODE
          );

          if (nodeWithText) {
            // The delay won't happenn if you comment out this conditional statement:
            if (nodeWithText.textContent?.match(pattern)) {
              return element;
            }
          }
        }
      }
    );
    setElements(newElements)

    console.log("elements 1:", elements?.length);
  }, [state]);

  console.log("elemets 2:", elements?.length);

  return (
    <div>
      <input
        id="input"
        type="text"
        onChange={handleInputChange}
        value={state.value}
      />
      <div id="count">{elements?.length}</div>
      <button>a</button>
      <button>b</button>
      <button>c</button>
    </div>
  );
};

export default App;

CodePudding user response:

useEffect triggered after react completed its render phase & flush the new changes to the DOM.

In your case you have two useEffects. The first one register your event lister which will then update your component state when input field change. This triggers a state update.( because of the setState )

So React will start render the component again & finish the cycle. And now you have 2nd useEffect which has state in dependency array. Since the state was updated & the new changes are committed to the DOM, react will execute 2nd useEffect logic.

Since your 2nd useEffect just assign some values to a normal variable React will not go re render your component again.

Based on your requirement you don't need a 2nd useEffect. You can use a useMemo,

  let elements = useMemo(() => {
    const selectors = "button";
    const pattern = new RegExp(state.value === "" ? "^$" : state.value);
    return Array.from(document.querySelectorAll(selectors)).filter(
      (element) => {
        if (element.childNodes) {
          const nodeWithText = Array.from(element.childNodes).find(
            (childNode) => childNode.nodeType === Node.TEXT_NODE
          );

          if (nodeWithText) {
            // The delay won't happenn if you comment out this conditional statement:
            if (nodeWithText.textContent?.match(pattern)) {
              return element;
            }
          }
        }
      })
  }, [state])

Note: You don't need to assign your elements into another state. It just create another unwanted re render in cycle. Since you are just doing a calculation to find out the element array you can do it with the useMemo

CodePudding user response:

Your useEffect() runs after your component has rendendered. So the sequence is:

  1. You type something into input, that triggers handleInputChange
  2. handleInputChange then updates your state using updateState()
  3. The state update causes a rerender, so App is called App()
  4. console.log('elemets 2:', elements.length) runs and logs elements as 0 as it's still empty
  5. App returns the new JSX
  6. Your useEffect() callback runs, updating elements

Notice how we're only updating the elements after you've rerendered and App has been called.


The state of your React app should be used to describe your UI in React. Since elements isn't React state, it has a chance of becoming out of sync with the UI (as you've seen), whereas using state doesn't have this issue as state updates always trigger a UI update. Consider making elements part of your state. If it needs to be accessible throughout your entire App, you can pass it down as props to children components, or use context to make it accessible throughout all your components.

With that being said, I would make the following updates:

  1. Add elements to your state
  2. Remove your useEffect() with the dependency of [state]. If we were to update the elements state within this effect, then that would trigger another rerender directly after the one we just did for the state update. This isn't efficient, and instead, we can tie the update directly to your event handler. See You Might Not Need an Effect for more details and dealing with other types of scenarios:

<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>

<script type="text/babel">
const { useEffect, useReducer} = React;

const App = () => {
  const initialState = {keyEvent: {}, value: "Initial state", elements: []};

  const [state, updateState] = useReducer(
    (state: any, updates: any) => ({ ...state, ...updates}),
    initialState
  );

  function searchDOM(value) {
    const selectors = "button";
    const pattern = new RegExp(value === "" ? "^$" : value);
    return Array.from(document.querySelectorAll(selectors)).filter(
      (element) => {
        if (element.childNodes) {
          const nodeWithText = Array.from(element.childNodes).find(
            (childNode) => childNode.nodeType === Node.TEXT_NODE
          );

          return nodeWithText?.textContent?.match(pattern);
        }
        return false;
      }
    );
  }

  function handleInputChange(event) {
    updateState({
      value: event.target.value,
      elements: searchDOM(event.target.value)
    });
  }

  function isCommand(event) {
    return event.ctrlKey;
  }

  function handleDocumentKeyDown(event) {
    if (isCommand(event)) {
      updateState({
        keyEvent: event
      });
    }
  }
  
  useEffect(() => {
    document.addEventListener("keydown", handleDocumentKeyDown);

    return () => {
      document.removeEventListener("keydown", handleDocumentKeyDown);
    };
  }, []);

  console.log("elements:", state.elements.length);

  return (
    <div>
      <input id="input" type="text" onChange={handleInputChange} value={state.value} />
      <div id="count">{state.elements.length}</div>
      <button>a</button>
      <button>b</button>
      <button>c</button>
    </div>
  );
};

ReactDOM.createRoot(document.body).render(<App />);

</script>

  • Related