react-hook-form + react-quill 미세팁 - 연동하기!

김장남·2023년 8월 9일
3

미세팁

목록 보기
2/3

예제는 여기서 확인할 수 있습니다! 예제 레포지토리

배경

프로젝트에서 WYSIWYG을 사용해야 하는 경우가 있는데 대부분의 경우 form의 형태로 사용할것같다. 내게도 그런 상황이 생겼는데 처음으로 경험해보는 일이었기 때문에 기록으로 남겨둔다. (기본적인 텍스트 데이터만 다룬다!)

해결하고 싶은 문제는 react-hook-form을 사용해서 react-quill의 유효성 검사를 하고싶었다.

조사

일반적으로 react-hook-form에서 html에서 제공하는 input, select등과 함께 쓰는것은 어렵지 않다. 공식홈페이지에 자세하게 설명되어있다.

다른 UI라이브러리와 함께쓰는 방법도 같이 설명되어있는데. Contrller를 사용하는 방법이다.

또 한가지 방법으로는 ref를 사용한 방법이 있다.

하지만 Controller를 사용한 방법을 사용하면 입력시마다 에디터가 랜더링 되는 현상이 발생했고, ref를 사용했을때는 값의 길이(min length, max length)를 검사하고 싶을때가 문제였다.

문제정의

react-quill에 정의된 onChage함수를 보면

interface ReactQuillProps {
  ...
	onChange?(value: string, delta: DeltaStatic, source: Sources, editor: UnprivilegedEditor): void;
  ...
}

위와 같은데 value 값을 확인해보면
html이 적용된 text가 나온다.

ex) <p> 안녕하세요 테스트 문구입니다. </p> 

하지만 내가 validation에서 확인 하고 싶은 값은 안녕하세요 테스트 문구입니다.라는 텍스트의 길이었다.

그것을 확인하기 위해서는 onChange 함수에서 넘겨주는 editor가 필요했다.
editor여러가지 기능이 있는데 나는 그중에서 getText()의 리턴값인 HMTL이 포함되지 않은 text가 필요했다. (getLength()가 아닌 이유는 나중에 설명)

어떻게 값을 가져와야 할지는 알아냈는데 또 다른 문제가 생겼다.
validation검사를 할때는 HTML이 포함되지 않은 text가 필요했고
form을 submit할 때는 HTML이 적용된 text가 필요했다.

해결방법 - 주의사항 읽어보시고 쓰세요

주의! - react-hook-form에서 공식적으로 제공하는 기능이 아니므로 라이브러리의 변동사항이 있을시에는 정상적으로 동작하지 않을 수도있습니다.

그럼 구조적으로 react-hook-form이 제공하는 방법을 통해서 해결 할 수가 없을것 같았다. (적어도 나는 방법을 찾지 못했다.)

그래서 그냥 간단하게 값을 두개를 저장해두면 되지 않을까? 하고 생각했고 실행에 옮겼다.

일반적으로 react-hook-form에서 register를 사용하는 방법은 아래와 같다.

import { useForm } from "react-hook-form";

export default function App() {
  const { register, handleSubmit } = useForm();
  const onSubmit = data => console.log(data);
   
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("firstName")} />
      <select {...register("gender")}>
        <option value="female">female</option>
        <option value="male">male</option>
        <option value="other">other</option>
      </select>
      <input type="submit" />
    </form>
  );
}

나는 이렇게 사용했다.

import { useForm } from "react-hook-form";
import Form from "../ui/Form";
import RichTextEditor from "../ui/RichTextEditor";

const TEXT_EDITOR_NAME = "richtext";
const TEXT_EDITOR_VALIDATION = { required: true, maxLength: 20, minLength: 5 };

export const getTextEditorWithHtmlName = (name: string) => `${name}_html`;
export default function TestForm() {
  const { register } = useForm();

  const withoutHtmlRegister = register(TEXT_EDITOR_NAME, TEXT_EDITOR_VALIDATION);
  const withHtmlRegister = register(getTextEditorWithHtmlId(TEXT_EDITOR_NAME));

  return (
    <Form>
      <RichTextEditor onChange={withoutHtmlRegister.onChange} />
      <button>검사!</button>
    </Form>
  );
}

