공부 및 일상기록

useReducer를 활용하여 유효성 검사 만들기 본문

개발/React

useReducer를 활용하여 유효성 검사 만들기

낚시하고싶어요 2023. 8. 4. 03:21

유효성검사를 할 때, react-hook-form을 사용하거나, useState로 각각의 상태를 만들고 검증하는 방법을 선택했었다.

 

뭔가 커스텀하기엔 직접 만들어서 사용하는게 훨씬 편해서 react-hook-form을 자주 사용하지 않았었는데,,

 

여러가지 유효성을 검사하기 시작하면 useState로 관리하는게 너무 지저분하고 코드를 보는동안 혼자서 혼란에 빠지곤 했다.

 

그래서 상태를 좀 더 복잡하고 의존적이게 다룰 수 있는 useReducer를 통해서 유효성 검사를 할 수 있다는 것을 알게 되어 한번 도전해봤다.

 

 

* useReducer 이해하고 넘어가기

const [state, dispatch] = useReducer(reducer, initialArg);
useState의 대체 함수입니다. (state, action) => newState의 형태로 reducer를 받고 dispatch 메서드와 짝의 형태로 현재 state를 반환합니다. (Redux에 익숙하다면 이것이 어떻게 동작하는지 여러분은 이미 알고 있을 것입니다.)  

 

위는 리액트 공식 홈페이지에 쓰여있는 useReducer에 대한 내용이다. 리덕스를 사용해봤다면 사실 useReducer는 정말 쉽게 사용할 수 있다. 먼저 리듀서가 반환하는 두가지는 state와 dispatch이다. dispatch는 reducer 함수에 액션을 전달하는 역할을 한다. 따라서 상태에 필요한 처리를 하고 싶을 때, 적절한 액션을 넘겨주면 해당 액션을 reducer 함수가 받아서 새로운 state를 반환하게 되는 것이다. useReducer의 두번째 매개변수는 초기 상태이다.

 

 

그럼 이제 유효성검사를 직접 만들어 보자.

 

1. reducer() 함수 만들기

const inputReducer = (state, action) => {
  switch (action.type) {
    case "CHANGE":
      return {
        ...state,
        value: action.val,
        isValid: validate(action.val, action.validators),
      };
    case "TOUCH":
      return {
        ...state,
        isTouched: true,
      };
    default:
      return state;
  }
};

리듀서 함수에는 state와 action이 전달된다. 이는 useReducer에서 넘겨주게 된다. 

먼저 어떤 액션에 동작할지 switch case 문을 통해 액션별 적용할 동작을 정한다.

 

** 나는 액션객체로 type, val, validators세가지 항목을 넘겨줄건데, type은 어떤 액션을 동작할지를 정하고, val은 입력받은 input값, validators에는 유효성 검사를 하고싶은 목록을 배열 형태로 넘길 것이다.

 

나는 CHANGE액션과 TOUCH 액션을 만들었다. CHANGE는 input값이 변할 때 실행되도록 하였다. CHANGE 액션이 동작하면 기존 state에서 value를 input에서 입력받은 val로 넣고, isValid에 validate함수를 통해 유효성 검사가 true인지 false인지 넘겨준다.

 

TOUCH 액션은 초기 상태에 대한 허용을 위해 넣어준다. 만약 유효성 검사에서 글자가 하나라도 쓰여 있어야 한다는 검사가 있다면, 해당 input은 초기부터 유효성 검사를 통과하지 못했다는 경고를 준다. 하지만 사용자가 이제 막 해당 input UI를 만났다면, 최초 클릭을 하기 전에 경고를 받는게 어색해 보인다. 그래서 input을 건들기 전 한번의 허용을 위해 해당 액션을 넣는다.

 

2. useReducer() 생성

const [inputState, dispatch] = useReducer(inputReducer, {
    value: "",
    isValid: false,
    isTouched: false,
  });

