avatar
Ganymedian
#4. Form handling in React
Jul 13
·
55 min read

  1. Workthrough for prev. project

  2. Form Basics : Controlled Input & UnControlled Input

  3. React & Forms

  4. React-Hook-Form

  5. Zod + Resolver

  6. Form Component & UI Component

  7. fourth project : React-Hook-Form Project

Workthrough for prev. project

지난번 과제는, useAxiosFetch 커스텀 훅을 CRUD 모두가 가능한 버전으로 업그레이드 하는 것이었죠.

먼저 코드를 봅시다.

// /src/hooks/useAxiosFetch2.jsx
import { useState, useCallback } from 'react';
import axiosInstance from "../util/axiosInstance";

function useAxiosFetch() {
  const [state, setState] = useState({
    data: null,
    loading: false,
    error: null,
  });

  const fetchData = useCallback(async (endpoint, config = {}) => {
    setState(prevState => ({ ...prevState, loading: true }));
    try {
      const response = await axiosInstance.request({
        url: endpoint,
        ...config,
      });
      setState({
        data: response.data,
        loading: false,
        error: null,
      });
      return response.data;
    } catch (error) {
      setState({
        data: null,
        loading: false,
        error: error,
      });
      throw error;
    }
  }, []);

  return { fetchData, ...state };
}

export default useAxiosFetch;

이 커스텀 훅은,

  1. fetch 를 담당하는 펑션 fetchData

  2. fetched data

  3. loading state

  4. error object

를 담은 object 를 리턴합니다.

useAxiosFetch 훅의 작동 원리를 자세히 살펴보자면….

  1. 상태 관리: useAxiosFetch 훅은 내부적으로 React의 useState 훅을 사용하여 state를 관리합니다. 이 statedata, loading, error 값을 포함합니다.

  2. 상태 업데이트: fetchData 함수 내에서 setState를 호출하여 이 상태를 업데이트합니다. 예를 들어, 요청이 시작될 때 loadingtrue로 설정하고, 요청이 완료되면 data를 업데이트하고 loadingfalse로 설정합니다.

  3. React의 리렌더링: setState가 호출되면 React는 해당 컴포넌트를 리렌더링합니다. 이는 useAxiosFetch를 사용하는 컴포넌트가 자동으로 새로운 상태 값으로 리렌더링된다는 것을 의미합니다.

  4. 클로저(Closure): fetchData 함수는 useCallback을 통해 메모이제이션되며, 이 함수는 클로저를 통해 현재의 setState 함수에 접근할 수 있습니다. 따라서 fetchData가 호출될 때마다 최신의 setState 함수를 사용하여 상태를 업데이트할 수 있습니다.

  5. 훅의 반환 값: useAxiosFetch 훅은 { fetchData, ...state }를 반환합니다. 이는 현재의 state 값(즉, data, loading, error)과 fetchData 함수를 함께 반환한다는 의미입니다.

작동 과정을 단계별로 따라가면 다음과 같습니다:

  1. 컴포넌트가 useAxiosFetch를 호출합니다.

  2. 훅은 초기 상태와 fetchData 함수를 반환합니다.

  3. 컴포넌트에서 fetchData를 호출합니다.

  4. fetchData 내부에서 setState를 사용해 loadingtrue로 설정합니다.

  5. React가 이 상태 변경을 감지하고 컴포넌트를 리렌더링합니다.

  6. API 요청이 완료되면 fetchData는 다시 setState를 호출하여 data를 업데이트하고 loadingfalse로 설정합니다.

  7. React가 다시 이 상태 변경을 감지하고 컴포넌트를 리렌더링합니다.

이러한 방식으로, 컴포넌트 내부에서 직접적인 업데이트 코드 없이도 fetchData의 실행 상태가 실시간으로 data, loading, error 값에 반영됩니다. 이는 React의 상태 관리 메커니즘과 훅의 클로저 특성을 활용한 결과입니다.

useAxiosFetch 훅이 실제로 사용되는 코드는..

// example: UserManagement.jsx
import React, { useEffect } from 'react';
import useAxiosFetch from './useAxiosFetch';

function UserManagement() {
  const { fetchData, data, loading, error } = useAxiosFetch();

  // Create (POST) - 새 사용자 생성
  const createUser = async (userData) => {
    try {
      const newUser = await fetchData('/users', {
        method: 'POST',
        data: userData,
        headers: { 'Content-Type': 'application/json' },
      });
      console.log('Created user:', newUser);
    } catch (err) {
      console.error('Error creating user:', err);
    }
  };

  // Read (GET) - 모든 사용자 조회
  const getUsers = async () => {
    try {
      await fetchData('/users');
      // 'data' 상태가 자동으로 업데이트됩니다.
    } catch (err) {
      console.error('Error fetching users:', err);
    }
  };

  // Read (GET) - 특정 사용자 조회
  const getUser = async (userId) => {
    try {
      const user = await fetchData(`/users/${userId}`);
      console.log('Fetched user:', user);
    } catch (err) {
      console.error('Error fetching user:', err);
    }
  };

  // Update (PUT) - 사용자 정보 업데이트
  const updateUser = async (userId, userData) => {
    try {
      const updatedUser = await fetchData(`/users/${userId}`, {
        method: 'PUT',
        data: userData,
        headers: { 'Content-Type': 'application/json' },
      });
      console.log('Updated user:', updatedUser);
    } catch (err) {
      console.error('Error updating user:', err);
    }
  };

  // Delete (DELETE) - 사용자 삭제
  const deleteUser = async (userId) => {
    try {
      await fetchData(`/users/${userId}`, {
        method: 'DELETE',
      });
      console.log('User deleted successfully');
    } catch (err) {
      console.error('Error deleting user:', err);
    }
  };

  useEffect(() => {
    getUsers(); // 컴포넌트 마운트 시 사용자 목록 조회
  }, []);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return (
    <div>
      <h1>User Management</h1>
      {data && (
        <ul>
          {data.map(user => (
            <li key={user.id}>{user.name}</li>
          ))}
        </ul>
      )}
      {/* 여기에 사용자 생성, 수정, 삭제를 위한 UI 요소들을 추가할 수 있습니다 */}
    </div>
  );
}

export default UserManagement;

이렇게 되겠죠.

useAxiosFetch 커스텀 훅은 오늘 배우게 될 코드에서 몇차례 반복해서 사용됩니다.

#4. Form handling in React

Form Basics : Controlled Input & UnControlled Input

Controlled Input & Uncontrolled Input

React에서는 input 엘리먼트를 제어하는 방식으로 두 가지 주요 접근 방식이 있습니다:

Controlled Input과 Uncontrolled Input입니다.

이 두 방식은 input 엘리먼트의 밸류를 관리하는 방식에서 근본적인 차이가 있죠.

Controlled Input

Controlled Input은 input 엘리먼트의 값이 컴포넌트의 상태(state)에 의해 제어되는 방식입니다.

