예제는 여기서 확인할 수 있습니다! 예제 레포지토리
프로젝트에서 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-form
의 register
의 반환값 중 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-form
의 register
에서 반환되는 onChange
의 type을 확인 해보면
export type ChangeHandler = (event: {
target: any;
type?: any;
}) => Promise<void | boolean>;
아래와 같은데 target
과 type
이 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
의 값이 다 보이는것 같았다. target
의 name
을 사용해서 어떤 값의 change
인지 체크하고, target
의 type
이 있으면 getFieldValue()
를 사용해서 값을 가져오고 target
의 type
이 없으면 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()
를 확인해보니 event
가Object
고 target
이 존재하면 checkbox
인지 확인해서 맞다면 target
의 checked
, 아니라면 target
의 value
를 반환하고 event
가 Object
가 아니면 그냥 event
를 그대로 리턴한다.
나한테 필요한 함수는 getElementValue()
인것 같으니 이에 맞게 데이터의 형태를 유추해보자면 getElementValue()
를 사용하는 로직을 타기위해서 event
의 type
이 없어야 함으로 아래와 같다.
inteface Event {
target: {
name: string;
value: string;
};
그럼 이제 다시 내 코드로 돌아와서 RichTextEditor
의 props
에 name
을 추가해주고 그 값을 통해 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에서 아래와 같은 에러가 생길때
이것을 보고 해결하시면 됩니당.