Author avatar

Chris Parker

How to use ContentEditable Elements in a React App

Chris Parker

  • Jan 21, 2020
  • 19 Min read
  • 3,025 Views
  • Jan 21, 2020
  • 19 Min read
  • 3,025 Views
Web Development
React

Introduction

We can turn any element into an editable element by adding the contenteditable attribute to it. This editable element is used across the web, one of the most common examples being Google Sheets.

If you’re working on a React app that involves use of the contenteditable element, you'll find some very useful tricks in this guide. The examples used in this guide will focus on a React project. We'll not focus on the user interface, but on the functionality of the element. We'll use Semantic UI React elements to get some default styles in our app.

Goals

To illustrate the use of the contenteditable element, we are going a create a simple table with CRUD operations. We'll go through some of the issues that may be encountered while working with the contenteditable element in any React app, as highlighted below:

  • Pasting
  • Spaces and Special Characters
  • Newlines
  • Highlighting
  • Focusing

Setup

Following is the starting code for our app. We’ll be creating a React project called ce-app.

1
npx create-react-app ce-app && cd ce-app
javascript

The first thing is to add dependencies for react-contenteditable and semantic-ui-react. The react-contenteditable is a useful component that makes it easier to work with the contenteditable element.

1
yarn add react-contenteditable semantic-ui-react
javascript

If you do not have npx, install by it by running the command: npm install -g npx. But if you are not using npmand instead using yarn, then you can use this command: npm i react-contenteditable semantic-ui-react. To keep everything simple, all of our code will be placed inside the index.js file. We need to load all of the dependencies, create the app component, and put some demo data in our state. The index.js file should look something like the following:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import React, { Component } from 'react'
import ReactDOM from 'react-dom'
import ContentEditable from 'react-contenteditable'
import { Table, Button } from 'semantic-ui-react'
import './styles.css'

class App extends Component {
  initialState = {
    store: [
      { id: 11, item: 'bat', cost: 1 },
      { id: 22, item: 'ball', cost: 2},
      { id: 33, item: 'badminton', cost: 3}
    ],
    row: {
      item: '',
      cost: '',
    },
  }

  state = this.initialState

  render() {
    const {
      store,
      row: { item, cost },
    } = this.state

    return (
      <div className="App">
        <h1>Example of React Contenteditable</h1>
        {/* 
		Table will be defined here 
		*/}
      </div>
    )
  }
}

ReactDOM.render(<App />, document.getElementById('root'))
javascript

The headers of the table are Item, Cost and UserAction, and each row has the same data as the state. Every cell contains a contenteditable element or an option to add a row or to delete it.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
<Table celled>
  <Table.Header>
    <Table.Row>
      <Table.HeaderCell>Item</Table.HeaderCell>
      <Table.HeaderCell>Cost</Table.HeaderCell>
      <Table.HeaderCell>UserAction</Table.HeaderCell>
    </Table.Row>
  </Table.Header>
  <Table.Body>
    {store.map(row => {
      return (
        <Table.Row key={row.id}>
          <Table.Cell>{row.item}</Table.Cell>
          <Table.Cell>{row.cost}</Table.Cell>
          <Table.Cell className="narrow">
            <Button
              onClick={() => {
                this.removeRow(row.id)
              }}
            >
              Delete
            </Button>
          </Table.Cell>
        </Table.Row>
      )
    })}
    <Table.Row>
      <Table.Cell className="narrow">
        <ContentEditable
          html={item}
          data-column="item"
          className="content-editable"
          onChange={this.handleCE}
        />
      </Table.Cell>
      <Table.Cell className="narrow">
        <ContentEditable
          html={cost}
          data-column="cost"
          className="content-editable"
          onChange={this.handleCE}
        />
      </Table.Cell>
      <Table.Cell className="narrow">
        <Button onClick={this.addNewRow}>Add</Button>
      </Table.Cell>
    </Table.Row>
  </Table.Body>
</Table>
javascript

We are starting with three methods. The first method is for adding a row, and it will remove entries from the current row and send the new row entries to the store. Another method will be used for deleting a row.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
addNewRow = () => {
  this.setState(({ row, store }) => {
    return {
      store: [...store, { ...row, id: store.length + 1 }],
      row: this.initialState.row,
    }
  })
}

