How to build an inline edit component in React

22 September 2021
·
react

Inline editing allows users to edit content without navigating to a separate edit screen. In this tutorial, we’ll be building an accessible inline edit component in React.

Here’s the final product:

GIF showing example of React inline edit component

We’ll also learn how to write some unit tests with React Testing Library. Let’s get started!

This tutorial assumes a basic understanding of React, including hooks.

If you want to jump straight to the full code, check out the React inline edit example on Codepen.

Inline editing and accessibility

When creating any React component, keep accessibility in mind. For example, your component should:

  • Work with only a keyboard
  • Use the correct HTML elements and other attributes to provide the most context to users

One way to approach writing an inline edit component is to have two separate components. One for a “view mode” and one for a “edit mode”:

// View mode
<div onClick={startEditing}>Text value</div>
 
// Edit mode
<input value="Text value" />

When a user clicks on the view mode component, it will disappear and the edit mode will appear.

The second approach (and the one we will be implementing below) is to always use an input element. We can use CSS to make it look as though it has begun editing when a user focuses on it.

// View and edit mode
<input value="Text value" />

By always using an input element, we get behaviours like tabbing and focusing for free. It also makes more explicit what the purpose of the component is.

Create your inline edit component with an input

Let’s get started by creating a React component that uses the HTML input tag:

const InlineEdit = ({ value, setValue }) => {
  const onChange = (event) => setValue(event.target.value);
 
  return (
    <input
      type="text"
      aria-label="Field name"
      value={value}
      onChange={onChange}
    />
  )
}

The aria-label tells screen reader users the purpose of the input. For instance, if it was the name of a list, you could use “List name”.

Then, let’s render our new InlineEdit component, and pass in a value and setValue props:

const App = () => {
  const [value, setValue] = useState();
 
  return <InlineEdit value={value} setValue={setValue} />;
}

In a real-life app, the setValue function would make an endpoint call to store the value in a database somewhere. For this tutorial though, we’ll store the value in a useState hook.

Add CSS to make it “click to edit”

We’ll then add some CSS to remove the input styling. This makes it look as though the user needs to click or focus on the input to start editing.

input {
  background-color: transparent;
  border: 0;
  padding: 8px;
}

We’ll also add some styling to show that the component is editable when a user hovers over it:

input:hover {
  background-color: #d3d3d3;
  cursor: pointer;
}

Allow users to save when they press Enter or Escape

If a user clicks away from the input, it will lose focus and return to “view” mode. To keep things keyboard-friendly, we’ll want the escape and enter keys to achieve the same affect.

const InlineEdit = ({ value, setValue }) => {
  const onChange = (event) => setValue(event.target.value);
 
  const onKeyDown = (event) => {
    if (event.key === "Enter" || event.key === "Escape") {
      event.target.blur();
    }
  }
 
  return (
    <input
      type="text"
      aria-label="Field name"
      value={value}
      onChange={onChange}
      onKeyDown={onKeyDown}
    />
  )
}

Only save on exit

Currently we call the setValue prop on each key press. In a real-life situation, where setValue was making an endpoint call, it would be making an endpoint call per keypress.

We want to prevent this from happening until a user exits the input.

Let’s create a local state variable called editingValue. This is where we’ll store the value of the input when it is in a “editing” phase.

const InlineEdit = ({ value, setValue }) => {
  const [editingValue, setEditingValue] = useState(value);
 
  const onChange = (event) => setEditingValue(event.target.value);
 
  const onKeyDown = (event) => {
    if (event.key === "Enter" || event.key === "Escape") {
      event.target.blur();
    }
  }
 
  const onBlur = (event) => {
    setValue(event.target.value)
  }
 
  return (
    <input
      type="text"
      aria-label="Field name"
      value={editingValue}
      onChange={onChange}
      onKeyDown={onKeyDown}
      onBlur={onBlur}
    />
  )
}

A user exiting the input will call the onBlur handler. So we can use this to call setValue.

Adding validation on empty strings

Finally, you don’t want users to be able to save an empty string or spaces as a value. In that case, we’ll cancel the edit and use the original value.

const onBlur = (event) => {
  if (event.target.value.trim() === "") {
    setValue(value);
  } else {
    setValue(event.target.value)
  }
}

You’ll now have a complete single-line inline edit component. Here’s the full code:

import { useState } from 'react';
 
const InlineEdit = ({ value, setValue }) => {
  const [editingValue, setEditingValue] = useState(value);
  
  const onChange = (event) => setEditingValue(event.target.value);
  
  const onKeyDown = (event) => {
    if (event.key === "Enter" || event.key === "Escape") {
      event.target.blur();
    }
  }
  
  const onBlur = (event) => {
    if (event.target.value.trim() === "") {
      setEditingValue(value);
    } else {
      setValue(event.target.value)
    }
  }
 
  return (
    <input
      type="text"
      aria-label="Field name"
      value={editingValue}
      onChange={onChange}
      onKeyDown={onKeyDown}
      onBlur={onBlur}
    />
  );
};
 
const App = () => {
  const [value, setValue] = useState();
 
  return <InlineEdit value={value} setValue={setValue} />;
};

Creating a multiline inline edit

