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
  • 145
  • 1
  • 3
  • 8

3 Answers3

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
  • 519
  • 3
  • 8
  • 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
0

Spent a little while trying to fix this for myself while learning React the other day & I thought I'd share my workaround. For context I was trying to set focus to a <div> that I was creating while rendering a list of entries to a table.

The problem in my case was that useEffect() wasn't reliably setting focus for two reasons :

  1. I needed to focus on the element in two different cases, one where it was on the page without loading new elements and one where it would be on the page after new elements were rendered. The first problem is pretty straight forward, updating when I was setting focus based on an accurate dependency in useEffect() fixed the problem.

  2. The element I wanted to focus on wasn't rendered yet if I was loading new entries. This one was trickier but I slept on it and of course fixed it in about 10 minutes the following morning.

While trying to focus on the new <div> I noticed that no matter where in my code I tried to access the element using document.getElementById(); it was always null. The answer to this problem is to go to where React actually renders the new <div>. In my case I had already broken this <div> into a separate React Component called Entry.

By passing a prop to Entry containing a boolean value based on whether or not the Entry matched the criteria for focus you can know in the Entry Component whether or not to set focus. From here it's as simple as useEffect() with an empty dependency array to check on Entry render whether you should set focus using document.getElementById().focus();.

const TablePage = () => {
  const [info, setInfo] = useState(null);
  const [lookup, setLookup] = useState(null);
  
  /* This will focus on the div if it's already on the page
   note the use of optional chaining to avoid errors if the lookup doesn't
   have an id field or if the element to focus isn't on the page */

  useEffect(() => {
    document.getElementById(lookup?.id)?.focus());
  }, [lookup]);
  
  return (
    <div>
      {info.map((info, index) => (
        <Entry 
          focus={info.id == lookup?.id ? true : false}
          key={info.id}
          info = {info}
        />
      ))}
    </div>
  )
}

Then the Entry Component

const Entry = ({ info, focus }) => {
  useEffect(() => {
    if(focus) document.getElementById(info.id).focus();
  }, []);
  return (
    // your component structure here
  )
}
-1

Just simply wrap the focus() call in a setTimeout

setTimeout(() => {
  addButton.current.focus()
})
  • 4
    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