입력 값은 상태로 관리되며, 입력 값이 변경될 때마다 상태가 업데이트되고, 그 상태가 다시 input 엘리먼트의 value 속성에 반영됩니다.

  1. 사용자가 input field 에 key 입력을 한다.

  2. 입력된 키 밸류가 즉시 state 로 업데이트된다.

  3. state 로 등록된 입력 밸류가 input field 의 밸류로 강제할당 된다.

사용자의 입력을 가로채서 state 로 등록한 후에 사용자가 입력한 input 에 다시 강제할당한다는 게, 조금 복잡하고 무의미하게 보일 수도 있습니다.

하지만, 사용자의 키 입력에 즉각 반응해야만 하는 상황에서는 유용할 수 있겠죠.

// Controlled Input
// 기존 html 의 form 요소 속성이 JSX 에서 바뀐 두가지에 주의하세요.
// class="" --> className=""
// label for="" --> label htmlFor=""
import React, { useState } from 'react';

function ControlledInputExample() {
  const [name, setName] = useState('');

  const handleChange = (event) => {
    setName(event.target.value);
  };

  return (
    <div>
      <h2>Controlled Input Example</h2>
      <label htmlFor="name" className="">Your name</label>
      <input type="text" name="name" id="name" value={name} onChange={handleChange} />
      <p>The input value is: {name}</p>
    </div>
  );
}

export default ControlledInputExample;
  1. 상태 관리: useState를 사용하여 name 상태를 관리합니다.

  2. 입력 값 변경: input 엘리먼트의 value 속성은 상태 name으로 설정되며, onChange 이벤트 핸들러에서 상태가 업데이트됩니다.

  3. 렌더링: 상태가 변경되면 컴포넌트가 다시 렌더링되며, 새로운 상태가 input 엘리먼트의 value 속성에 반영됩니다.

Uncontrolled Input

Uncontrolled Input 은 input 엘리먼트의 값을 컴포넌트의 상태로 제어하지 않고, DOM 자체가 값을 관리하도록 하는 방식입니다. 이 경우 초기 값은 defaultValue 속성으로 설정됩니다.

Uncontrolled Input 에서는, state 가 없으므로, input 요소에 직접 접근하거나 제어할 수 가 없겠죠.

앞 장에서 배웠던, Dom Anchor 로 기능하는, useRef 가 여기에서 유용하게 사용됩니다.

// Uncontrolled input & useRef
import React, { useRef } from 'react';

function UncontrolledInputExample() {
  const inputRef = useRef(null);

  const handleSubmit = (event) => {
    event.preventDefault();
    alert(`The input value is: ${inputRef.current.value}`);
  };

  return (
    <div>
      <h2>Uncontrolled Input Example</h2>
      <form onSubmit={handleSubmit}>
        <input type="text" defaultValue="Initial Value" ref={inputRef} />
        <button type="submit">Submit</button>
      </form>
    </div>
  );
}

export default UncontrolledInputExample;
  1. ref 사용: useRef 훅을 사용하여 input 엘리먼트에 대한 참조를 생성합니다.

  2. 초기 값 설정: defaultValue 속성을 사용하여 input 엘리먼트의 초기 값을 설정합니다.

  3. 값 접근: input 엘리먼트의 값 등 속성에 직접 접근할 때에는 ref를 사용합니다.

Controlled vs. Uncontrolled Inputs 요약

  • Controlled Input: 컴포넌트의 상태가 input 엘리먼트의 값을 제어합니다. 사용자의 입력 값이 즉시 state 에 반영됩니다.

  • Uncontrolled Input: DOM 자체가 input 엘리먼트의 값을 관리합니다. 초기 값은 defaultValue 속성으로 설정하고, 이후 값은 ref를 통해 접근합니다.

.

.

React & Forms

Form Action

HTML Form 의 디폴트 처리방식에서는, URL 기반의 액션이 트리거 된다는 문제가 있습니다.

즉, Form 이 제출 되었을 때, 브라우저의 기본 처리방식은, Form.acction 에서 지정된 URL 과 지정된 메서드(GET/POST) 을 사용하는 URL 이동이 트리거 된다는 것이죠.

고전적인 HTML 코딩에서는 문제될 것이 없지만, 이 특성은 React 와는 공존할 수 없는 문제가 됩니다.

React 는 상태 state 기반 태스크 처리 방식으로 작동하죠.

상태 state 는 현재 페이지의 WEB App 내에서만 유효합니다. React App 에서 작성하고 작업한 모든 state 들은, 페이지를 이동하거나 refresh 되는 순간에 모두 사라져 버립니다.

따라서 React 에서의 Form 핸들링은, 먼저 e.preventDefault() 로 정상적인 폼 제출을 가로챈 후, React 방식의 프로그래밍으로 폼 제출 데이터와 흐름을 관리합니다. 그래야만 refresh 또는 페이지 이동이 발생하지 않겠죠.

// /src/study/lecture4/FormExample.jsx
import React, { useRef } from 'react';
import useAxiosFetch from '@/hooks/useAxiosFetch';

function FormExample() {
	const nameRef = useRef();
	const ageRef = useRef();
  const { fetchData, data, loading, error } = useAxiosFetch();

  const handleSubmit = async (event) => {
    event.preventDefault();
	  const formData = new FormData(event.target);
    const headers = { 'Content-Type': 'application/json' };

    try {
      await fetchData('/users', {
        method: 'POST',
        data: formData,
        headers,
      });
      // 폼 제출 성공 후 입력 필드 초기화
      nameRef.current.value = '';
      ageRef.current.value = '';
    } catch (error) {
      console.error('Error:', error);
    }
  };
  
	if (loading) return <div>Loading...</div>;
	if (error) return <div>Error: {error.message}</div>;

  return (
    <div>
      <h2>React Form Handling Example</h2>
      <form onSubmit={handleSubmit}>
        <div>
          <label>
            Name:
            <input
              type="text"
              name="name"
              ref={nameRef}
            />
          </label>
        </div>
        <div>
          <label>
            Age:
            <input
              type="number"
              name="age"
              ref={ageRef}
            />
          </label>
        </div>
        <button type="submit">Submit</button>
      </form>
    </div>
  );
}

export default FormExample;

코드 자체로는 특별한 게 없죠? 지난 장에서 작업한, useAxiosFetch 커스텀 훅을 꺼내와 사용하였고, 유용하게 작동하고 있는 것 같습니다.

주의해서 관찰해야 할 점은, 여기서 Uncontrolled Input 이 사용되었다는 점이죠. Uncontrolled Input 이 사용되었고, Form 제출 이후에 Form 의 리셋을 위해서 useRef 가 사용되었습니다.

주어진 태스크 가 단순히, Form Data 를 제출하고 그 결과 메세지 만을 출력하는 것이기 때문에, 굳이 input 이 state 로 관리되어야 할 필요는 없는 것 같습니다.

