DEV Community

Cover image for Focus management in React with Redux
Wouter Alberts
Wouter Alberts

Posted on

Focus management in React with Redux

Intro

Controlling focus in a React app isn’t straightforward, but very important for accessibility and usability in general. Keyboard users rely heavily on predictable focus management.

There are several possible approaches. Personally, I’ve come to like the 3rd option.

  • autoFocus attribute
    • eslint has an opinion and suggests you don’t use it (jsx-a11y/no-autofocus), but if you know what you’re doing, there are definitely situations where this is viable.
    • relies on elements entering the DOM, doesn’t work when toggling visibility with css.
  • forwardRef and useImperativeHandle
    • lets a child expose a method (e.g. focusButton()) that a parent call on demand
    • very explicit, lots of boilerplate and quickly become hard to follow
  • redux or other state managers
    • requires a “focus” state prop, ideally an object so it’s new every time
    • works well with a custom hook to listen for changes and apply the focus

AutoFocus

The simplest method in terms of amount of code. Simply use <button autoFocus /> to make the element gain focus when it mounts.

Works well when…

  • You have a conditionally rendered component (or element inside that component) that, when shown, always needs to gain focus.

Example use case

For a simple example, imagine a form that requires an extra confirmation before it’s sent to the server. So pressing Enter in any field (or clicking the 1st submit button) would ask “Are you sure?”. There would be Confirm and Cancel buttons.

The form looks something like this:

// TheForm.tsx

import { useState } from 'react'
import { ConfirmControls, Fields, SubmitButton } from './components' // details omitted for brevity

const TheForm = () => {
  const [atConfirmStep, setAtConfirmStep] = useState(false)

  // logic to toggle atConfirmStep omitted for brevity

  const submitBtnRef = useRef<HTMLButtonElement>(null)

  const handleCancelConfirmation = useCallback(() => {
    submitBtnRef.current?.myCustomFocus()
  }, [])

  return (
    <form>
      <Fields />

      {!atConfirmStep && <SubmitButton ref={submitBtnRef} />}

      {atConfirmStep && <ConfirmControls onCancel={handleCancelConfirmation} />}
    </form>
  )
}

export default TheForm
Enter fullscreen mode Exit fullscreen mode

Upon triggering the SubmitButton, a screen reader would read “Are you sure?” and focus would automatically move to the ConfirmButton. Since the ConfirmButton is only rendered when atConfirmStep is true, the autoFocus attribute works. The button gains focus when the component mounts.

Tab would focus the CancelButton. Canceling would reset atConfirmStep to false, replacing the ConfirmControls with the SubmitButton.

For easier keyboard navigation, it makes sense to let users hit Escape to cancel out of the confirm step.

When canceled, since the ConfirmControls are removed from DOM, the document body would gain focus. That’s not ideal, so let’s go the extra mile and focus the SubmitButton instead. Keyboard / Assistive Tech users will appreciate that.

React.forwardRef with React.useImperativeHandle

To move focus to the SubmitButton, we can’t use autoFocus, because that would focus the button immediately after page load. That’s a terrible experience.

So how do we focus the SubmitButton when the user cancels out of the ConfirmControls?

One option is to make Form call a focus() method on the SubmitButton. Yes, that’s a parent calling a method on a child component. The flow is this:

  1. ConfirmControl is canceled, invokes onCancel (received as prop from Form).
  2. Form has a ref pointing to SubmitButton. When handleCancelConfirmation is invoked, run submitButtonRef.myCustomFocus(). “myCustomFocus()” is a method of SubmitButton, made available through useImperativeHandle (details omitted, see useImperativeHandle).
  3. The myCustomFocus() method now does something like buttonRef.focus(), where buttonRef points to the actual html element.

This works, but it has downsides:

  • It requires a lot of boilerplate with the forwardRef and useImperativeHandle.
  • The flow isn’t straightforward to follow.
  • It becomes increasingly difficult to manage when additional components are involved, e.g. if SomeComponent is sat between Form and SubmitButton. In this case, Form would trigger a method on SomeComponent, which in turn would trigger the myCustomFocus() method on SubmitButton. Now, both SomeComponent and SubmitButton need use forwardRef and useImperativeHandle. That’s a lot of hard to read code just for setting focus.

There’s a simpler approach: Using a state manager.

Redux to set focus

Using redux, or any other state manager, we can simplify the approach and make it much easier to read and maintain.

Note: One might argue that redux is for managing state declaratively, and using it imperatively to achieve something such as setting focus isn’t what it’s meant for. I like to be pragmatic about it. As you’ll see, managing focus becomes super easy and readable. That’s ultimately what matters.

For this approach, you need:

  • A slice of state where you add a “focus” field.
  • An action+reducer to set the focus.
  • In your component with focusable element(s):
    • A ref to any focusable element in your component.
    • An effect that listens for changes to the focus field in the store…
    • …and applies focus to the element when appropriate.

Redux store

For the rest of this article I’ll assume you’re using RTK (Redux Toolkit) with a store.ts file much like documented here: TypeScript Quick Start.

Store slice

Add a “focus” field to your store slice:

// myFormSlice.ts

import { createSlice } from '@reduxjs/toolkit'

interface MyFormState {
  focus: { identifier: string } | null
}

const initialState: MyFormState = {
  focus: null,
}

const myFormSlice = createSlice({
  name: "myForm",
  initialState,
  ...
})

export default myFormSlice
Enter fullscreen mode Exit fullscreen mode

The identifier (more on that in a bit) is a string that uniquely identifies an element that can potentially be given focus. E.g. in our form example, that’s “submitButton”. This can be any string, as long as it clearly describes the element.

You’ll notice focus is of type { identifier: string } | null instead of just string | null. The reason is this:

