커서야 어디가니

Input의 커서를 컨트롤 해봅니다

배경

페이먼츠 미션을 진행하는 도중 input의 커서가 자꾸 뒤로가는 문제를 만났습니다. 끝에서부터 입력하면 아무 문제 없지만, 중간에서 부터 입력하면 커서가 뒤로가서 불편했습니다.


왜 이런 문제가 발생하는지 그리고 어떻게 해결하는지 분석해 보았습니다.

정상 작동하는 경우

function App() {
  const [localCardNumber, setLocalCardNumber] = useState("");

  const handleValueChange = (event) => {
    const input = event.target;
    if (!input) return;

    setLocalCardNumber(input.value);
  };

  return (
    <input type="text" onChange={handleValueChange} value={localCardNumber} />
  );
}

실행해보기

이 코드는 중간에서 부터 입력해도 잘 작동합니다.

문제 상황

function App() {
  const [localCardNumber, setLocalCardNumber] = useState("");

  const handleValueChange = (event) => {
    const input = event.target;
    if (!input) return;

    setLocalCardNumber(input.value + "m");
  };

  return (
    <input type="text" onChange={handleValueChange} value={localCardNumber} />
  );
}

setLocalCardNumber(input.value + "m")input.value가 아닌 살짝 뒤에 m을 붙여서 update하면 중간에 있는 커서가 끝으로 가버립니다.

실행해보기

Why?

아마도 재조정 과정에서 inputvalue property가 달라져서 그 달라진 부분을 RealDOM에 반영(commit)했기 때문에 발생한게 아닐까 싶습니다.
그냥 m을 안붙이고 들어온 event.target.value를 쓰면 value가 달라지지 않았기 때문에 setState가 일어났지만 RealDOM에는 다시 그리지 않아서 커서가 유지되는것 같습니다.

그러면 VanilaJS에서는 어떻게 동작할까?

const $input = document.querySelector('input');
$input.addEventListener('input', (event) => {
  const input = event.target;
  const { value } = input;
  input.value = value + 'm';
});

실행해보기

엇! 이것도 똑같다!

VirtualDOM, RealDOM이런거랑 상관 없이 그냥 브라우저 입장에서 생각해 보자면

value의 길이가 달라져서,,커서를 어디다 둬야할지 모르기 때문에 그런건가?’ 하는 생각이 들었습니다.

function getRandomInt(min, max) {
  min = Math.ceil(min);
  max = Math.floor(max);
  return Math.floor(Math.random() * (max - min + 1)) + min;
}

const $input = document.querySelector("input");
$input.addEventListener("input", (event) => {
  const input = event.target;
  const { value } = input;
  if (value.length > 1) {
    const randomIndex = getRandomInt(0, value.length - 1);
    const arr = [...value];
    arr[randomIndex] = "m";
    input.value = arr.join("");
  }
});

랜덤 index를 구해서, m으로 교체해보았습니다. 그러면 전체 길이는 똑같기 때문입니다. 그럼 과연…?

실행해보기

그냥 커서가 맨 뒤로갑니다.

아무래도 브라우저 내부적으로 이전 value와 현재 value를 비교 해서 커서를 이동시키는게 아닐까 싶습니다.

그래서 이것은 브라우저의 작동 방식일뿐, 리액트의 재조정과는 상관이 없다고 생각합니다.

해결 방안

github issue를 보면 다양한 방법이 나오는데, 저는 입력시에 들어온 커서의 위치를 가지고 다시 커서를 해당 위치로 옮기는 방법으로 문제를 해결했습니다.

function App() {
  const [localCardNumber, setLocalCardNumber] = useState("");

  const handleValueChange = (event) => {
    const input = event.target;
    if (!input) return;
    const { selectionStart } = input;

    setLocalCardNumber(input.value + "m");
    queueMicrotask(() => {
      input.setSelectionRange(selectionStart, selectionStart);
    });
  };

  return (
    <input type="text" onChange={handleValueChange} value={localCardNumber} />
  );
}

input에 있는 selectionStart를 가지고 setSelectionRange로 커서의 위치를 이동시키면 됩니다. 그런데 테스트 결과 바로 input.setSelectionRange(selectionStart, selectionStart); 를 써도 이동이 안되었습니다.
실행해보기

setState이후 queueMicraoTaskrequestAnimationFrame으로 약간 지연시켜야 커서가 이동되었습니다.
실행해보기

그런데! VanilaJS는 그냥 바로 input.setSelectionRange해줘도 작동했습니다.
실행해보기

이 둘의 차이는 아마도 react내부적으로 setState를 처리하는 방식이 비동기적이어서 그런것 같습니다.

결론

리액트에서 (불가피하게) 사용자가 input에 입력하는 값을 조작해야 할때는 selectionStart, setSelectionRange를 활용하자!

Leave a Reply

Your email address will not be published.