그런데 만약에, name 의 밸리데이션 규칙에 length.min(3) 같은 조건이 있다면 어떨까요? email 인풋 필드가 추가되고, email 형식에 대한 밸리데이션 규칙이 추가된다면요?

사용자는 사용자의 키 입력에 실시간으로 반응하고 밸리데이션 규칙을 통과했는지를 실시간으로 표시해주는 경험을 원할 것입니다.

Form Validation in React

외부 라이브러리를 사용하지 않고 Core React 만을 사용했을 때, 폼 밸리데이션은 아래와 같은 모습을 하게 될 것입니다.

사용자의 키 입력에 실시간으로 반응하고 밸리데이션 룰을 통과했는지 여부의 메세지를 출력하는 기능을 포함시켜 보겠습니다.

// /src/study/lecture4/FormValidation.jsx
import { useState, useEffect } from "react";
import useAxiosFetch from "/src/hooks/useAxiosFetch";

function FormValidation() {
  const [name, setName] = useState("");
  const [email, setEmail] = useState("");
  const [errors, setErrors] = useState({});
  const { fetchData, data, loading, error } = useAxiosFetch();

  const validateField = (fieldName, value) => {
    let error = "";
    switch (fieldName) {
      case "name":
        if (!value.trim()) {
          error = "이름을 입력해주세요.";
        } else if (value.length < 3) {
          error = "이름은 3 글자 이상.";
        }
        break;
      case "email":
        if (!value.trim()) {
          error = "이메일을 입력해주세요.";
        } else if (!/\\S+@\\S+\\.\\S+/.test(value)) {
          error = "유효한 이메일 주소를 입력해주세요.";
        }
        break;
      default:
        break;
    }
    return error;
  };

  // name 필드에 변화가 발생할 때마다 트리거 되면서 setErrors 스테이트 업데이트 펑션이 실행됩니다.
  useEffect(() => {
    setErrors((prevErrors) => ({
      ...prevErrors,
      name: validateField("name", name),
    }));
  }, [name]);

  // email 필드에 변화가 발생할 때마다 트리거 되면서, setErrors 펑션에 email 밸리데이션 결과를 업데이트합니다.
  useEffect(() => {
    setErrors((prevErrors) => ({
      ...prevErrors,
      email: validateField("email", email),
    }));
  }, [email]);

  const serverActionFunc = async (event) => {
    event.preventDefault();
    if (!errors.name && !errors.email) {
      console.log("Form is valid");
      const formData = { name, email };
      const headers = { "Content-Type": "application/json" };
      try {
        await fetchData("/users", {
          method: "POST",
          data: formData,
          headers,
        });
        // 폼 제출 성공 후 입력 필드 초기화
        setName("");
        setEmail("");
      } catch (error) {
        console.error("Error:", error);
      }
    } else {
      console.log("Form is invalid");
    }
  };

	if (loading) return <div>Loading...</div>;
	if (error) return <div>Error: {error.message}</div>;

  return (
    <form onSubmit={serverActionFunc}>
      <div>
        <label>이름:</label>
        <input
          type="text"
          value={name}
          onChange={(e) => setName(e.target.value)}
        />
        {errors.name && <span className="error">{errors.name}</span>}
      </div>
      <div>
        <label>이메일:</label>
        <input
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
        />
        {errors.email && <span className="error">{errors.email}</span>}
      </div>
      <button type="submit" disabled={loading}>
        제출
      </button>
      {loading && <p>제출 중...</p>}
      {error && <p className="error">서버 에러: {error.message}</p>}
      {data && <p className="success">제출 성공!</p>}
    </form>
  );
}

export default FormValidation;

우선, 코드가 상당히 길어졌죠.

validateField 펑션을 모듈로 빼도 되겠지만, 그게 그다지 코드를 정리하는데에 큰 도움이 될 것 같지도 않습니다. 우리는 장황한 코드에 비즈니스 로직이 파묻혀 버리는, 이런 순간을 보통 괴로워하죠.

이제 React-Hook-Form 에 대해서 알아볼 때가 됐습니다.

.

.

React-Hook-Form

React-Hook-Form (RHF)는 React에서 폼을 관리하고 유효성을 검사하는 데 매우 유용한 라이브러리입니다.

RHF 는 외부 라이브러리로, 프로젝트에 인스톨 한 후 사용합니다.

# npm i react-hook-form

RHF-Code ver.01

이제 RHF를 사용해서 우리의 예제 코드를 업그레이드 해보겠습니다.

주의해서 보실 점은, 다음의 코드 진행입니다.

  1. Form.Submit 액션시에, 브라우저는, 브라우저 내장 이벤트, onSubmit 을 트리거 합니다.

  2. 우리가 그렇게 코딩 하였기 때문에, onSubmit 에, handleSubmit() 이 먼저 트리거 됩니다.

  3. handleSubmit 은 RHF 의 useForm() 훅이 리턴하는 펑션으로, 먼저 event.preventDefault() 를 실행한 다음, 주어진 규칙에 따라 폼의 유효성 검사 - validation 을 수행합니다.

  4. 유효성 검사를 모두 통과하면, handleSubmit(serverActionFunc) 코드에 따라, 콜백 펑션으로 사용자지정 펑션인, serverActionFunc() 펑션이 트리거 됩니다.

  5. serverActionFunc() 은 서버와의 통신 액션을 수행합니다.

  6. 유효성 검증을 통과하지 못하면, 지정된 에러 메세지와 에러가 리턴됩니다.

// /src/study/lecture4/RHFCodeV01.jsx
// import React from 'react';
import { useForm } from "react-hook-form";
import useAxiosFetch from "@/hooks/useAxiosFetch";

function RHFCodeV01() {
  // RHF 의 useForm 훅이 리턴하는 펑션들입니다.
  // form.element 들에 controlled input 으로 관리할 필요가 없도록, RHF 내부적으로 form.element 들을 관리하고 있습니다.
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
    reset,
  } = useForm();
  // 우리의 useAxiosFetch 훅은 여기서도 힘을 발휘합니다.
  const { fetchData, data, loading, error: fetchError } = useAxiosFetch();

  // onSubmit 단계 이전에 RHF 에서 실시간으로 validation 검증이 완료됩니다.
  // 따라서, 여기에선 validation 관련 코드가 모두 제거됩니다.
  const serverActionFunc = async (formData) => {
    try {
      await fetchData("/users", {
        method: "POST",
        data: formData,
        headers: { "Content-Type": "application/json" },
      });
      console.log("Form submitted successfully");
      reset(); // 폼 제출 후 리셋
    } catch (error) {
      console.error("Error:", error);
    }
  };
  
	if (loading) return <div>Loading...</div>;
	if (fetchError ) return <div>Error: {error.message}</div>;

  return (
    <form onSubmit={handleSubmit(serverActionFunc)}>
      <div>
        <label htmlFor="name">Name</label>
        <input
          id="name"
          {...register("name", { required: "Name is required" })}
        />
        {errors.name && <span role="alert">{errors.name.message}</span>}
      </div>
      <div>
        <label htmlFor="email">Email</label>
        <input
          id="email"
          {...register("email", {
            required: "Email is required",
            pattern: {
              value: /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/,
              message: "Invalid email address",
            },
          })}
        />
        {errors.email && <span role="alert">{errors.email.message}</span>}
      </div>
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? "Submitting..." : "Submit"}
      </button>
      {loading && <p>Loading...</p>}
      {fetchError && <p role="alert">Error: {fetchError.message}</p>}
      {data && <p>Form submitted successfully!</p>}
    </form>
  );
}