The effect that listens for changes and applies focus must respond whenever the identifier matches, even if the identifier string is the same as before. Using just a string doesn’t work. Imagine focus being of type string | null, value null and then changed to “submitButton”. The effect would detect the change and apply focus. Note we’re not dispatching another action just to reset focus to null, that would be wasteful. So the value remains “submitButton”. Now, after some interaction, for the sake of example, the focus is set to the submit button again. But dispatching a new focus action doesn’t change the string “submitButton”, so the effect doesn’t run and the element doesn’t gain focus.

Instead, we use an object: { identifier: string }, which is always new even id the string value is the same as before (because it’s a new object). It does require us to assign the whole object to “focus”; let me show you.

The action+reducer to set focus

Add the following reducer to your slice:

// store/myFormSlice.ts

import { createSlice, PayloadAction } from '@reduxjs/toolkit'

// snip, refer to above if needed

const myFormSlice = createSlice({
  name: "myForm",
  initialState,
  reducers: {
    setFocus: (state, action: PayloadAction<string>) {
      state.focus = { identifier: action.payload }
    }
  }
})

export const { actions: myFormActions } = myFormSlice

export default myFormSlice
Enter fullscreen mode Exit fullscreen mode

This reducer assigns the whole object to state.focus. That’s important for change detection. You can’t do state.focus.identifier = action.payload as explained above; that wouldn’t trigger the effect to run.

Selector to get “focus” from store

Your component with focusable elements need to know when to apply focus. So provide a selector to take it from the store:

// store/selectors.ts

export const selectFocus = (rootState: RootState) => rootState.myForm.focus
Enter fullscreen mode Exit fullscreen mode

Importantly, this returns the whole focus object and not just the identifier string.

Note: RootState describes the entire store, as documented in the RTK docs: Usage With TypeScript

The effect to set focus

In components that have a focusable element, you’ll need an effect to apply focus. And also a ref pointing to that element.

// SubmitButton.tsx

import { useEffect, useRef } from 'react'
import { useAppSelector } form './store'
import { selectFocus } from './store/selectors'

const SubmitButton = () => {
  const focus = useAppSelector(selectFocus)
  const buttonRef = useRef<HTMLButtonElement>(null)

  useEffect(() => {
    if (focus?.identifier === 'submitButton') {
      buttonRef.current?.focus()
    }
  }, [focus])

  return <button ref={buttonRef} ...>Submit</button>
}

export default SubmitButton
Enter fullscreen mode Exit fullscreen mode

Note: See TypeScript Quick Start for useAppSelector and useAppDispatch.

Now, to apply focus to this button, you can dispatch the setFocus action from anywhere in your app, like this:

// SomeComponent.tsx

import { useAppDispatch, useAppSelector } from './store'
import { myFormActions } from './store/myFormSlice'

const SomeComponent = () => {
  const dispatch = useAppDispatch();

  const focusSubmitButton = () => {
    dispatch(myFormActions.setFocus('submitButton'))
  }

  return <button onClick={focusSubmitButton}>Focus submit button</button>
}

export default SomeComponent
Enter fullscreen mode Exit fullscreen mode

And just like that, pressing that “Focus submit button” in SomeComponent.tsx will focus the SubmitButton.

To recap, what happens is this:

  1. User presses “Focus submit button”
  2. The setFocus action is dispatched with arg “submitButton”
  3. The reducer assigns { identifier: "submitButton" } to state.focus of the myForm slice.
  4. The useEffect in SubmitButton detects “focus” has changed and compares the identifier against “submitButton”. Since the identifier is indeed “submitButton”, it calls focus() on the button element.

Now imagine a more complicated app where actions that apply focus aren’t necessarily close to the components that can receive focus. The redux approach feels like the ideal fit for focus management!

Reusable hook: useFocusable

I’ll leave you with a reusable hook that you can use in components that can receive focus (SubmitButton.tsx in our example). A reusable hook beats repeating the logic in every component!

It also takes an optional condition argument to conditionally apply focus. Useful for when you have multiple elements with the same identifier, e.g. a collection of radio buttons, of which you want to focus the selected one.

// hooks/useFocusable.ts

import { useEffect, useMemo, useRef } from 'react'
import { useAppSelector } from './store'
import { selectFocus } from './store/selectors'

type FocusableIdentifier = 'submitButton' | 'someElement' | ...

const useFocusable = <T extends HTMLElement>({
  identifier,
  condition,
}: {
  identifier: string
  condition?: () => boolean
}) => {
  const focus = useAppSelector(selectFocus)
  const ref = useRef<T>(null)

  const conditionFn = useMemo(() => {
    return condition ?? (() => true)
  }, [condition])

  useEffect(() => {
    if (focus?.identifier === identifier && conditionFn()) {
      ref.current?.focus()
    }
  }, [conditionFn, focus, identifier])

  return ref
}
Enter fullscreen mode Exit fullscreen mode

Using this hook, our SubmitButton component would look like this:

// SubmitButton.tsx

import { useEffect } from 'react'
import useFocusable from './hooks/useFocusable'
import { selectFocus } from './store/selectors'

const SubmitButton = () => {
  const buttonRef = useFocusable<HTMLButtonElement>({
    identifier: 'submitButton'
  })

  return <button ref={buttonRef} ...>Submit</button>
}

export default SubmitButton
Enter fullscreen mode Exit fullscreen mode

Closing

Predictable focus management is great for accessibility and usability in general. It’s our job as developers to do what we can to make the UX as great as we possibly can for everyone, while ideally keeping the code easy to understand.

Out of the provided options; autoFocus, forwardRef/useImperativeHandle and redux, which do you prefer? Do you use something else? Let me know in the comments!

Thanks for reading!

Top comments (0)