register를 두번 사용하고 withoutHtmlRegister에는 validation에 사용할 옵션을 넣어주고 witHtmlRegister에는 id의 뒤에 _html을 붙인 값을 사용해 react-hook-form이 관리할 데이터에 임의로 넣어주었다. (input hidden의 값과 비슷하지 않을까..?)

그리고 react-hook-formregister의 반환값 중 onChange만 사용하면 돼서 RichTextEditor(react-quill wrapper)에 넘겨주었다.

// ui/RichTextEditor.tsx
import { ChangeHandler } from "react-hook-form";
import ReactQuill from "react-quill";
import "react-quill/dist/quill.snow.css";

export default function RichTextEditor({
  onChange,
}: {
  onChange: ChangeHandler;
}) {
  const handleOnChange = (
    value: string,
    d: any,
    s: any,
    editor: ReactQuill.UnprivilegedEditor
  ) => {
    const text = value; // html이 포함된 text
    const withHtmlText = editor.getText(); // html이 포함되지 않은 text
  };
  return <ReactQuill onChange={handleOnChange} />;
}

RichTextEditor컴포넌트는 위와 같이 생겼다.

이제 react-hook-form의 라이프사이클에서 onChange가 어떤방식으로 동작하는지만 알면 그 동작에 끼워맞춰 보면 될것같아서 react-hook-form소스를 한번 보기로 했다.(react-hook-form github)

레포지토리에서 검색창을 켜서 onChange를 검색하니 함수가 선언된곳은 한곳이었다.

일단 react-hook-formregister에서 반환되는 onChange의 type을 확인 해보면

export type ChangeHandler = (event: {
    target: any;
    type?: any;
}) => Promise<void | boolean>;