export default RHFCodeV01;

코드가 한결 간결해 졌습니다.

비즈니스 로직이 쓸데없는 코드 더미 속에 파묻혔던 이전 코드와 비교하면 훨씬 나아진 수준입니다.

우선, validation 검증 코드가 input 안으로 들어갔죠. 그리고 무엇보다, handleSubmit 펑션 내부에서 유효성을 검증하는 코드가 사라졌습니다.

register 펑션에서 지정해준 message 는, 밸리데이션 검증 실패시, errors.param1.message 에 자동으로 할당됩니다.

아래 코드는, useForm 이 리턴하는 handleSubmit 에서, 모든 유효성 검증을 수행한 후에, 유효성 검증이 통과했을 때, 콜백 펑션인 serverActionFunc 펑션으로 데이터와 핸들을 넘겨준다는 의미에요.

유효성 검증이 이루어지는 동안에는 handleSubmit 에서 프로세스가 핸들링 되고, 검증이 완료된 다음 부터는 serverActionFunc 으로 프로세스 핸들링이 넘어갑니다.

// handleSubmit 에 콜백 펑션으로 serverActionFunc 을 전달한 코드
<form onSubmit={handleSubmit(serverActionFunc)}>

InputGroup Component

RHF-Code ver.02

ver.01 의 코드는 전보다 간결해 졌지만, 아직 충분하지 않습니다.

이번엔, UI 컴포넌트로 Form.element 를 분리해서 비즈니스 로직이 조금 더 뚜렷하게 드러날 수 있도록 업그레이드 해보죠.

InputGroup 을 독립 컴포넌트로 분리하면, 보다 효율적인 컴포넌트 관리와 코드 재사용성을 높일 수있습니다.

/src/components/ 폴더를 생성하고,

/InputGroup.jsx

파일을 다음 처럼 작성합니다.

// /src/components/InputGroup.jsx
import "./InputGroup.css"

export function InputGroup({ errorMessage = "", children }) {
  return (
    <div className={`form-group ${errorMessage.length > 0 ? "error" : ""}`}>
      {children}
      {errorMessage.length > 0 && <div className="msg">{errorMessage}</div>}
    </div>
  );
}

같은 폴더에 InputGroup.css 를 생성해줍니다.

// 프로젝트의 테마에 따라 에러 메세지 출력 형태를 커스터마이징 하면 됩니다.
.error input[type="text"],
.error input[type="password"] {
    border-color: red;
    color: red;
}

이제, InputGroup 컴포넌트를 사용하는 버전으로 업그레이드 해봅니다.

// /src/study/lecture4/**RHF-CodeV02.jsx**
import React from 'react';
import { useForm } from 'react-hook-form';
import useAxiosFetch from '@/hooks/useAxiosFetch';
import InputGroup from "@/components/InputGroup"

function MyForm() {
	// RHF 의 useForm 훅이 리턴하는 펑션들입니다.
	// form.element 들에 controlled state 를 걸어줄 필요가 없도록, RHF 내부적으로 form.element 들을 관리하고 있습니다.
  const { 
    register, 
    handleSubmit, 
    formState: { errors, isSubmitting }, 
    reset 
  } = useForm();
  // 우리의 자랑스러운 useAxiosFetch 훅은 여기서도 힘을 발휘합니다.
  const { fetchData, data, loading, error: fetchError } = useAxiosFetch();

	// onSubmit 단계 이전에 validation 검증이 완료됩니다. 
	// 따라서, 여기에선 validation 관련 코드가 모두 제거됩니다.
  const serverActionFunc = async (formData) => {
    try {
      await fetchData('/users', {
        method: 'POST',
        data: formData,
        headers: { 'Content-Type': 'application/json' },
      });
      console.log('Form submitted successfully');
      reset(); // 폼 제출 후 리셋
    } catch (error) {
      console.error('Error:', error);
    }
  };
  
	if (loading) return <div>Loading...</div>;
	if (fetchError ) return <div>Error: {error.message}</div>;

  return (
    <form onSubmit={handleSubmit(serverActionFunc)}>
      <InputGroup errorMessage={errors.name?.message}>
        <label htmlFor="name">Name</label>
        <input 
          id="name"
          {...register('name', { required: 'Name is required' })} 
        />
      </InputGroup>

      <InputGroup errorMessage={errors.email?.message}>
        <label htmlFor="email">Email</label>
        <input
          id="email"
          {...register('email', {
            required: 'Email is required',
            pattern: {
              value: /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/,
              message: 'Invalid email address'
            }
          })}
        />
      </InputGroup>

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Submitting...' : 'Submit'}
      </button>
      {fetchError && <p role="alert">Error: {fetchError.message}</p>}
      {data && <p>Form submitted successfully!</p>}
    </form>
  );
}

export default MyForm;

코드가 조금은 더 구조화, 간소화 되었습니다.

하지만, 아직 많이 부족합니다.

.

.

.

Zod + Resolver

리액트 프레임웍 생태계에서, 가장 널리 사용되고 있는 밸리데이션 라이브러리로는

Zod, Yup 등이 있습니다.

우리는 이 중, Zod 를 사용할 것입니다.

install Zod & Resolver

먼저 프로젝트에 설치 합니다.

# npm i zod 

# npm install @hookform/resolvers

resolver

resolver 는, 밸리데이션을 실시간으로 감시해주는 역할을 합니다.

resolver 패키지에는 zodResolver, yupResolver 등.. 밸리데이션 라이브러리에 상응하는 메서드가 포함되는데, 우리는 이 중에서, zodResolver 를 사용할 것입니다.

resolver 는, 밸리데이션 스키마를 파라메터로 받아서 그 밸리데이션 결과를 실시간으로 RHF 의 내장 state 에 반영하는 역할을 합니다.

RHF-Code ver.03

먼저 코드 부터 보고, 하나씩 풀어보죠.

// /src/schemas/RHFExampleSchema.tsx
// Zod 스키마 정의
// 스키마를 별도의 폴더에서 관리해야 할 필요가 있습니다. 
// /src/schemas/ 폴더를 스키마 관리 폴더로 정하고 이 곳에 스키마를 모아 둡니다.
export const formSchema = z.object({
  name: z.string().min(1, { message: "Name is required" }),
  email: z.string().email({ message: "Invalid email address" })
});
// 스키마로부터 타입 추론
export type FormSchemaType = z.infer<typeof formSchema>;

