1

Using functional components and Hooks in React, I'm having trouble moving focus to newly added elements. The shortest way to see this is probably the following component,

function Todos (props) {
    const addButton = React.useRef(null)
    const [todos, setTodos] = React.useState(Immutable.List([]))

    const addTodo = e => {
      setTodos(todos.push('Todo text...'))

      // AFTER THE TODO IS ADDED HERE IS WHERE I'D LIKE TO
      // THROW THE FOCUS TO THE <LI> CONTAINING THE NEW TODO
      // THIS WAY A KEYBOARD USER CAN CHOOSE WHAT TO DO WITH
      // THE NEWLY ADDED TODO
    }

    const updateTodo = (index, value) => {
      setTodos(todos.set(index, value))
    }

    const removeTodo = index => {
      setTodos(todos.delete(index))
      addButton.current.focus()
    }

    return <div>
      <button ref={addButton} onClick={addTodo}>Add todo</button>
      <ul>
        {todos.map((todo, index) => (
          <li tabIndex="0" aria-label={`Todo ${index+1} of ${todos.size}`}>
            <input type="text" value={todos[index]} onChange={e => updateTodo(index, e.target.value)}/>
            <a onClick={e => removeTodo(index)} href="#">Delete todo</a>
          </li>
        ))}
      </ul>
  </div>
}

ReactDOM.render(React.createElement(Todos, {}), document.getElementById('app'))

FYI, todos.map realistically would render a Todo component that has the ability to be selected, move up and down with a keyboard, etc… That is why I'm trying to focus the <li> and not the input within (which I realize could be done with the autoFocus attribute.

Ideally, I would be able to call setTodos and then immediately call .focus() on the new todo, but that's not possible because the new todo doesn't exist in the DOM yet because the render hasn't happened.

I think I can work around this by tracking focus via state but that would require capturing onFocus and onBlur and keeping a state variable up to date. This seems risky because focus can move so wildly with a keyboard, mouse, tap, switch, joystick, etc… The window could lose focus…

Mark Huot
  • 135
  • 1
  • 2
  • 8

2 Answers2

2

Use a useEffect that subscribes to updates for todos and will set the focus once that happens.

example:

useEffect(() => {
 addButton.current.focus()
}, [todos])

UPDATED ANSWER:

So, you only had a ref on the button. This doesn't give you access to the todo itself to focus it, just the addButton. I've added a currentTodo ref and it will be assigned to the last todo by default. This is just for the default rendering of having one todo and focusing the most recently added one. You'll need to figure out a way to focus the input if you want it for just a delete.

ref={index === todos.length -1 ? currentTodo : null} will assign the ref to the last item in the index, otherwise the ref is null

import React, { useState, useEffect } from 'react';
import ReactDOM from 'react-dom';

function Todos(props) {
    const currentTodo = React.useRef(null)
    const addButton = React.useRef(null)
    const [todos, setTodos] = useState([])

    useEffect(() => {
        const newTodos = [...todos];
        newTodos.push('Todo text...');
        setTodos(newTodos);

        // event listener for click
        document.addEventListener('mousedown', handleClick);

        // removal of event listener on unmount
        return () => {
            document.removeEventListener('mousedown', handleClick);
        };
    }, []);


    const handleClick = event => {
        // if there's a currentTodo and a current addButton ref
        if(currentTodo.current && addButton.current){
            // if the event target was the addButton ref (they clicked addTodo)
            if(event.target === addButton.current) {
                // select the last todo (presumably the latest)
                currentTodo.current.querySelector('input').select();
            }
        }
    }

    const addTodo = e => {
        const newTodo = [...todos];
        newTodo.push('New text...');
        setTodos(newTodo);
    }

    // this is for if you wanted to focus the last on every state change
    // useEffect(() => {
    //     // if the currentTodo ref is set
    //     if(currentTodo.current) {
    //         console.log('input', currentTodo.current.querySelector('input'));
    //         currentTodo.current.querySelector('input').select();
    //     }
    // }, [todos])

    const updateTodo = (index, value) => {
        setTodos(todos.set(index, value))
    }

    const removeTodo = index => {
        setTodos(todos.delete(index))
        currentTodo.current.focus()
    }

    return <div>
        <button onClick={addTodo} ref={addButton}>Add todo</button>
        <ul>
            {todos.length > 0 && todos.map((todo, index) => (
                <li tabIndex="0" aria-label={`Todo ${index + 1} of ${todos.length}`} key={index} ref={index === todos.length -1 ? currentTodo : null}>
                    <input type="text" value={todo} onChange={e => updateTodo(index, e.target.value)} />
                    <a onClick={e => removeTodo(index)} href="#">Delete todo</a>
                </li>
            ))}
        </ul>
    </div>
}