useReducer는 useState와 비슷하게 생각하면 된다. 첫 번째 반환값으로 상태를 반환하고, 두 번째 반환값으로 해당 상태를 수정하게 되는 것이다. 다만 좀 다른건 첫 번째 매개변수(inputReducer)로 리듀서함수를 넣어서 리듀서로 처리된 상태를 반환하는 것이다.

 

3. validate() 함수 생성

const VALIDATOR_TYPE_REQUIRE = "REQUIRE";
const VALIDATOR_TYPE_MINLENGTH = "MINLENGTH";
const VALIDATOR_TYPE_MAXLENGTH = "MAXLENGTH";
const VALIDATOR_TYPE_MIN = "MIN";
const VALIDATOR_TYPE_MAX = "MAX";
const VALIDATOR_TYPE_EMAIL = "EMAIL";
const VALIDATOR_TYPE_FILE = "FILE";

interface ValidateType {
  type: string;
  val?: number;
}

export const VALIDATOR_REQUIRE = () => ({ type: VALIDATOR_TYPE_REQUIRE });
export const VALIDATOR_FILE = () => ({ type: VALIDATOR_TYPE_FILE });
export const VALIDATOR_MINLENGTH = (val: number) => ({
  type: VALIDATOR_TYPE_MINLENGTH,
  val: val,
});
export const VALIDATOR_MAXLENGTH = (val: number) => ({
  type: VALIDATOR_TYPE_MAXLENGTH,
  val: val,
});
export const VALIDATOR_MIN = (val: number) => ({
  type: VALIDATOR_TYPE_MIN,
  val: val,
});
export const VALIDATOR_MAX = (val: number) => ({
  type: VALIDATOR_TYPE_MAX,
  val: val,
});
export const VALIDATOR_EMAIL = () => ({ type: VALIDATOR_TYPE_EMAIL });

export const validate = (value: string, validators: ValidateType[]) => {
  let isValid = true;
  for (const validator of validators) {
    if (validator.type === VALIDATOR_TYPE_REQUIRE) {
      isValid = isValid && value.trim().length > 0;
    }
    if (
      validator.type === VALIDATOR_TYPE_MINLENGTH &&
      validator.val !== undefined
    ) {
      isValid = isValid && value.trim().length >= validator.val;
    }
    if (
      validator.type === VALIDATOR_TYPE_MAXLENGTH &&
      validator.val !== undefined
    ) {
      isValid = isValid && value.trim().length <= validator.val;
    }
    if (validator.type === VALIDATOR_TYPE_MIN && validator.val !== undefined) {
      isValid = isValid && +value >= validator.val;
    }
    if (validator.type === VALIDATOR_TYPE_MAX && validator.val !== undefined) {
      isValid = isValid && +value <= validator.val;
    }
    if (validator.type === VALIDATOR_TYPE_EMAIL) {
      isValid = isValid && /^\S+@\S+\.\S+$/.test(value);
    }
  }
  return isValid;
};

validate함수를 통해 유효성을 검사하게 된다. 

validate함수는 두개의 매개변수 (value, validators)를 받는다.

value는 input을 통해 받은 값이다. 이 값은 dispatch 함수가 실행될 때 넘겨줄 것이므로 action객체를 통해 validate로 전달된다.

validators는 유효성 검사를 할 목록이다. 배열 형태로 이뤄져 있으며, 위 코드에서 validate함수 위에 적힌 함수들이 들어오게 된다.

 

4. dispatch 작성

const changeHandler = (
    e: ChangeEvent<HTMLInputElement> | ChangeEvent<HTMLTextAreaElement>
  ) => {
    dispatch({ type: "CHANGE", val: e.target.value, validators: validators });
  };

 const touchHandler = () => {
    dispatch({ type: "TOUCH" });
  };

input의 이벤트값을 검증할 것이므로, change이벤트를 처리하는 함수에 dispatch를 넣어서 액션객체를 생성한다. 

changeHandler 함수에는 CHANGE 액션을 넘겨서 val로 새로운 input값을 넘기고, 검증할 validators들을 넘긴다.

**여기서 validators는 인풋을 사용할 곳에서 props로 넘겨서 위처럼 변수값으로 넣어준것이다.

 