아래와 같은데 targettype이 any타입이라서 무엇을 넣어줘야 react-hook-form이 일반적으로 동작할때와 같은 동작을 할까 궁금해 하면서 코드를 확인했다.

  const onChange: ChangeHandler = async (event) => {
    const target = event.target;
    let name = target.name;
    let isFieldValueUpdated = true;
    const field: Field = get(_fields, name);
    const getCurrentFieldValue = () =>
      target.type ? getFieldValue(field._f) : getEventValue(event);

    if (field) {
      let error;
      let isValid;
      const fieldValue = getCurrentFieldValue();
      const isBlurEvent =
        event.type === EVENTS.BLUR || event.type === EVENTS.FOCUS_OUT;
      const shouldSkipValidation =
        (!hasValidation(field._f) &&
          !_options.resolver &&
          !get(_formState.errors, name) &&
          !field._f.deps) ||
        skipValidation(
          isBlurEvent,
          get(_formState.touchedFields, name),
          _formState.isSubmitted,
          validationModeAfterSubmit,
          validationModeBeforeSubmit,
        );
      const watched = isWatched(name, _names, isBlurEvent);

간단하게 유추해보자면

	const target = event.target;
	let name = target.name
	...
	const getCurrentFieldValue = () => 
		target.type ? getFieldValue(field._f) : getEventValue(event);

에서 event의 값이 다 보이는것 같았다. targetname을 사용해서 어떤 값의 change인지 체크하고, targettype이 있으면 getFieldValue() 를 사용해서 값을 가져오고 targettype이 없으면 getEventValue()를 사용해서 값을 가져온다.

그럼 여기서 한번더 getFieldValue()와 getEventValue()를 확인해서 onChange에 넘길 값을 확정하면 될것 같다.

// getFieldValue.ts
export default function getFieldValue(_f: Field['_f']) {
  const ref = _f.ref;

  if (_f.refs ? _f.refs.every((ref) => ref.disabled) : ref.disabled) {
    return;
  }

  if (isFileInput(ref)) {
    return ref.files;
  }

  if (isRadioInput(ref)) {
    return getRadioValue(_f.refs).value;
  }

  if (isMultipleSelect(ref)) {
    return [...ref.selectedOptions].map(({ value }) => value);
  }

  if (isCheckBox(ref)) {
    return getCheckboxValue(_f.refs).value;
  }

  return getFieldValueAs(isUndefined(ref.value) ? _f.ref.value : ref.value, _f);
}

일단 getFieldValue()ref를 사용하니 일단 넘어가자.

// getEventValue.ts
import isCheckBoxInput from '../utils/isCheckBoxInput';
import isObject from '../utils/isObject';

type Event = { target: any };

export default (event: unknown) =>
  isObject(event) && (event as Event).target
    ? isCheckBoxInput((event as Event).target)
      ? (event as Event).target.checked
      : (event as Event).target.value
    : event;

getElementValue()를 확인해보니 eventObjecttarget이 존재하면 checkbox인지 확인해서 맞다면 targetchecked, 아니라면 targetvalue를 반환하고 eventObject가 아니면 그냥 event를 그대로 리턴한다.

나한테 필요한 함수는 getElementValue()인것 같으니 이에 맞게 데이터의 형태를 유추해보자면 getElementValue()를 사용하는 로직을 타기위해서 eventtype이 없어야 함으로 아래와 같다.

inteface Event {
  target: {
    name: string;
    value: string;
  };

그럼 이제 다시 내 코드로 돌아와서 RichTextEditorpropsname을 추가해주고 그 값을 통해 onChange를 호출 해주면 될것같다.

import { ChangeHandler } from "react-hook-form";
import ReactQuill from "react-quill";
import "react-quill/dist/quill.snow.css";
import { getTextEditorWithHtmlName } from "../components/TextForm";

export default function RichTextEditor({
  onChange,
  name,
}: {
  onChange: ChangeHandler;
  name: string;
}) {
  const handleOnChange = (
    value: string,
    d: any,
    s: any,
    editor: ReactQuill.UnprivilegedEditor
  ) => {
    const text = value; // html이 포함된 text
    const withHtmlText = editor.getText(); // html이 포함되지 않은 text
    onChange({ target: { name, value: withHtmlText } });
    onChange({
      target: { name: getTextEditorWithHtmlName(name), value: text },
    });
  };
  return <ReactQuill onChange={handleOnChange} />;
}

두개의 값이 잘 들어가있다.

마지막으로 error를 표시하고 submit함수를 맞게 수정해주면 될것같다.

import { ErrorOption, SubmitHandler, useForm } from "react-hook-form";
import Form from "../ui/Form";
import RichTextEditor from "../ui/RichTextEditor";

const TEXT_EDITOR_NAME = "richtext";
const TEXT_EDITOR_VALIDATION = { required: true, maxLength: 20, minLength: 5 };

type ValidationType = keyof typeof TEXT_EDITOR_VALIDATION;
type Validation = typeof TEXT_EDITOR_VALIDATION;

export const getTextEditorWithHtmlName = (name: string) => `${name}_html`;

const getFormErrorByType = (
  type: ErrorOption["type"],
  validation: Validation
) => {
  switch (type) {
    case "required": {
      return "필수 입력 필드입니다.";
    }
    case "maxLength": {
      return `필드의 최대 입력 길이는 ${validation.maxLength}입니다.`;
    }
    case "minLength": {
      return `필드의 최소 입력 길이는 ${validation.minLength}입니다.`;
    }
  }
};

export default function TestForm() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm();

  const withoutHtmlRegister = register(
    TEXT_EDITOR_NAME,
    TEXT_EDITOR_VALIDATION
  );

  register(getTextEditorWithHtmlName(TEXT_EDITOR_NAME));

  const onSubmit: SubmitHandler<any> = (data) => {
    console.log(data);
    // 값을 서버에 보내야 할 때는 `${name}_html`의 값을 보내면 된다!
  };

  return (
    <div className="p-8 w-10/12">
      <Form onSubmit={handleSubmit(onSubmit)}>
        <RichTextEditor
          onChange={withoutHtmlRegister.onChange}
          name={TEXT_EDITOR_NAME}
        />
        <p className="text-rose-500">
          {errors[TEXT_EDITOR_NAME]?.type
            ? getFormErrorByType(
                errors[TEXT_EDITOR_NAME].type as ValidationType,
                TEXT_EDITOR_VALIDATION
              )
            : ""}
        </p>
        <button className="bg-blue-500 text-white p-3 rounded-xl">검사!</button>
      </Form>
    </div>
  );
}

아래와 같이 잘 동작한다.(\n을 하나의 글자로 치는 부분은 필요에 따라 수정하면 될것같담)

추가 미세팁

nextjs에서 아래와 같은 에러가 생길때

이것을 보고 해결하시면 됩니당.

profile
React 개발자

0개의 댓글