We previously created this component in Vue and Svelte ; now we'll explore creating it in React.

While squashing some bugs on and making some updates to my address book, I decided to change the input fields to the inline editable style found on Trello  (show below), Google Sheets, etc.

I'd like to use this component in other apps so we'll be making it a standalone component in this tutorial.

Since this is a single component, it's easier testing in an online sandbox like this one on Sandbox.io instead of creating a full-fledged app.

To start, let's create the needed files:

$ mkdir react-inline-input; cd react-inline-input
$ touch package.json .gitignore .babelrc
$ mkdir src; touch src/InlineInput.js

Let's fill out package.json:

d{
  "name": "react-inline-input",
  "version": "1.0.0",
  "description": "A React component for inline editable inputs.",
  "main": "dist/index.js",
  "scripts": {
    "build": "./node_modules/.bin/babel src --out-file dist/index.js"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/ukchukx/react-inline-input"
  },
  "keywords": [
    "react",
    "inline",
    "editable",
    "input"
  ],
  "files": [
    "dist",
    "src"
  ],
  "author": "Uk Chukundah",
  "license": "MIT",
  "peerDependencies": {
    "react": "^16.12.0",
    "react-dom": "^16.12.0"
  },
  "devDependencies": {
    "@babel/cli": "^7.8.3",
    "@babel/core": "^7.8.3",
    "@babel/preset-env": "^7.8.3",
    "@babel/preset-react": "^7.8.3",
    "react": "^16.12.0",
    "react-dom": "^16.12.0"
  }
}

And .babelrc:

{
  "presets": ["@babel/preset-react", "@babel/preset-env"]
}

For now our .gitignore will only exclude the obligatory node_modules directory:

node_modules

Let's go ahead and create our first commit.

Before writing out our component, let's install prop-types to help validate our component props:

$ npm i -D prop-types

The default state of an inline editable component is to display a static element. When the component is clicked, it transforms into an input field that accepts user input. When focus goes away from the element, it toggles back to a static element.

Our component will handle text, number, select and textarea inputs.

We'll leave styling up to users. At the least, you should show a pointer cursor when the mouse hovers over the static element.

Number and text input

import React, { Component } from 'react';
import PropTypes from 'prop-types';

export default class InlineInput extends Component {
  static propTypes = {
    value:  PropTypes.oneOfType([
      PropTypes.string,
      PropTypes.number
    ]),
    type: PropTypes.string,
    placeholder: PropTypes.string,
    labelClasses: PropTypes.string,
    inputClasses: PropTypes.string,
    onInput: PropTypes.func.isRequired
  };

  static defaultProps = {
    value: '',
    type: 'text',
    placeholder: 'text',
    labelClasses: '',
    inputClasses: ''
  };

  state = {
    editing: false,
    inputEl: React.createRef(),
    isText: this.props.type === 'text',
    isNumber: this.props.type === 'number'
  };

  static getDerivedStateFromProps(nextProps, prevState) {
    const { type } = nextProps;

    return {
      isText: type === 'text',
      isNumber: type === 'number'
    };
  }

  toggle = () => {
    this.setState({ editing: !this.state.editing }, () => {
      const { editing, inputEl } = this.state;
      
      if (editing) inputEl.current.focus();
    });
  };

  handleEnter = (e) => {
    if (e.keyCode === 13) this.state.inputEl.current.blur();
  };

  handleBlur = () => {
    this.toggle();
  };

  handleInput = (e) => {
    this.emitValue(this.state.isNumber ? +e.target.value : e.target.value);
  };

  emitValue = (value) => {
    this.props.onInput(value);
  };

  computeLabel = () => {
    const { state: { isNumber, isText }, props: { value, placeholder } } = this;

    if (isNumber) return value === '' ? placeholder : value;
    if (isText) return value ? value : placeholder;
  };

  render() {
    const { state: { editing, isText, isNumber } } = this;

      return (
        editing && (isText || isNumber)
          ? this._renderInput()
          : this._renderLabel()
      );
  }

  _renderLabel = () => {
    const { props: { labelClasses }, toggle, computeLabel } = this;
    
    return (
      <span className={labelClasses} onClick={toggle}>
        {computeLabel()}
      </span>
    );
  };