removeRow = id => {
  this.setState(({ store }) => ({
    store: store.filter(item => id !== item.id),
  }))
}
javascript

The third method is the handleCE component. It will be called with every change in ContentEditableby using onChange. In order to use a single function for all the columns, we have a data-column attribute added to our component so we can have the key (column) as well as the value for each ContentEditable and can then set our row.

1
2
3
4
5
6
7
8
9
10
11
handleCE = evt => {
  const { row } = this.state
  const {
    currentTarget: {
      dataset: { column },
    },
    target: { value },
  } = evt

  this.setState({ row: { ...row, [column]: value } })
}
javascript

Below is the CSS added to the project for our UI to look good.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
.App {
  margin: 10px auto;
  max-width: 900px;
  font-family: sans-serif;
}

.ui.table td {
  padding: 2rem;
}

.ui.table td.narrow {
  padding: 0;
}

.ui.button {
  margin: 0 1.5rem;
}

.content-editable {
  padding: 1rem;
}

.content-editable:hover {
  background: #999999;
}

.content-editable:focus {
  background: #efefef;
  outline: 0;
}
css

Now our setup is complete, and our app has a table and the functionality to add a row by using contenteditable instead of using an input or textarea, and thus we can control the element styling entirely.

Issue 1: Pasting

Now that our app seems ready, a user might think that it would be straightforward to just copy text from Excel or Google Sheets and paste it into our app. Let's just try that. The paste seems to be working fine. Now let's submit it. The contenteditable element keeps the style and format of the text. If we try to copy text from the text editor and paste it, it still will not paste plain text. The text from Excel or Google Sheets is accompanied by HTML that should not be submitted. To resolve this, we need a function to paste only the text and not the HTML.

1
2
3
4
5
6
pastePlainText = evt => {
  evt.preventDefault()

  const text = evt.clipboardData.getData('text/plain')
  document.execCommand('insertHTML', false, text)
}
javascript

The onPaste attribute of the ContentEditable can used as below.

1
<ContentEditable onPaste={this.pastePlainText} />
javascript

Issue 2: Spaces and Special Characters

There won't be a problem if we type text with some spaces and submit. It seems that contenteditable works fine with spaces. However, if we copy some text and keep the space at the start and end of the text, we get a &nbsp; tag (the non-breaking space) at the beginning and end of our text. Same is the case with less than, greater than and ampersand. We can add the following method to replace these characters:

1
2
3
4
5
6
7
const trimSpaces = string => {
  return string
    .replace(/&nbsp;/g, '')
    .replace(/&amp;/g, '&')
    .replace(/&gt;/g, '>')
    .replace(/&lt;/g, '<')
}
javascript

We can add this in the addNewRow method and replace those characters before submitting.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
addNewRow = () => {
  const trimSpaces = string => {
    return string
      .replace(/&nbsp;/g, '')
      .replace(/&amp;/g, '&')
      .replace(/&gt;/g, '>')
      .replace(/&lt;/g, '<')
  }

  this.setState(({ store, row }) => {
    const trimmedRow = {
      ...row,
      item: trimSpaces(row.item),
      id: store.length + 1,
    }
    return {
      store: [...store, trimmedRow],
      row: this.initialState.row,
    }
  })
}
javascript

Issue 3: New Lines

It is quite possible that the enter key will be pressed instead of the tab key to get to the next item, and that will result in a new line. The contenteditable will take that literally. To avoid that, we can disable the enter key by using its code (13).

1
2
3
4
5
6
7
8
disableNewlines = evt => {
  const keyCode = evt.keyCode || evt.which

  if (keyCode === 13) {
    evt.returnValue = false
    if (evt.preventDefault) evt.preventDefault()
  }
}
javascript

The onKeyPress attribute will be used for this purpose.

1
<ContentEditable onKeyPress={this.disableNewlines} />
javascript

Issue 4: Highlighting

The cursor is moved to the start of the div when we tab through an existing contenteditable element, and that is not helpful. We should create a function that will highlight our element when selected by a tab or mouse.

1
2
3
4
5
highlightAll = () => {
  setTimeout(() => {
    document.execCommand('selectAll', false, null)
  }, 0)
}
javascript

The onFocus attribute will be used for this purpose.