// /src/study/lecture4/**RHF-CodeV03.jsx**
import React from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import useAxiosFetch from '@/hooks/useAxiosFetch';
import { InputGroup } from '@/components/InputGroup';
import { formSchema, FormSchemaType } from '@/schemas/RHFExampleSchema';

function MyForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
    reset
  } = useForm<FormSchemaType>({
    resolver: zodResolver(formSchema)
  });

	// RHF 의 isSubmitting 을 사용하므로, 여기서는 loading 이 사용되지 않습니다.
  const { fetchData, data, loading, error: fetchError } = useAxiosFetch();

  const onSubmit = async (formData: FormSchemaType ) => {
    try {
      await fetchData('/users', {
        method: 'POST',
        data: formData,
        headers: { 'Content-Type': 'application/json' },
      });
      console.log('Form submitted successfully');
      reset();
    } catch (error) {
	    // useAxiosFetch 의 fetchError 가 에러 메세지로 먼저 처리되므로, 여기서 추가처리하지는 않습니다.
      console.error('Error:', error);
    }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <InputGroup errorMessage={errors.name?.message}>
        <label htmlFor="name">Name</label>
        <input id="name" {...register('name')} />
      </InputGroup>

      <InputGroup errorMessage={errors.email?.message}>
        <label htmlFor="email">Email</label>
        <input id="email" {...register('email')} />
      </InputGroup>

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Submitting...' : 'Submit'}
      </button>

      {fetchError && (
        <InputGroup errorMessage={fetchError.message}>
          <div role="alert">Error occurred while submitting the form.</div>
        </InputGroup>
      )}

      {data && <p>Form submitted successfully!</p>}
    </form>
  );
}

export default MyForm;

코드가 매우 간결해 졌습니다.

이 코드는 비즈니스 로직이 한 눈에 들어올 정도로 잘 정돈된 코드로 보입니다.

간결한 코드임에도, 폼 밸리데이션과 에러 메세지도 출력되고 있습니다. 에러 핸들링에도 부족함이 없어 보입니다.

ver.02 으로부터 가장 중요하게 바뀐 부분은

// 
useForm<FormSchemaType>({
   resolver: zodResolver(formSchema)
});

바로 이 부분입니다.

ver.02 까지는 useForm() 에 아무런 인수도 전달해주지 않았지만, ver.03 에서는 zodResolver 라는 펑션과 스키마를 resolver 라는 이름으로 전달해주었죠. 이 구조는 그냥 외워두시면 됩니다.

TypeScript

그런데, RHF-Code ver.03 의 코드에서는, 조금 낯선 코드가 등장합니다. 바로 TypeScript 죠.

// infer 는, zod 스키마로부터 Type 을 '추론' 한다는 뜻입니다.
// typeof formSchema 로부터 Type 을 추론하여 변수에 할당한다.
export type FormSchemaType = z.infer<typeof formSchema>;

// Typescript 에서는, 펑션이 받아들이는 인수의 Type 을 선언해줘야 합니다.
// 스키마로부터 추론된 Type 을 useForm 이 받아들이는 인수의 Type 으로 선언하는 코드입니다.
useForm<FormSchemaType>

Zod 에서는, Type Driven 코드, Type 이 필요합니다.

이 때문에, Zod 를 사용하기 위해서 우리의 컴포넌트는 TypeScript 이어야 하고, 파일이름 역시, RHFExampleSchema.tsx 로 저장해야만 합니다.

Vite 프로젝트에서, 일부 파일의 Typescript 코딩은 유연하게 받아들여 집니다. 따라서, Typescript 의 Type 이 필요한 컴포넌트는 자유롭게 .tsx 형식으로 저장하면 됩니다.

아직까지는 Typescript 의 많은 것을 배워야 할 필요는 없습니다.

  1. 선언되는 변수에는, string, number, boolean, enum, type, function 중 하나의 타입을 명시화 또는 선언해 줘야 한다.

  2. 펑션, 컴포넌트 에서는, 수신하는 파라메터나 Props 의 타입을 선언해줘야 한다.

이 두가지가, TypeScript 의 가장 중요한 원칙이고, 우리가 여기서 기억하고 넘어가야 할 요점입니다.

Type 의 선언은 이렇게 하면 됩니다.

// Type 은 대문자로 시작하는 컨벤션을 따릅니다.
export type FetchDataType = {
  albumId?: number;
  id: number;
  title: string;
  artist: ArtistType;
};

type ArtistType = {
	name: string;
	gender: string;
	nationality: string;
}
// interface 보다는 type 이, 타입 결합 측면에서 유연합니다.

타입스크립트는, type-driven 철학으로, 이전의 석기시대 Javascript 의 개발방식을 근원적으로 개선해줍니다. Type 이 개발의 가이드가 되어주죠. 개발자는 Type 만 따라가면 됩니다.

JS 진영에서 개발을 진행하다 보면 언젠가는 TS 로 전직할 수 밖에 없어요. 하지만 하루아침에 이삿짐 옮기듯 되는 일은 아니겠죠.

지금은 우리가 React 를 배우는 일에 집중해야 하는 단계이기 때문에, 타입스크립트는 그야말로 최소한의 필요에따라 조금씩 익히면 됩니다. 오늘은, Zod 와 관련해서 한두가지 중요한 TypeScript 의 컨셉을 배웠습니다. 하지만 기억하세요. 오늘 배운 게 Typescript 의 가장 큰 원칙 입니다.

Zod Schema

zod 의 스키마를 정의하는 방법은 아래와 같습니다.

z.object 의 인수로 오브젝트를 주면 되는데,

오브젝트에는 각 속성, 그리고 속성에 할당될 z 의 함수 체인을 지정해주면 됩니다.

여기서 message 로 지정해 준 메세지가 사용자에게 에러 메세지로 전달되죠.

// /src/schemas/formSchema.ts
export const formSchema = z.object({
  name: z.string().min(1, { message: "Name is required" }),
  email: z.string().email({ message: "Invalid email address" })
});
// 스키마로부터 타입 추론
export type FormSchemaType = z.infer<typeof formSchema>;

오늘은 이정도만 이해하고 넘어가는 것으로 충분할 것 같습니다.

Zod 의 사용방법과 스킬에 관해서는, 공식 문서를 수 없이 찾아보면서 익숙해지게 될 것입니다.

.

.

세줄 요약

  1. RHF + Zod + Resolver 를 사용하면 uncontrolled input 으로도, 사용자의 입력 값을 실시간으로 감시하고 검증, 그 결과를 화면에 표시해줄 수 있다.

  2. Zod 는 Typescript 를 필요로 하지만, 필요한 부분만 조금씩 추가하면 된다. Typescript 는 좋은 거니까 기회가 왔을 때 조금씩 배워나가자.

  3. RHF + Zod + Resolver 를 사용한 코드는 완전 간결해졌다. 이제 당신은 과거의 스파게티 코드로 돌아갈 수 없다.