  _renderInput = () => {
    const { 
      props: { inputClasses, placeholder, type, value }, 
      handleBlur, 
      handleEnter, 
      handleInput, 
      state: { inputEl } 
    } = this;
    
    return (
      <input 
        ref={inputEl}
        type={type} 
        defaultValue={value} 
        className={inputClasses} 
        placeholder={placeholder}
        onBlur={handleBlur}
        onKeyUp={handleEnter}
        onInput={handleInput} />
    );
  };
}

Our default state is a static element. We'll use a span as our static element. When the span is clicked, we hide the span and show our input element (via the onClick handler on the span).

When the span is clicked, toggle() is called which does two things. It hides the span by setting editing to true and it places the focus on our input so it can accept user input without the user needed to click on the input again. That's one of the reasons we're holding a ref to the input via inputEl; the other is to take away focus when the Enter key is pressed.

When focus leaves the input, we handle the DOM blur event fired via handleBlur(). This function calls toggle() to hide the input and show the span.

We also want to toggle the component when the user presses the Enter key. We handle that via handleEnter() which just takes away focus from the input element which will cause handleBlur() to be called.

When the user changes the input value, we call handleInput(). It  reads the current input value, converts it to a number if type has been set to number, and calls emitValue().

emitValue()  sends the value it receives to the parent component via the event handler passed into the onInput prop.

The inputClasses and labelClasses props are to give the user full control over styling as we're not going to do any styling.

computeLabel() controls what is shown as the static element text. For number inputs, we only show placeholder when no input has been provided; otherwise we show the user input (this ensures that 0 is shown, for example). For text inputs, we show placeholder if value is empty.

That's all that's needed for text and number input. We'll go ahead and create a commit here.

Textarea

import React, { Component } from 'react';
import PropTypes from 'prop-types';

export default class InlineInput extends Component {
  static propTypes = {
    // ...
    cols: PropTypes.number,
    rows: PropTypes.number
  };

  static defaultProps = {
    // ...
    cols: 20,
    rows: 2
  };

  state = {
    // ...
    isTextArea: this.props.type === 'textarea'
  };

  static getDerivedStateFromProps(nextProps, _) {
    // ...

    return {
      // ...
      isTextArea: type === 'textarea'
    };
  }

  // ...

  computeLabel = () => {
    const { state: { isNumber, isText, isTextArea }, props: { value, placeholder } } = this;

    if (isNumber) return value === '' ? placeholder : value;
    if (isText || isTextArea) return value ? value : placeholder;
  };

  render() {
    const { 
      state: { editing, isText, isNumber, isTextArea },
      _renderInput,
      _renderLabel,
      _renderTextArea
    } = this;
    const shouldShowNumberOrText = editing && (isText || isNumber);
    const shouldShowTextArea = editing && isTextArea;

      return (
        shouldShowNumberOrText
          ? _renderInput()
          : (shouldShowTextArea 
              ? _renderTextArea() 
              : _renderLabel()
            )
      );
  }

  // ...

  _renderTextArea = () => {
    const { 
      handleBlur, 
      handleEnter, 
      handleInput, 
      props: { cols, inputClasses, placeholder, rows, type, value }, 
      state: { inputEl } 
    } = this;

    return (
      <textarea 
        ref={inputEl}
        value={value} 
        className={inputClasses} 
        placeholder={placeholder}
        onBlur={handleBlur}
        onInput={handleInput}
        rows={rows}
        cols={cols}>
      </textarea>
    );
  };
}

In textareas, Enter is a normal input and it would be bad UX to toggle the component when Enter is pressed. That's the reason Enter is handled separately in handleEnter() so we can use handleInput() to handle text area input without modification.

The only addition is the rows and cols props to give the parent component control over sizing. They are initialized to their HTML default values if no values are supplied.

A text area label is computed the same way as a text input label, so we just modify the conditional test for text inputs in computeLabel().

That's all that's needed to support textareas. Let's commit our work.

Select

import React, { Component } from 'react';
import PropTypes from 'prop-types';

export default class InlineInput extends Component {
  static propTypes = {
    // ...
    options: PropTypes.array,
    renderSelectLabel: PropTypes.func
  };

