Ford
Ford

Reputation: 45

react cannot read properties of null of ref with forwardRef and useRef

I'm using function components. I am using useRef to store a previousValueCount because I don't want it to rerender the component when it updates. This works perfect as long as my useRef declaration is in the component that my code reads and writes with that ref in. However, realizing I may need this higher up the tree, I turned to props. However, when I moved the useRef declaration to the parent and passed the ref as a prop, the code seemed to break, telling me it was null, regardless of the initialization value and it working before in child. To my understanding I could not pass ref as prop so I turned to React.forwardRef. I tried the same thing with no solution and a similar error shared below. How can I pass a ref as a prop or down component tree?

APP COMPONENT:

function App() {
  const [itemsLeftCount, setItemsleftCount] = useState(0);

  return (
    <ToDos
      itemsLeftCount={itemsLeftCount}
      setItemsLeftCount={setItemsLeftCount}
    ></ToDos>
  )
}

PARENT COMPONENT:

function ToDos(props) {
  const prevItemsLeftCountRef = useRef(0);

  return (
    <ToDoBox
      itemsLeftCount={props.itemsLeftCount}
      setItemsLeftCount={props.setItemsLeftCount}
      ref={prevItemsLeftCountRef}
    ></ToDoBox>

  {

CHILD COMPONENT:

const ToDoBox = React.forwardRef((props, ref) => {
  useEffect(() => {
    //LINE 25 Error points to right below comment
    ref.prevItemsLeftCountRef.current = props.itemsLeftCount;
  }, [props.itemsLeftCount]);

  useEffect(() => {
    //ON MOUNT
    props.setItemsLeftCount(ref.prevItemsLeftCountRef.current + 1);

    //ON UNMOUNT
    return function removeToDoFromItemsLeft() {
      //this needs to only run after setitemsleftcount state is for sure done updating
      props.setItemsLeftCount(ref.prevItemsLeftCountRef.current - 1);
    };
  }, []);
})

I receive this error: Uncaught TypeError: Cannot read properties of null (reading 'prevItemsLeftCountRef') at ToDoBox.js:25:1

@Mustafa Walid

function ToDos(props) {
  const prevItemsLeftCountRef = useRef(0);
  const setPrevItemsLeftCountRef = (val) => {
    prevItemsLeftCountRef.current = val;
  };

 return (
   <ToDoBox
     itemsLeftCount={props.itemsLeftCount}
     setItemsLeftCount={props.setItemsLeftCount}
     prevItemsLeftCountRef={prevItemsLeftCountRef}
     setPrevItemsLeftCountRef={setPrevItemsLeftCountRef}
    ></ToDoBox>
  )
}

function ToDoBox(props) {
  useEffect(() => {
    //itemsLeftCount exists further up tree initialized at 0
    props.setPrevItemsLeftCountRef(props.itemsLeftCount); 
  }, [props.itemsLeftCount]);

}

Upvotes: 2

Views: 2435

Answers (2)

Drew Reese
Drew Reese

Reputation: 203572

Issue

You don't need a ref for any of this. You should be using React state. I really couldn't exactly nail down any one specific issue that causing the double initial decrement, but there are quite a few issues in the code that they are all worth mentioning.

Solution

First, the mounting useEffect hook in the ToDoBox component. I see that you were trying to keep and maintain two item count state values, a current value and a previous value. This is completely redundant and as the code shows, can lead to state synchronization issues. Simply stated, you should be using a functional state update right here to correctly access the previous item count state value to increment or decrement it. You may want to also check that the current ToDoBox component isn't the "create box" in the returned clean up function, to ensure the ToDoBox that creates todos doesn't accidentally update the item count.

Example:

useEffect(() => {
  // RUNS ON MOUNT
  if (!isCreateBox) {
    setItemsLeftCount(count => count + 1);
  }
  
  return function removeToDoFromItemsLeft() {
    if (!isCreateBox) {
      setItemsLeftCount(count => count - 1);
    }
  };
}, []);

This alone was enough to stop the item count double-decrement issue.

Other issues I noticed

State mutations

Both the functions to add and delete todos are both mutating the state. This is why you had to add the auxiliary state state in ToDos to force your app to rerender and display the mutations.

addToDoToList

Here updatedArr is a reference to the dataArr state object/array, and the function pushes directly into the array (the mutation), and then saves the very same object/array reference back into state.

function addToDoToList() {
  let updatedArr = dataArr;   // <-- saved reference to state
  updatedArr.push(dataInput); // <-- mutation!!
  setDataArr(updatedArr);     // <-- same reference back into state
}
function handleClickCreateButton() {
  addToDoToList(); // <-- mutates state
  //force a rerender of todos
  setState({});    // <-- force rerender to see mutation
}

handleClickDeleteButton

Here, similarly, updateArr is a reference to the current state. Array.prototype.splice mutates the array in-place. And again, the mutated state reference is saved back into state and a force rerender is required.

function handleClickDeleteButton() {
  // if splice from dataArr that renders a component todobox for each piece of data
  // if remove data from dataArr, component removed...
  let updatedArr = dataArr;    // <-- saved reference to state
  updatedArr.splice(index, 1); // <-- mutation!!
  setDataArr(updatedArr);      // <-- same reference back into state

  // then need to rerender todos... I have "decoy state" to help with that
  setState({});
}

Oddly enough, the comments around this code implies you even know/understand that something is up.

The solution here is to again use functional state updates to correctly update from the previous state and return new object/array references at the same time so React sees that state has updated and triggers the rerender. I suggest also adding an id GUID to the todos to make identifying them easier. This is better than using the array index.

Examples:

import { nanoid } from 'nanoid';

...

function addTodo() {
  // concat to and return a new array reference
  setDataArr(data => data.concat({
    id: nanoid(),
    todo: dataInput,
  }));
}

function removeTodo(id) {
  // filter returns a new array reference
  setDataArr(data => data.filter(todo => todo.id !== id));
}

Other General Design Problems

  • Props drilling of the root itemsLeftCount state and setItemsLeftCount state updater function all the way down to the leaf ToDoBox components.
  • Not centralizing the control over the dataArr state invariant.
  • Using the mapped array index as the React key.
  • Trying to compute "derived" state, i.e. the item count, in nested children.

Here's a boiled-down/minified working version of your code:

App

function App() {
  const [itemsLeftCount, setItemsLeftCount] = useState(0);

  return (
    <div>
      <ToDos setItemsLeftCount={setItemsLeftCount} />
      <div>{itemsLeftCount} items left</div>
    </div>
  );
}

ToDos

import { nanoid } from "nanoid";

function ToDos({ setItemsLeftCount }) {
  const [dataInput, setInputData] = useState("");
  const [dataArr, setDataArr] = useState([]);

  useEffect(() => {
    // Todos updated, update item count in parent
    setItemsLeftCount(dataArr.length);
  }, [dataArr, setItemsLeftCount]);

  function getInputData(event) {
    setInputData(event.target.value);
  }

  function addTodo() {
    setDataArr((data) =>
      data.concat({
        id: nanoid(),
        todo: dataInput
      })
    );
  }

  function removeTodo(id) {
    setDataArr((data) => data.filter((todo) => todo.id !== id));
  }

  return (
    <div>
      <ToDoBox isCreateBox getInputData={getInputData} addTodo={addTodo} />
      {dataArr.map((data) => (
        <ToDoBox key={data.id} data={data} removeTodo={removeTodo} />
      ))}
    </div>
  );
}

ToDoBox

function ToDoBox({ addTodo, data, getInputData, isCreateBox, removeTodo }) {
  if (isCreateBox) {
    return (
      <div>
        <input
          placeholder="Create a new todo..."
          onChange={getInputData}
          tabIndex={-1}
        />
        <span onClick={addTodo}>+</span>
      </div>
    );
  } else {
    return (
      <div>
        <span>{data.todo}</span>{" "}
        <span onClick={() => removeTodo(data.id)}>X</span>
      </div>
    );
  }
}

Edit react-cannot-read-properties-of-null-of-ref-with-forwardref-and-useref

Upvotes: 3

Mustafa Walid
Mustafa Walid

Reputation: 155

  • The parent ref can be modified using a function, this function is then passed down to its child. The child will then modify the parent's ref using this function
const MainComponent = () => {
  const myRef = useRef("old Ref");

  const changeRef = (val) => {
    myRef.current = val;
  };

  return <SubComponent changeRef={changeRef} />;
};

const SubComponent = ({ changeRef }) => {
  changeRef("new Ref");

  return <h1>Changed Parent's Ref!</h1>;
};

Upvotes: 0

Related Questions