ReactDOM.render(React.createElement(Todos, {}), document.getElementById('root'))
Katia Wheeler
  • 459
  • 3
  • 7
  • That won’t work _inside_ the `addTodo` callback, though. Ideally I’d like to `addTodo().then(todo => todo.focus())` – Mark Huot May 09 '19 at 16:00
  • Could you pass a ref to the new todo and focus the ref? – Katia Wheeler May 09 '19 at 16:13
  • Yup. But how do I delay the `todoRef.focus()` call until _after_ the render happens and the DOM node is in the page? E.g., `addTodo(); todoRefs[lastTodo].focus()` doesn’t work because the ref isn’t added immediately since React buffers the renders. – Mark Huot May 09 '19 at 20:00
  • You should be able to use a `useEffect` to wait for the todos to be rendered. So, something like above but instead of the `addButton` focus, you would use the todos. So: `useEffect(() => { if(todoRefs.current) { todoRefs.current[lastTodo].focus() } }, [todos]); ` Then when the todos render, it will focus the latest one. – Katia Wheeler May 09 '19 at 20:01
  • That would steal focus on the initial rendering though, right? In this case I need to control it so it only moves focus after a user event. Otherwise it would steal focus whenever the component re-renders for any state change. – Mark Huot May 09 '19 at 20:06
  • Okay, I think I've updated the answer to solve your problem. Essentially, you'll add a ref to the last todo in the list in addition to the `addButton` (and another for the delete button if you so choose). You'll have an event listener for the document itself that will select the last todo if the click event happens on the `addButton`. This way it's controlled to only occur when the user clicks the `addButton`. I left the other `useEffect` for always selecting for state updates just in case you wanted to see it – Katia Wheeler May 09 '19 at 20:32
  • I never thought to use a Ref to carry state from the click event through to the re-render. I would use `const focusIndex = React.useRef(null)`. Then in my `addTodo` I would set `focusIndex.current = todos.size` so that the effect can focu the passed index with `React.useEffect(() => { if (focusIndex.current !== null) { refs[focusIndex.current].current.focus(); } focusIndex.current = null })`. This allows me to set `focusIndex` to anything. It's not limited to _only_ the last todo. Would you mind updating your answer so the answer is flexible to future seekers and then I'll accept it? – Mark Huot May 09 '19 at 20:33
  • A full example using a Ref to track the desired focus index, https://codepen.io/markhuot/pen/mYVqYj – Mark Huot May 09 '19 at 20:37
-1

Just simply wrap the focus() call in a setTimeout

setTimeout(() => {
  addButton.current.focus()
})
  • 2
    That is not thinking in React. – k3llydev May 09 '19 at 14:50
  • 1
    Timeout worries me because depending on browser speed it could still fire before the DOM update or fire too late and steal focus away from whatever the user was trying to do. – Mark Huot May 09 '19 at 15:58
  • Using setTimeout to solve such issues is a bad idea (React or not) as it creates a [Race Condition](https://stackoverflow.com/questions/34510/what-is-a-race-condition) which may lead to random bugs and other weird issues with your state. – maxime Feb 20 '22 at 09:37