If you want your inline edit component to be multiline, we can use the textarea element instead:

<textarea
  rows={1}
  aria-label="Field name"
  value={editingValue}
  onBlur={onBlur}
  onChange={onChange}
  onKeyDown={onKeyDown}
/>

The one difference with textarea is that you pass in a rows value. This specifies the height of your textarea.

By default, textareas aren’t dynamic. Luckily, over on StackOverflow I found a solution to this problem.

If you add the following CSS to your text area:

textarea {
  resize: none;
  overflow: hidden;
  min-height: 14px;
  max-height: 100px;
}

And then pass in an onInput handler, you’ll be able to achieve a “dynamic” look.

import { useEffect } from 'react';
 
const onInput = (event) => {
  if (event.target.scrollHeight > 33) { 
    event.target.style.height = "5px";
    event.target.style.height = (event.target.scrollHeight - 16) + "px";
  }
}
 
return (
  <textarea
   rows={1}
   aria-label="Field name"
   value={editingValue}
   onBlur={onBlur}
   onChange={onChange}
   onKeyDown={onKeyDown}
   onInput={onInput}
  />
)

Note you may need to fiddle around with some of the values in the onInput depending on the height and font size of your text area.

The one other thing you’ll need to add is a focus ring - the blue outline around a focused element. We can do this with some CSS:

textarea:focus {
  outline: 5px auto Highlight; /* Firefox */
  outline: 5px auto -webkit-focus-ring-color; /* Chrome, Safari */
}

And you’re done! Here’s the full code for a multiline inline edit:

import { useState, useRef } from 'react';
 
const MultilineEdit = ({ value, setValue }) => {
  const [editingValue, setEditingValue] = useState(value);
 
  const onChange = (event) => setEditingValue(event.target.value);
 
  const onKeyDown = (event) => {
    if (event.key === "Enter" || event.key === "Escape") {
      event.target.blur();
    }
  };
 
  const onBlur = (event) => {
    if (event.target.value.trim() === "") {
      setEditingValue(value);
    } else {
      setValue(event.target.value);
    }
  };
 
  const onInput = (target) => {
    if (target.scrollHeight > 33) {
      target.style.height = "5px";
      target.style.height = target.scrollHeight - 16 + "px";
    }
  };
 
  const textareaRef = useRef();
 
  useEffect(() => {
    onInput(textareaRef.current);
  }, [onInput, textareaRef]);
 
  return (
    <textarea
      rows={1}
      aria-label="Field name"
      value={editingValue}
      onBlur={onBlur}
      onChange={onChange}
      onKeyDown={onKeyDown}
      onInput={(event) => onInput(event.target)}
      ref={textareaRef}
    />
  );
};

Ensure your component’s functionality with unit tests

Before we finish, let’s write a couple of unit tests to ensure the functionality of our component. We’ll be using React Testing Library:

npm install --save-dev @testing-library/react @testing-library/user-event
# or
yarn add -D @testing-library/react @testing-library/user-event

We can ensure that pressing enter causes the input to lose focus:

import { useState } from 'react';
import { fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import InlineEdit from "./Inline-Edit";
 
const apples = "apples"
const oranges = "oranges"
 
const TestComponent = () => {
  const [value, setValue] = useState(apples);
  return <InlineEdit value={value} setValue={setValue} />;
}
 
describe("Inline Edit component", () => {
  test("should save input and lose focus when user presses enter", () => {
    render(<TestComponent />)
    const input = screen.getByRole("textbox");
 
    userEvent.type(input, `{selectall}${oranges}{enter}`);
    // RTL doesn't properly trigger component's onBlur()
    fireEvent.blur(input); 
 
    expect(input).not.toHaveFocus();
    expect(input).toHaveValue(oranges);
  });
});

If you haven’t used React Testing Library before, let’s break this test down:

  • The render function will render your component into a container. You can access it using the screen variable
  • We search for the input component via its aria role, "textbox"
  • We can use the userEvent.type() function to simulate a user typing. If you want to type special keys like space or enter, you can do it with curly braces around it (e.g {space} and {enter})

Similarly, we can write two more unit tests:

test("should focus when tabbed to", () => {
  render(<TestComponent />);
  const input = screen.getByRole("textbox");
 
  expect(document.body).toHaveFocus();
  userEvent.tab();
 
  expect(input).toHaveFocus();
});
 
test("should reset to last-saved value if input is empty", () => {
  render(<TestComponent />);
  const input = screen.getByRole("textbox");
 
  userEvent.type(input, "{selectall}{space}{enter}");
  fireEvent.blur(input);
 
  expect(input).toHaveValue(originalName)
});

And finally, we can use a cool library called jest-axe. You can use it to assert that your component doesn’t have any accessibility violations:

import { axe, toHaveNoViolations } from "jest-axe"
 
expect.extend(toHaveNoViolations)
 
test("should not have any accessibility violations", async () => {
  const { container } = render(<TestComponent />);
  const results = await axe(container);
  expect(results).toHaveNoViolations();
});

If we had forgotten to include an aria-label, for instance, then this test would have failed.


And that’s it! Now you should be able to create inline-editable components for your React app, complete with unit tests.

Recent posts

Comments