touchHandler 함수에는 TOUCH 액션을 넘겨서 해당 input을 한번이라도 건드렸는지를 감시한다.

 

5. input에 changeHandler와 touchHandler 삽입

<StInput
        id={id}
        type={type}
        placeholder={placeholder}
        onChange={changeHandler}
        onBlur={touchHandler}
        value={inputState.value}
      />

나는 styled-components로 input을 만들어서 이름이 StInput이다. 이는 그냥 스타일이 적용된 Input이므로 똑같이 사용하면 된다.

onChange에는 changeHandler를 넣고, onBlur에 touchHandler를 넣는다.

** onBlur는 해당 input에서 포커스가 벗어났을때 발생하는 이벤트이다. 따라서 input을 클릭했다가 아무것도 작성하지 않더라도 다른곳을 클릭하여 focus를 빠져나간다면 TOUCH액션이 동작하게 된다.

 

6. 해당 Input컴포넌트 사용하기

  <Input
        element="input"
        type="text"
        label="Title"
        errorText="유효한 제목을 입력하세요."
        validators={[VALIDATOR_REQUIRE()]}
      />

여기서 봐야하는 부분은 validators이다. 위의 4번 설명에서 dispatch에 validators에 validators라는 변수를 넘겨주었는데, 이처럼 props를 통해서 넘긴 것이다. 해당 배열 안에는 내가 검증하고 싶은 부분들을 (3번 설명에서 validate함수 위에 적었던 검사들..) 넣어주면 된다.

 

7. Input 컴포넌트 

사실 설명만 들으면 이런 방식이구나 하겠지만 완벽한 이해는 되지 않을 것이다. 따라서 직접 작성한 Input컴포넌트를 참고한다면 쉽게 이해 할 것이다.

import React, { ChangeEvent, useReducer, useState } from "react";
import { styled } from "styled-components";
import { validate } from "../../util/validator";

interface InputProps {
  id?: string;
  label: string;
  element: string;
  type?: string;
  placeholder?: string;
  rows?: number;
  errorText?: string;
  validators?: any;
}

const inputReducer = (state: any, action: any) => {
  switch (action.type) {
    case "CHANGE":
      return {
        ...state,
        value: action.val,
        isValid: validate(action.val, action.validators),
      };
    case "TOUCH":
      return {
        ...state,
        isTouched: true,
      };
    default:
      return state;
  }
};

export default function Input({
  id,
  label,
  element,
  type,
  placeholder,
  rows,
  errorText,
  validators,
}: InputProps) {
  const [inputState, dispatch] = useReducer(inputReducer, {
    value: "",
    isValid: false,
    isTouched: false,
  });

  const changeHandler = (
    e: ChangeEvent<HTMLInputElement> | ChangeEvent<HTMLTextAreaElement>
  ) => {
    dispatch({ type: "CHANGE", val: e.target.value, validators: validators });
  };

  const touchHandler = () => {
    dispatch({ type: "TOUCH" });
  };

  const inputElement =
    element === "input" ? (
      <StInput
        id={id}
        type={type}
        placeholder={placeholder}
        onChange={changeHandler}
        onBlur={touchHandler}
        value={inputState.value}
      />
    ) : (
      <StTextarea
        id={id}
        rows={rows || 3}
        onChange={changeHandler}
        onBlur={touchHandler}
        value={inputState.value}
      />
    );

  return (
    <StContainer>
      <StLabel htmlFor={id}>{label}</StLabel>
      {inputElement}
      {!inputState.isValid && inputState.isTouched && <p>{errorText}</p>}
    </StContainer>
  );
}

const StContainer = styled.div``;

const StLabel = styled.label``;
const StInput = styled.input``;
const StTextarea = styled.textarea``;

사실 색다른 기능이 있어서 블로그 포스팅을 한다기 보다, 그동안 useState만 사용해서 로컬상태를 관리했는데, useReducer를 통해서 뭔가 복잡한 처리를 통한 상태 관리가 필요한 경우 한 곳에서 쉽게 처리할 수 있다는걸 배우게 되어서 포스팅하였다.