.

.

.

Form Component & UI Component

지금까지 우리는, input type text 만을 다뤄봤죠.

Plain Html 에서, 가장 커스터마이징, 스타일링 하기 쉬운 요소만 다뤄본 셈입니다.

radio, checkbox, select 등등은, React 에서도 스타일링 하기 어렵기는 마찬가지 입니다.

특히, select 는 이제 스타일링 없이 사용하는 일이 불가능에 가깝죠. 오래된 CSS 불능 영역이기도 합니다. radio, checkbox, select 등의 form 요소들은, 직접 스타일링 솔루션을 만들어서 작성하던지, 이미 만들어진 UI Component 를 가져와 사용하던지 둘 중의 하나입니다.

React 생태계에서는, 이들 요소들을 직접 스타일링 하기 보다는, 이미 만들어진 컴포넌트들을 불러와서 사용하는 게 일반적입니다.

이제, UI 컴포넌트들을 좀 알아보겠습니다.

UI Components

UI Component 란, 말 그대로, UI 에 사용되는 “이미 완성된” 컴포넌트 입니다.

우리의 수업 과정에서 우리는 InputGroup.jsx 라는, 재사용성이 높은 컴포넌트를 작성했었죠.

InputGroup.jsx 는 이미 완성된 컴포넌트이고, UI 컴포넌트 의 좋은 예 입니다.

React 생태계에는 수많은 UI 컴포넌트 프로바이더 들이 있습니다. 이미 만들어진, 완성도 높은, 또는 확장성을 자랑하는, UI 컴포넌트 들이 있는데, 이 중 몇가지를 골라서 사용 예제와 함께 살펴보겠습니다.

Styled Component & Unstyled Component (Headless UI)

예를 들어, 프로젝트에서 DatePicker 가 필요한 상황이라고 가정해보죠.

인터넷에는 다양한 DatePicker 컴포넌트와 모듈들이 존재하죠. 그 중 하나를 가져다 썼을 때, 프로젝트의 레이아웃과 조화를 이루지 못하는 상황이 발생하기도 합니다. 어쩌면 당장은 괜찮을 수도 있지만 테마를 바꿀 때나 반응형 웹에서 문제를 일으킬 수도 있겠죠.

스타일이 사전에 결정되어서 커스터마이징에 제약이 있는 컴포넌트들을 Styled Component 라고 하고, 반면에, 스타일이 전혀 없거나 최소한의 스타일만 지정된 컴포넌트들을 Unstyled Component 또는 Headless UI Component 라고 분류합니다.

Styled Component :

Bootstrap,

Chakra,

Mantine,

ReactSelect,

Daisy-UI

Un-Styled Component :

Radix,

Shad-CN

Hook Component : (훅 제공자들도 컴포넌트 버전을 함께 제공하고 있습니다.)

React-Aria,

Downshift,

Base-UI

어떤 컴포넌트든, 프로젝트에서 필요하다고 판단되는 컴포넌트를 가져와 쓰고 개발 생산성에 도움을 받는다면 좋겠습니다만,

사실, Styled Component 가 당장에 가시적인 효능은 보여주지만, 시간이 지나면서 애물단지가 되어 버리는 경우가 많습니다.

이왕이면 UnStyled Component 에 익숙해지는 게 야러모로 좋지 않을까 생각합니다만, 어느정도의 진입장벽이 있습니다.

어느쪽을 사용하든, 필요에 따라 효과적으로 사용하는 게 더 중요하겠죠.

Radix

# npm install @radix-ui/react-select

Radix 의 UI Component : Select 를 사용한 예제 코드

// MySelectbox 를 호출

const test2 = () => {
    const fruits = ["apple", "orange", "banana"];
    const drinks = ["cola", "beer", "soju"];
  return (
    <div>
		<Flex gap="3">	
        <MySelectbox 
            placeholder={"Select Fruit"} 
            data={fruits}
            defaultValue={fruits[0]}
            themeColor={"cyan"}
        />
        <MySelectbox 
            placeholder={"Select Drink"} 
            data={drinks}
            defaultValue={drinks[0]}
            themeColor={"crimson"}
        />
		</Flex>
    </div>
  )
}
// MySelectbox 는 SelectBox 를 생성합니다.
// 스타일을 Props 로 전달해서 스타일링을 컨트롤 할 수 있습니다.
import React from 'react'
import * as Select from '@radix-ui/react-select'

const MySelectbox = ({ placeholder, data, defaultValue, themeColor }) => {

  return (	
		  <Select.Root defaultValue={defaultValue}>
		    <Select.Trigger color={themeColor} variant="soft" placeholder={placeholder} />
		    <Select.Content color="cyan">
          {data.map((i, each => {
              return <Select.Item value={each} key={i}>{each}</Select.Item>
              })                    
          )}
		    </Select.Content>
		  </Select.Root>
  )
}

export default MySelectbox 

Shad CN

ShadCN 은 Radix 를 승계한 Unstyled UI Component 로, Radix 기반에 Typescript 와 Tailwind 를 사용합니다.

지금 단계에서 시작하기에는 다소 어려움이 있습니다만, 지금 거론되는 이유는,

바로, ShadCN 의 Form 에는 React-Hook-Form 이 연동되어 있기 때문입니다.

우리가 배운 RHF 의 Form validation 은, useForm 훅의 리턴 객체를 해체하여, 그 중 하나의 펑션인 register 펑션으로 각 form 요소를 감시상태 로 등록하여 관리한다는 컨셉이었죠.

ShadCN 의 Form 에서는, useForm 의 리턴을 해체하지 않고, 리턴된 form 객체 전체를 Form 이하의 모든 컴포넌트에서 공유되도록 프롭으로 전달해 줍니다. 이후, 각 form.element 들은, 이 form 중에서 자기 요소에 대해 어떤 상태 변화가 발생하였는가를 감시합니다.

이전까지의 RHF 코드 보다 개발 생산성과 코드 간결화에서 큰 개선이 있을 것입니다.

아래 예제 코드에서 처럼, ShadCN 으로 Form 컴포넌트를 만들어 두면, 스타일링은 물론이고, 코드를 간결화 하여 보다 향상된 코드관리와 개발 생산성 향상을 기대할 수 있습니다.