1
<ContentEditable onFocus={this.highlightAll} />
javascript

Issue 5: Focusing After Submit

So far, the focus is lost when a row is submitted, which makes it impossible to get a nice flow while populating our table. The focus should be on the first item of the new row when a row is submitted. To resolve this issue, the first thing to do is to create a ref below state.

1
firstEditable = React.createRef()
javascript

Then call focus on the firstEditable of the current div, at the end of the addNewRow method.

1
this.firstEditable.current.focus()
javascript

We can use the innerRef of the ContentEditable for this purpose.

1
<ContentEditable innerRef={this.firstEditable} />
javascript

After this, the focus should be on the next row after submitting a new row.

Dealing with Numbers and Currency

Dealing with numbers is not specific to contenteditable, but as we have a cost attribute in our table, the following functions can be useful when using currency or numbers. Another way to deal with numbers is to add an <input type=” number”> in the HTML file to enable inputs with numbers only, but we need to define our own functions for ContentEditable. Earlier we had disabled new lines in the text input, and now we will enable dot (.), comma (,), and digits (0-9) only.

1
2
3
4
5
6
7
8
9
10
validateNum = evt => {
  const keyCode = evt.keyCode || evt.which
  const string = String.fromCharCode(keyCode)
  const regex = /[0-9,]|\./

  if (!regex.test(string)) {
    evt.returnValue = false
    if (evt.preventDefault) evt.preventDefault()
  }
}
javascript

The above solution for numbers will not restrict incorrect formats like 1,00,0.00.00, but here we only validate the input for a single keypress.

1
<ContentEditable onKeyPress={this.validateNum} />
javascript

Editing Existing Rows

Our application has the edit option only for the last row, and the only method to edit is to delete it and create new row. A great functionality would be the ability to edit every row on the go. We will create another function only for updating the rows. Its functionality will be similar to that of the new row, except it will iterate the store and update the row using its index rather than creating a new row. And to make the indexing possible, another data attribute is added.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
handleCEUpdate = evt => {
  const {
    currentTarget: {
      dataset: { row, column },
    },
    target: { value },
  } = evt

  this.setState(({ store }) => {
    return {
      store: store.map(item => {
        return item.id === parseInt(row, 10) ? { ...item, [column]: value } : item
      }),
    }
  })
}
javascript

Now all the values of our row are ContentEditable instead of just displaying the values.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
{store.map((row, i) => {
  return (
    <Table.Row key={row.id}>
      <Table.Cell className="narrow">
        <ContentEditable
          html={row.item}
          data-column="item"
          data-row={row.id}
          className="content-editable"
          onKeyPress={this.disableNewlines}
          onPaste={this.pastePlainText}
          onFocus={this.highlightAll}
          onChange={this.handleCEUpdate}
        />
      </Table.Cell>
      <Table.Cell className="narrow">
        <ContentEditable
          html={row.cost.toString()}
          data-column="cost"
          data-row={row.id}
          className="content-editable"
          onKeyPress={this.validateNum}
          onPaste={this.pastePlainText}
          onFocus={this.highlightAll}
          onChange={this.handleCEUpdate}
        />
      </Table.Cell>
      ...
  )
})}
javascript

Complete Code

Below is the complete source code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
import React, { Component } from 'react'
import ReactDOM from 'react-dom'
import ContentEditable from 'react-contenteditable'
import { Table, Button } from 'semantic-ui-react'
import './styles.css'

class App extends Component {
  initialState = {
    store: [
      { id: 11, item: 'bat', cost: 1 },
      { id: 22, item: 'ball', cost: 2},
      { id: 33, item: 'badminton', cost: 3},
    ],
    row: {
      item: '',
      cost: '',
    },
  }

  state = this.initialState
  firstEditable = React.createRef()

  addNewRow = () => {
    const { store, row } = this.state
    const trimSpaces = string => {
      return string
        .replace(/&nbsp;/g, '')
        .replace(/&amp;/g, '&')
        .replace(/&gt;/g, '>')
        .replace(/&lt;/g, '<')
    }
    const trimmedRow = {
      ...row,
      item: trimSpaces(row.item),
    }

    row.id = store.length + 1

    this.setState({
      store: [...store, trimmedRow],
      row: this.initialState.row,
    })

    this.firstEditable.current.focus()
  }

