0

I've been stuck on this error for a long time, so I would appreciate some help. Here is a minimally reproducible exmaple:

import "./App.css";
import React, { useState } from "react";
import $ from "jquery";

function App() {
    const [value, setValue] = useState("");

    const focusHandler = () => {
        $("input").on("keypress", (e) => {
            let copy = value;
            if (e.key === ".") {
                e.preventDefault();
                copy += "    ";
                setValue(copy);
            }
        });
    };

    const blurHandler = (event) => {
        $("input").off("keypress");
        setValue(event.target.value);
    };

    const changeHandler = (event) => {
        setValue(event.target.value);
    };

    return (
        <div>
            <input
                value={value}
                onFocus={focusHandler}
                onBlur={blurHandler}
                onChange={changeHandler}
            />
        </div>
    );
}

export default App;

On input focus, I'm adding an event listener to look for a . keypress and append a tab (4 spaces) to the input if it is pressed. But when I press . multiple times, the input gets stuck at the first tab, and doesn't move any further (e.g. input permanenetly shows 4 spaces). Using console.log shows me that the value state doesn't seem to be updating in focusHandler and reverts to the original value ("").

An important note is that switching to a class-based component with this.state makes it work. Any insight as to why this is happening?

ggorlen
  • 44,755
  • 7
  • 76
  • 106
Sam Liu
  • 157
  • 1
  • 8
  • Have you inspected the state with the React dev tools? – Code-Apprentice Jul 08 '21 at 14:51
  • Why are you combining jQuery with React? This is a strange setup -- React already attaches listeners to the DOM elements, so tossing jQuery handlers on top of that just seems to add to the confusion. – ggorlen Jul 08 '21 at 14:59
  • @Code-Apprentice Yes, I have. I haven't been able to get much more information out of the dev tools than I have with `console.log` statements – Sam Liu Jul 08 '21 at 15:01
  • @ggorlen Admittedly, it's not the cleanest way to handle what I want to do. Do you think mixing jQuery and React is what's causing the error? – Sam Liu Jul 08 '21 at 15:02
  • Even if it works, it strikes me as an antipattern. I mean, you have two listeners: `onChange => setValue(something);` and jQuery has `.on("keypress" => setValue(somethingElse)`. It's not clear to me how React is supposed to make sense of this since pressing a key will fire both change and keypress events. As an aside, [keypress is deprecated](https://developer.mozilla.org/en-US/docs/Web/API/Document/keypress_event). – ggorlen Jul 08 '21 at 15:05
  • @ggorlen Hmm, I see what you're saying. What's a better way to approach this without adding event listeners? The `changeHandler` seems to be necessary to control the state. – Sam Liu Jul 08 '21 at 15:08

3 Answers3

3

As mentioned in the comments, jQuery is the wrong tool for the job. Bringing in jQuery is the same as calling DOM methods directly: it's circumventing React and adding additional listeners on top of the ones React already gives you. You can expect misbehavior if you're setting state from multiple handlers unrelated to React. Once you're in React, use the tools it gives you (refs, effects, handlers) to solve the problem.

Worst case scenario is when an approach appears to work, then fails in production, on other people's machines/browsers, when refactoring from classes to hooks or vice-versa, in different versions of React, or for 1 out of 1000 users. Staying well within the API React gives you better guarantees that your app will behave correctly.

Controlled component

For manipulating the input value, you can use both onKeyDown and onChange listeners. onKeyDown fires first. Calling event.preventDefault() inside of onKeyDown will block the change event and ensure only one call to setState for the controlled input value occurs per keystroke.

The problem with this the input cursor moves to the end after the component updates (see relevant GitHub issue). One way to deal with this is to manually adjust the cursor position when you've made an invasive change to the string by adding state to keep track of the cursor and using a ref and useEffect to set selectionStart and selectionEnd properties on the input element.

This causes a brief blinking effect due to asynchrony after the render, so this isn't a great solution. If you're always appending to the end of the value, you assume the user won't move the cursor as other answers do, or you want the cursor to finish at the end, then this is a non-issue, but this assumption doesn't hold in the general case.

One solution is to use useLayoutEffect which runs synchronously before the repaint, eliminating the blink.

With useEffect:

const {useEffect, useRef, useState} = React;