// ShadCN 의 Form 과 FormField 컴포넌트를 사용한 RHF 간소화 코드 예제
// 이 코드에서도 RHF + Zod 의 실시간 Form.validation 이 작동합니다.
const FormTry01 = () => {
  const form = useForm<UserType>({
    resolver: zodResolver(UserFormSchema),
  });

  const onSubmit = (data: UserType) => {
    console.log(data);
  };

  return (
    <div className="w-10/12">
      <h1>FormTry01</h1>
      <Form {...form}>
        <form
          onSubmit={form.handleSubmit(onSubmit)}
          className="w-auto space-y-6"
        >
          <ShadCNInput
            form={form}
            name="userName"
            label="Username"
            placeholder="Enter your username"
          />
          <ShadCNInput
            form={form}
            name="email"
            label="Email"
            placeholder="Enter your email address"
          />
          <ShadCNInputDate
            form={form}
            name="dateOfBirth"
            label="Date of Birth"
            placeholder="Choose your date of birth"
          />
          <ShadCNSelect
            form={form}
            name="hobby"
            label="Hobby"
            placeholder="Select your hobby"
            options={hobbies}
          />
          <ShadCNRadioGroup
            form={form}
            name="gender"
            label="Gender"
            description="Select your gender"
            options={genders}
          />
          <ShadCNSwitch
            form={form}
            name="newsLetter"
            label="News Letter"
            description="Receive News Letter lorem ipsum dolor sit amet."
          />
          <ShadCNCheckbox
            form={form}
            name="terms"
            label="Accept Terms and Conditions"
          />
          <ShadCNButtton form={form} />
        </form>
      </Form>
    </div>
  );
};

export default FormTry01;

간결하고 직관적인 코드관리가 돋보이죠.

폼 컴포넌트 구성이 까다로운 select , radio 등의 코드도 단순화 되었을 뿐만 아니라, 이에 더 나가 CSS Variables + Tailwind Utility Class 를 통한 Theming 구성도 가능해 집니다.

ShadCN 은 이렇게 장점을 일일이 열거하기 어려울 만큼 훌륭한 UI Component 이지만 결정적인 단점도 있는데, 바로, 문을 열고 들어가서 자리에 앉기 까지가 좀 고통스럽다는 것입니다.

우선, 공식문서가 좀 부족합니다. 공식 문서에 오류가 있거나 한 것은 아닌데, 그 정보량이 많이 부족해요. 게다가, ShadCN 과 Tailwind 의 설정, 글로벌 variables 준비, 유틸리티 클래스 적용 등 의 관문들이 즐비하게 기다리고 있기 때문에, 성공적인 결과물을 브라우저에 띄우기 까지에는 적잖은 시간과 끈기가 필요합니다. 특히, 커스터마이징 할 때, Tailwind class 들을 어느 컴포넌트에 넣어줘야 하는지, 등의 정보가 없어요. 삽질 좀 하게 됩니다.

하지만, 사실 어렵다고 느껴지는 대부분의 문제들은 RHF, Tailwind 등의 제3자 라이브러리와 연동되는 영역들이라서 딱히 ShadCN 의 잘못은 아니긴 합니다.

// 예제: ShadCNSelect.tsx 컴포넌트
// 위의 FormTry01 에서 사용한 ShadCNSelect 컴포넌트의 코드입니다.
// 이 코드를 사용하기 위해서는 Typescript 학습과 끈기가 필요합니다.

import { UseFormReturn } from "react-hook-form";
import { UserType } from "@/schemas/UserFormSchema";
import {
  FormField,
  FormItem,
  FormControl,
  FormDescription,
  FormMessage,
} from "@/components/ui/form";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import FormTitle from "./FormTitle";
import { cn } from "@/lib/utils";

type ShadCNSelectProps = {
  form: UseFormReturn<UserType>;
  name: keyof UserType;
  label: string;
  description?: string;
  placeholder?: string;
  options: { value: string; label: string }[];
};

const ShadCNSelect = ({
  form,
  name,
  label,
  description,
  placeholder,
  options,
}: ShadCNSelectProps) => {
  const {
    formState: { errors },
  } = form;
  const error = errors[name]?.message;

  return (
    <FormField
      control={form.control}
      name={name}
      render={({ field }) => (
        <FormItem>
          <FormTitle>{label}</FormTitle>
          <Select
            onValueChange={field.onChange}
            defaultValue={field.value as string | undefined}
          >
            <FormControl>
              <SelectTrigger
                className={cn(
                  error && "border-destructive",
                  "space-y-0  rounded-lg border p-4"
                )}
              >
                <SelectValue placeholder={placeholder} />
              </SelectTrigger>
            </FormControl>
            <SelectContent>
              {options.map((option) => (
                <SelectItem key={option.value} value={option.value}>
                  {option.label}
                </SelectItem>
              ))}
            </SelectContent>
          </Select>
          <FormDescription>{description}</FormDescription>
          <FormMessage />
        </FormItem>
      )}
    />
  );
};

export default ShadCNSelect;

// 이 컴포넌트는, 어느 프로젝트에서든지 제약 없이 곧바로 사용할 수 있는,
// reusable 컴포넌트입니다.
// 또한, globals.css 의 theming 을 통해 유연한 테마 적용도 가능합니다.

ShadCN Charts

이 글이 작성되고 있는 2024.07 현재, 따끈따끈한 ShadCN Charts 가 추가 되었습니다.

https://ui.shadcn.com/charts

ShadCN 설치

ShadCN 은, 패키지 설치를 하기는 합니다만, 컴포넌트 복사 수준으로 설치가 되므로, 필요한 컴포넌트들을 하나씩 설치 해줘야 합니다. 이 방식에는 dependency 로 인한 충돌도 없을 뿐더러, 빌드시 스크립트의 용량을 절약할 수 있다는 이점도 있습니다.

// 먼저 init이 필요하고, init 프로세스에서 typescript 설치를 확인합니다.
# npx shadcn-ui@latest init
// ShadCN init 은, globals.css 를 리셋하고 덮어씁니다. 
// 작업해 놓은 css 변수들이 있으면 미리 백업해준 뒤에 init 을 실행합시다.

// 프로젝트에서 사용할 UI Component 들을 모두 설치합니다.
# npx shadcn-ui@latest add input
# npx shadcn-ui@latest add select
# npx shadcn-ui@latest add checkbox
# npx shadcn-ui@latest add radio-group
# npx shadcn-ui@latest add button
# npx shadcn-ui@latest add popover
# npx shadcn-ui@latest add calendar

ShadCN init 은 다음 패키지를 설치하고 관련된 설정을 마칩니다.

// 이 라이브러리들은 Tailwind 의 class 들을 관리하는 유틸리티입니다. ~~셋이 한몸~~
  "tailwind-merge",
  "class-variance-authority",
  "clsx",

Daisy UI

DaisyUI 는 매우 잘 만들어진, Styled Component 입니다. 확장성 보다는 개발 속도에 중점을 두고있는 프로젝트에서 가장 먼저 고려해볼 만한 UI 컴포넌트 중 하나가 아닐까 생각합니다.

DaisyUI 의 홈페이지는 한번 경험해볼 만 합니다. 굉장한 상상력과 인사이트죠. 꼭 한번 구경 경험 해보세요.

https://daisyui.com/

DaisyUI 의 홈페이지에서는 Scroll Driven Animation 이 매우 감각적으로 사용되고 있죠.

웹에서는 Scroll Driven Animation 에 대한 관심이 점점 확산되고 있는 추세입니다.