  removeRow = id => {
    const { store } = this.state

    this.setState({
      store: store.filter(item => id !== item.id),
    })
  }

  disableNewlines = evt => {
    const keyCode = evt.keyCode || evt.which

    if (keyCode === 13) {
      evt.returnValue = false
      if (evt.preventDefault) evt.preventDefault()
    }
  }

  validateNum = evt => {
    const keyCode = evt.keyCode || evt.which
    const string = String.fromCharCode(keyCode)
    const regex = /[0-9,]|\./

    if (!regex.test(string)) {
      evt.returnValue = false
      if (evt.preventDefault) evt.preventDefault()
    }
  }

  pastePlainText = evt => {
    evt.preventDefault()

    const text = evt.clipboardData.getData('text/plain')
    document.execCommand('insertHTML', false, text)
  }

  highlightAll = () => {
    setTimeout(() => {
      document.execCommand('selectAll', false, null)
    }, 0)
  }

  handleCE = evt => {
    const { row } = this.state
    const {
      currentTarget: {
        dataset: { column },
      },
      target: { value },
    } = evt

    this.setState({ row: { ...row, [column]: value } })
  }

  handleCEUpdate = evt => {
    const {
      currentTarget: {
        dataset: { row, column },
      },
      target: { value },
    } = evt

    this.setState(({ store }) => {
      return {
        store: store.map(item => {
          return item.id === parseInt(row, 10) ? { ...item, [column]: value } : item
        }),
      }
    })
  }

  render() {
    const {
      store,
      row: { item, cost },
    } = this.state

    return (
      <div className="App">
        <h1>React Example  With Contenteditable</h1>

        <Table celled>
          <Table.Header>
            <Table.Row>
              <Table.HeaderCell>Item</Table.HeaderCell>
              <Table.HeaderCell>Cost</Table.HeaderCell>
              <Table.HeaderCell>UserAction</Table.HeaderCell>
            </Table.Row>
          </Table.Header>
          <Table.Body>
            {store.map((row, i) => {
              return (
                <Table.Row key={row.id}>
                  <Table.Cell className="narrow">
                    <ContentEditable
                      html={row.item}
                      data-column="item"
                      data-row={i}
                      className="content-editable"
                      onKeyPress={this.disableNewlines}
                      onPaste={this.pastePlainText}
                      onFocus={this.highlightAll}
                      onChange={this.handleCEUpdate}
                    />
                  </Table.Cell>
                  <Table.Cell className="narrow">
                    <ContentEditable
                      html={row.cost.toString()}
                      data-column="cost"
                      data-row={i}
                      className="content-editable"
                      onKeyPress={this.validateNum}
                      onPaste={this.pastePlainText}
                      onFocus={this.highlightAll}
                      onChange={this.handleCEUpdate}
                    />
                  </Table.Cell>
                  <Table.Cell className="narrow">
                    <Button
                      onClick={() => {
                        this.removeRow(row.id)
                      }}
                    >
                      Delete
                    </Button>
                  </Table.Cell>
                </Table.Row>
              )
            })}
            <Table.Row>
              <Table.Cell className="narrow">
                <ContentEditable
                  html={item}
                  data-column="item"
                  className="content-editable"
                  innerRef={this.firstEditable}
                  onKeyPress={this.disableNewlines}
                  onPaste={this.pastePlainText}
                  onFocus={this.highlightAll}
                  onChange={this.handleCE}
                />
              </Table.Cell>
              <Table.Cell className="narrow">
                <ContentEditable
                  html={cost}
                  data-column="cost"
                  className="content-editable"
                  onKeyPress={this.validateNum}
                  onPaste={this.pastePlainText}
                  onFocus={this.highlightAll}
                  onChange={this.handleCE}
                />
              </Table.Cell>
              <Table.Cell className="narrow">
                <Button disabled={!item || !cost} onClick={this.addNewRow}>
                  Add
                </Button>
              </Table.Cell>
            </Table.Row>
          </Table.Body>
        </Table>
      </div>
    )
  }
}

ReactDOM.render(<App />, document.getElementById('root'))
javascript

Conclusion

I hope that this guide provides you with all the necessary help for contenteditable elements.

7