const App = () => {
  const [value, setValue] = useState("");
  const [cursor, setCursor] = useState(-1);
  const inputRef = useRef();
  const pad = ".    ";
  
  const onKeyDown = event => {
    if (event.code === "Period") {
      event.preventDefault();
      const {selectionStart: start} = event.target;
      const {selectionEnd: end} = event.target;
      const v = value.slice(0, start) + pad + value.slice(end);
      setValue(v);
      setCursor(start + pad.length);
    }
  };
  
  const onChange = event => {
    setValue(event.target.value);
    setCursor(-1);
  };
  
  useEffect(() => {
    if (cursor >= 0) {
      inputRef.current.selectionStart = cursor;
      inputRef.current.selectionEnd = cursor;
    }
  }, [cursor]);

  return (
    <div>
      <p>press `.` to add 4 spaces:</p>
      <input
        ref={inputRef}
        value={value}
        onChange={onChange}
        onKeyDown={onKeyDown}
      />
    </div>
  );
};

ReactDOM.createRoot(document.querySelector("#app"))
  .render(<App />);
input {
  width: 100%;
}
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<div id="app"></div>

With useLayoutEffect:

const {useLayoutEffect, useRef, useState} = React;

const App = () => {
  const [value, setValue] = useState("");
  const [cursor, setCursor] = useState(-1);
  const inputRef = useRef();
  const pad = ".    ";
  
  const onKeyDown = event => {
    if (event.code === "Period") {
      event.preventDefault();
      const {selectionStart: start} = event.target;
      const {selectionEnd: end} = event.target;
      const v = value.slice(0, start) + pad + value.slice(end);
      setValue(v);
      setCursor(start + pad.length);
    }
  };
  
  const onChange = event => {
    setValue(event.target.value);
    setCursor(-1);
  };
  
  useLayoutEffect(() => {
    if (cursor >= 0) {
      inputRef.current.selectionStart = cursor;
      inputRef.current.selectionEnd = cursor;
    }
  }, [cursor]);

  return (
    <div>
      <p>press `.` to add 4 spaces:</p>
      <input
        ref={inputRef}
        value={value}
        onChange={onChange}
        onKeyDown={onKeyDown}
      />
    </div>
  );
};

ReactDOM.createRoot(document.querySelector("#app"))
  .render(<App />);
input {
  width: 100%;
}
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<div id="app"></div>

Uncontrolled component

Here's another attempt using an uncontrolled component. This doesn't have the blinking problem because the DOM element's .value property is synchronously set at the same time as the .selectionStart property and is rendered in the same repaint.

const App = () => {
  const pad = ".    ";
  
  const onKeyDown = event => {
    if (event.code === "Period") {
      event.preventDefault();
      const {target} = event;
      const {
        value, selectionStart: start, selectionEnd: end,
      } = target;
      target.value = value.slice(0, start) + 
                     pad + value.slice(end);
      target.selectionStart = start + pad.length;
      target.selectionEnd = start + pad.length;
    }
  };

  return (
    <div>
      <p>press `.` to add 4 spaces:</p>
      <input
        onKeyDown={onKeyDown}
      />
    </div>
  );
};

ReactDOM.createRoot(document.querySelector("#app"))
  .render(<App />);
input {
  width: 100%;
}
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<div id="app"></div>
ggorlen
  • 44,755
  • 7
  • 76
  • 106
1

Don't mix direct DOM manipulation, whether that's vanilla JavaScript or jQuery, with React. There is no need to add an event handler with jQuery here, because your methods are already event handlers. Just use them directly:

    const focusHandler = (e) => {
        // handle the event here!
    }
Code-Apprentice
  • 81,660
  • 23
  • 145
  • 268
  • Thank you. That actually makes a lot of sense. The `event` parameter of an event listener already gives me everything I need. – Sam Liu Jul 08 '21 at 15:33
1

My solution:

const changeHandler = (event) => {
        const key = event.nativeEvent.data;
        if (key === ".") {
            event.preventDefault();
            const initialValue = event.target.value.split(".")[0];
            console.log(initialValue);
            setValue(initialValue + "    ");
        } else {
            setValue(event.target.value);
        }
};
Sam Liu
  • 157
  • 1
  • 8