저도 이 기술에 관심이 많아서요. 기회가 되면 이 주제를 한번 깊이 다뤄보고 싶습니다.

Dot UI

이 글을 작성하고 있는 동안에, 새로운 Unstyled UI Components Library 가 메일박스에 도착했네요.

ShadCN 과 비슷한, 아니 거의 같은 방식으로 작동하고 있는 것 같고요..

아직 시간이 충분하지 못해서 많은 것을 살펴보진 못했지만, 일단은 상당히 좋아 보입니다.

도큐먼트도 ShadCN 보다는 충분해 보이고요, 무엇보다 Tailwind 와의 연동 부분이 상당히 구조화 되어있는 것 같습니다.

보면 볼 수록 완성도가 상당한 것 같습니다. 개인 개발자인 것 같은데, 초기 공개 버전의 모든 면이 다른 라이브러리를 압도하는 분위기군요. 다른 우주에서 왔거나, 뭔가 불가능한 일이 일어난 느낌입니다.

https://dotui.org/

관심이 있는 분들은 한번 둘러보시는 것도 좋을 것 같습니다. 언젠가 기회가 되면 다시 다루어 보기로 하죠.

Tailwind

BootStrap 은 아마도 많은 분들이 이미 경험해보셨을 거라고 생각합니다.

Tailwind 도 BootStrap 과 비슷하죠. 사전 정의된 className 으로 CSS-Style 을 대체한다는 점에서는 비슷합니다.

하지만 Tailwind 에서는 보다 근본적인 Low-Level 의 제어와 커스터마이징이 가능해서, 복잡한 디자인 시스템을 구조화 하여 관리할 수 있으며, 나아가 dark mode 와 Theme 구성도 쉽고 빠르게 구현할 수 있습니다.

Tailwind 에 관한 복잡한 내용을 여기서 다룰 수는 없고,

오늘은 예제 코드를 맛보고 넘어가는 것으로 가름하겠습니다.

install tailwind

npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
// npx tailwindcss init 이 과정이 꼭 필요합니다.
// css 의 속성과 닮은 tailwind 의 className 들
// p-4 는, padding: 1rem 을 뜻하는 Tailwind 의 utility-class 입니다. 
// px-6, py-10 도 가능하고, m-2 도 가능합니다. margin-2 죠.
// 시도해볼만 하겠죠?
<div className="flex flex-row items-start p-4 border-l border-gray-500">
</div>

React-Icons

텍스트 보다는 이미지가, 이미지 보다는 애니메이션이 보다 전달력이 좋다고 알려져 있죠.

모던 리액트에서는 Icon 의 활용도가 높아지고 있습니다.

react-icons 을 프로젝트에 설치합니다.

# npm i react-icons

다 됐습니다. 이제,

https://react-icons.github.io/react-icons/

여기서 필요한 아이콘을 검색해서 찾으신 후,

import { AiOutlineHome } from "react-icons/ai";

<AiOutlineHome />

이렇게 사용하면 됩니다.

아이콘의 스타일링은,

const style = { color: "white", fontSize: "1.5em" }
<FaFacebookF style={style} />

// 또는
<FaFacebookF color="white" fontSize="1.5em" />

.

.

.

.

.

.

fourth project : React-Hook-Form Project

사용자 Data 가 다음 과 같을 때,

type user = {
  name: string,
  email: string,
  dateOfBirth: Date,
  gender: "male" | "female" | "bin",
  active: boolean,
  newsLetter: boolean,
  hobby: "yoga" | "running" | "drinking" | "music" | "movies",
}

사용자 Data 를 입력받는 Form 을 만들어 봅시다.

해결해야 할 태스크는,

  1. Zod Schema 를 작성하십시오.

  2. RHF+Zod+Resolver 를 사용하여 사용자 입력 폼 과 밸리데이션을 구성하십시오.

  3. 각 입력 값의 유효성 검사에 실패했을 때, 각 입력 엘리먼트 밑에 해당 오류 메시지가 출력되도록 하십시오.

  4. 하나의 요소라도 유효성 검사에 실패한 상황에는, Submit 버튼이 disabled 상태가 유지되도록 하십시오.

  5. DateOfBirth 항목의 입력을, ShadCN 또는 DaisyUI 의 UI Component 를 사용하여 구현하십시오.

  6. 추가 테스크 1 : ShadCN 또는 DaisyUI 의 Toast 컴포넌트를 사용하여 Form.Submit 의 오류 또는 성공 결과 메시지 Toast 를 띄우도록 작업하십시오.

  7. 추가 테스크 2 : 사용자의 생일이 2005.01.31 이전, 1980.10.31 이후 인 날짜만 유효하도록 zod validation 규칙을 추가하십시오. 연령제한

Code Base

이번 과제는, 코드 베이스 보다는 완성된 코드로 지향점을 잡고 시작하는 게 어떨까 합니다.

사실 UI Component 를 설정하고 사용한다는 게 좀 성가시고 어려운 일이기도 합니다.

특히, ShadCN 의 DatePicker 에서 사용되는 Calendar 컴포넌트는 그 완성도가 너무 떨어져서, 소스코드를 수정해야 사용할 수 있습니다. (css 추가도 필요해요.)

게다가 설정 파일의 내용도 참조할 만한 곳을 찾아보기 어렵기도 하고, 저 역시 완성된, 참조할 만한 코드를 찾지 못해서 짧지 않은 시간동안 삽질한 경험이 있기에, 전체 프로젝트의 소스파일이 필요하겠다는 생각이 들었습니다.

그래서….

Typescript + ShadCN + Tailwind 로 구현한 소스코드와 Working demo 가

https://stackblitz.com/~/github.com/KangWoosung/shadcn-exercise?file=src/App.tsx

이곳에 올려져 있습니다. (Stackblitz 는, wasm 을 쓰기 때문에 초기 로딩에 시간이 몇 분 이상 소요됩니다. 기다려 줍시다.)

아울러, CVA(class-variance-authority) 툴이 사용되어서, 어떻게 버튼 등의 UI 추상화가 설계되는지 참고할만한 코드가 되지 않을까 생각합니다. 비록, 컬러셋 등은 한참 유아적 수준에 그치고 있지만, 이는 사실 디자인 감각의 영역인지라... 참고하고 봐주시면 좋겠습니다.

선공개된 소스코드는 지금까지 배운 영역 너머의 기술들을 포함하고 있어서 좀 난해한 코드도 있을 수 있습니다.

하지만, 완성된 웹앱의 구현물을 보시고, 앱이 어떻게 작동해야 하는지, 또 전체적인 앱의 구조는 어떻게 설계되는지를 참고하면서, 우리가 배운 RHF+Zod schema 를 사용해서 할 수 있는 만큼만은 직접 과제를 구현해 보시길 바랍니다.

.

.

다시 1~2 주 후에 다음 강으로 돌아오겠습니다.

긴 글 따라 오시느라 고생하셨습니다.

Fin.







....