3

I wouldn't expect the following React app to work properly, but it does. I'd expect the useCallback hook to capture and preserve the initial value of the ref. I understand that the ref couldn't be listed in the dependency array so maybe this is a special case only intended for refs?

Why isn't the content of newTodoRef.current.value captured by useCallback just once when App first renders?

import React, { useCallback, useReducer, useRef } from 'react';

type Todo = { id: number, text: string }
type ActionType = { type: 'ADD', text: string} | { type: 'REMOVE', id: number}

const todoReducer = (state: Todo[], action: ActionType) => {
    switch(action.type) {
        case 'ADD': return [ ...state, { id: state.length, text: action.text }]
        case 'REMOVE': return state.filter(({ id }) => id !== action.id) // this is buggy, but that's beside the point
        default: throw new Error()
    }
}

function App() {
    const [todos, dispatch] = useReducer(todoReducer, [])

    const newTodoRef = useRef<HTMLInputElement>(null)

    const onAddTodo = useCallback(() => {
        if (newTodoRef.current) {
            dispatch({ type: "ADD", text: newTodoRef.current.value })
            newTodoRef.current.value = ''
        }
    }, [])

    return (
        <div>
            {todos.map(todo => (
                <div key={todo.id}>
                    {todo.text}
                    <button onClick={() => dispatch({ type:"REMOVE", id: todo.id })}>Remove</button>
                </div>
            ))}
            <input type="text" ref={newTodoRef}/>
            <button onClick={onAddTodo}>ADD</button>
        </div>
    )
}

export default App;
maja
  • 697
  • 5
  • 18

1 Answers1

4

Why isn't the content of newTodoRef.current.value captured by useCallback just once when App first renders?

The reference to the top level object, newTodoRef, is captured in this way. The reason this works out fine is that the ref is a mutable object, and the same object on every render. Once react has created the div element on the page, it will mutate newTodoRef, changing its .current property to the element. Then later, you access newTodoRef, which is still the same object, and you get it's .current property. The property has changed in the meantime, but the object has not.

Nicholas Tower
  • 72,740
  • 7
  • 86
  • 98
  • if I understand correctly, this wouldn't apply to a normal object held in a useState hook because useState is invoked at every render? – maja Jan 09 '22 at 11:06
  • With some fiddling around I managed to replicate the behavior using useState/useReducer while taking care to only modify the nested properties. With this I have no more doubts... – maja Jan 09 '22 at 14:11
  • React state is supposed to be immutable. If you're obeying that, then it's not going to work, because anything that would change the contents of the object will also need to change the object itself, and your closure wouldn't have a reference to that new object. Only if you break immutability will you be able to get it to "work" with state. But don't do that, since it will cause other bugs, since react is designed with the assumption that state is immutable. – Nicholas Tower Jan 09 '22 at 15:25
  • yes I didn't mean to use it in actual code, it was just an attempt to see "how it works" – maja Jan 10 '22 at 07:20