  static defaultProps = {
    // ...
    options: [],
    renderSelectLabel: null
  };

  state = { 
      /*...*/
  };

  static getDerivedStateFromProps(nextProps, _) {
    const { type } = nextProps;

    return {
      // ...
      isSelect: type === 'select'
    };
  }

  // ...

  handleChange = (e) => {
    const selectedIndex = this.props.placeholder ? e.target.selectedIndex - 1 : e.target.selectedIndex;

    this.setState({ selectedIndex }, () => {
      this.emitValue(this.props.options[selectedIndex].value);
    });
  };

  // ...

  computeLabel = () => {
    const { state: { isNumber, isText, isTextArea, selectedIndex }, props: { value, placeholder, options } } = this;

    if (isNumber) return value === '' ? placeholder : value;
    if (isText || isTextArea) return value ? value : placeholder;
    // Select
    return selectedIndex === -1 ? placeholder : options[selectedIndex].label;
  };

  render() {
    const { 
      state: { editing, isNumber, isSelect, isText, isTextArea },
      _renderInput,
      _renderLabel,
      _renderSelect,
      _renderTextArea
    } = this;
    const shouldShowNumberOrText = editing && (isText || isNumber);
    const shouldShowTextArea = editing && isTextArea;
    const shouldShowSelect = editing && isSelect;

      return (
        shouldShowNumberOrText
          ? _renderInput()
          : (shouldShowTextArea 
              ? _renderTextArea() 
              : (shouldShowSelect 
                  ? _renderSelect()
                  : _renderLabel()
                )
            )
      );
  }

  _renderLabel = () => {
    const { 
      props: { labelClasses, renderSelectLabel }, 
      toggle, 
      computeLabel,
      _renderSelectLabel
    } = this;
    const selectLabelRenderFn = renderSelectLabel ? renderSelectLabel : _renderSelectLabel;
    
    return (
      <span className={labelClasses} onClick={toggle}>
        {computeLabel()}
        {selectLabelRenderFn()}
      </span>
    );
  };

  // ...

  _renderSelect = () => {
    const { 
      handleBlur, 
      handleChange,
      props: { inputClasses, options, placeholder, value }, 
      state: { inputEl } 
    } = this;

    return (
      <select 
        ref={inputEl}
        class={inputClasses}
        defaultValue={value}
        onChange={handleChange}
        onBlur={handleBlur}>
        {placeholder
          ? <option disabled value>{placeholder}</option>
          : ''}
        {options.map(({ label, value }, i) => (
          <option 
            key={i}
            value={value}>
            {label}
          </option>
        ))}
      </select>
    );
  };

  _renderSelectLabel = () => this.state.isSelect ? (<span>&#9660;</span>) : '';
}

For selects we need an array of options which is supplied through the options prop, which is an array of objects that have label and value keys.

In the span element we show a caret to indicate that it's a select and we provide a way for it to be customized using the renderSelectLabel prop.

In our label computed prop, for selects we display the label of the currently selected value.

With that, we have added support for select inputs. Let's commit our changes.

Notifying parent components on blur

Since we our components handle blur event for it's input elements, let's pass that along to the parent component as well. That's trivial to support because of how we organized our component:

import React, { Component } from 'react';
import PropTypes from 'prop-types';

export default class InlineInput extends Component {
  static propTypes = {
    // ...
    onBlur: PropTypes.func
  };

  static defaultProps = {
    // ...
    onBlur: null
  };

  // ...

  handleBlur = () => {
    const { onBlur } = this.props;

    this.toggle();
    if (onBlur) onBlur();
  };

  // ...
}

Whenever we receive a blur event from an input element, we call the passed event handler.

Don't remember to commit your changes.

Publishing to NPM

Before we publish, we need to fill our README with helpful text and build the component.

To build the component, run:

$ npm run build

See the changes made here.

To publish, I first login to my NPM account with:

$ npm login

Then, from the root directory I run:

$ npm publish

After successfully publishing, we can use our component in React projects by installing it with:

$ npm install react-inline-input

or in the browser using https://unpkg.com/react-inline-input.

The GitHub repository can be found here.