Next.js_React Hook Form /w Zod

정소현·2024년 9월 27일

Next.js

목록 보기
3/5

인증인가, tanstackQuery, Zustand

☘️ 제어 컴포넌트와 비제어 컴포넌트

제어 컴포넌트
: 리액트에서 값이 제어되는 컴포넌트
비제어 컴포넌트
: 리엑트에서 값이 제어되지 않는 컴포넌트

  • 즉각적으로, 실시간으로 값에 대한 피드백이 필요하다 => 제어 컴포넌트
  • 즉각적인 피드백이 불필요하고 제출시에만 값이 필요하다, 불필요한 렌더링과 값 동기화가 싫다 => 비제어 컴포넌트 사용

🌟 React Hook Form /w Zod

  • React를 기반으로 한 검증 라이브러리
yarn add react-hook-form

폼 컴포넌트 생성

"use client";

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

const SignInForm = () => {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm();

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

  return (
    <form
      onSubmit={handleSubmit(onSubmit)}
      className="flex flex-col gap-4 p-5 items-center w-full m-auto"
    >
      <div className="flex flex-col gap-2">
        <label htmlFor="email">Email</label>
        <input
          id="email"
          type="text"
          {...register("email", {
            required: "Email is required",
            pattern: {
              value: /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/,
              message: "Invalid email address",
            },
          })}
          placeholder="Email"
          className="text-black"
        />
        {errors.email && (
          <p className="text-red-500">{errors.email.message}</p>
        )}
      </div>

      <div className="flex flex-col gap-2">
        <label htmlFor="password">Password</label>
        <input
          type="password"
          {...register("password", {
            required: "Password is required",
          })}
          placeholder="Password"
          className="text-black"
        />
        {errors.password && (
          <p className="text-red-500">{errors.password.message}</p>
        )}
      </div>
      <button
        className="bg-gray-800 text-white px-4 py-2 rounded-md"
        type="submit"
      >
        Sign In
      </button>
    </form>
  );
};

export default SignInForm;

React Hook From/ w Zod(2)

Zod는 TypeScript와 JavaScript를 위한 스키마 선언 및 검증 라이브러리

  1. Zod 설치
yarn add zod
  1. 기본 사용법
  • 스키마 정의 및 검증
import { z } from 'zod';

const userSchema = z.object({
  firstName: z.string().min(1, "First name is required"),
  lastName: z.string().min(1, "Last name is required"),
});

try {
  userSchema.parse({ firstName: "John", lastName: "" });
} catch (e) {
  console.error(e.errors);
}
  • React Hook From + Zod
  1. React Hook Form과 Zod를 함께 사용하기 위해 @hookform/resolvers 패키지를 설치
yarn add @hookform/resolvers
  1. 폼 컴포넌트에 Zod를 통합
"use client";

import { zodResolver } from "@hookform/resolvers/zod";
import { FieldValues, useForm } from "react-hook-form";
import { z } from "zod";

const signInSchema = z.object({
  email: z.string().email({ message: "invalid email" }).min(1, {
    message: "email required",
  }),

  password: z.string(),
});

const SignInForm = () => {
  const { register, handleSubmit, formState } = useForm({
    mode: "onChange",
    defaultValues: {
      email: "",
      password: "",
    },
    resolver: zodResolver(signInSchema),
  });

  const onSubmit = async (value: FieldValues) => {
    const res = await fetch("/api/sign-in", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        ...value,
      }),
    });

    const data = await res.json();
    console.log(data);
  };

  return (
    <form
      onSubmit={handleSubmit(onSubmit)}
      className="flex flex-col gap-4 p-5 items-center w-full m-auto"
    >
      <div className="flex flex-col gap-2">
        <label htmlFor="email">Email</label>
        <input
          {...register("email")}
          placeholder="Email"
          className="text-black"
        />
        {formState.errors.email && (
          <span>{formState.errors.email.message}</span>
        )}
      </div>

      <div className="flex flex-col gap-2">
        <label htmlFor="password">Password</label>
        <input
          type="password"
          {...register("password")}
          placeholder="Password"
          className="text-black"
        />
      </div>
      <button
        disabled={!formState.isValid}
        className="bg-gray-800 text-white px-4 py-2 rounded-md"
        type="submit"
      >
        Sign In
      </button>
    </form>
  );
};

export default SignInForm;
  • 비동기 검증
    : 비동기 검증을 위해서는 resolver옵션을 사용. 예를 들어 , 서버에서 유효성 검사를 수행시 설정
"use client";

import React from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const schema = z.object({
  email: z.string().email("Invalid email").nonempty("Email is required"),
  password: z.string().nonempty("Password is required"),
});

const SignInForm = () => {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm({
    resolver: zodResolver(schema),
    mode: 'onBlur',
    asyncResolver: async (values) => {
      const result = await fetch('/validate-email', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({ email: values.email }),
      });

      const data = await result.json();
      if (!data.isValid) {
        return {
          values: {},
          errors: {
            email: { message: "Email already taken" },
          },
        };
      }
      return { values, errors: {} };
    },
  });

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

  return (
    <form
      onSubmit={handleSubmit(onSubmit)}
      className="flex flex-col gap-4 p-5 items-center w-full m-auto"
    >
      <div className="flex flex-col gap-2">
        <label htmlFor="email">Email</label>
        <input
          id="email"
          type="text"
          {...register("email")}
          placeholder="Email"
          className="text-black"
        />
        {errors.email && (
          <p className="text-red-500">{errors.email.message}</p>
        )}
      </div>

      <div className="flex flex-col gap-2">
        <label htmlFor="password">Password</label>
        <input
          type="password"
          {...register("password")}
          placeholder="Password"
          className="text-black"
        />
        {errors.password && (
          <p className="text-red-500">{errors.password.message}</p>
        )}
      </div>
      <button
        className="bg-gray-800 text-white px-4 py-2 rounded-md"
        type="submit"
      >
        Sign In
      </button>
    </form>
  );
};

export default SignInForm;

🌟 Auth + MiddleWare

middleware

  • 요청이 완료되기 전에 코드를 실행할 수 있는 기능을 제공
  • Middleware에서는 들어오는 요청을 기반으로 응답을 재작성, 리다이렉트, 요청 또는 응답헤더수정, 혹은 직접 응답 가능

(app디렉토리와 동일한위치에 middleware.ts생성)
middleware.ts

import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

// This function can be marked `async` if using `await` inside
export function middleware(request: NextRequest) {
  return NextResponse.redirect(new URL('/home', request.url));
}

export const config = {
  matcher: '/about/:path*',
};
  1. matcher
  • 특정 path로만 해당 middleware가 동작되게 하고 싶다면 matcher사용가능
export const config = {
  matcher: '/about/:path*',
};
  • 다양한 path를 대상으로 matcher를 만들기 위해서는 배열 형식으로 선언
  • 아래 선언한 matcher는 /about 이하 path와 /dashboard 이하 path로 항상 middleware가 동작하게 함
export const config = {
  matcher: ['/about/:path*', '/dashboard/:path*'],
};
  • 아래 문법은 route handler, 정적파일, favicon.ico를 제외하고는 middleware가 동작
export const config = {
  matcher: [
    /*
     * Match all request paths except for the ones starting with:
     * - api (API routes)
     * - _next/static (static files)
     * - _next/image (image optimization files)
     * - favicon.ico (favicon file)
     */
    '/((?!api|_next/static|_next/image|favicon.ico).*)',
  ],
};

조건문

만약 동적인 값으로 matcher를 작동시키고 싶거나 path가 동일하더라도 특정 조건에만 middleware를 작동시켜야 한다면 middleware 함수 내부에 조건문에서 이를 충족시킬 수 있음

1. NextResponse

예제 코드에서는 middleware에서 response를 생성해서 사용자에게 바로 응답을 전달

import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  if (request.nextUrl.pathname.startsWith('/about')) {
    return NextResponse.rewrite(new URL('/about-2', request.url));
  }

  if (request.nextUrl.pathname.startsWith('/dashboard')) {
    return NextResponse.rewrite(new URL('/dashboard/user', request.url));
  }
}

NextResposne를 통해 사용자를 특정 url로 리다이렉트 시키거나 요청한 url은 그대로 노출하면서 특정 경로로 이동시키는 rewrite를 수행


☘️ 인증종류

  • OAuth/OpenID Connect (OIDC): 사용자 자격 증명을 공유하지 않고 타사 접근을 활성화한 상태로 하는 인증 / 소셜 미디어 로그인 및 단일 로그인(SSO) 솔루션
  • 자격 증명 기반 로그인(이메일 + 비밀번호): 사용자가 이메일과 비밀번호를 이용한 인증
  • 비밀번호 없는/토큰 기반 인증: 비밀번호 없는 접근을 위해 이메일 링크 또는 SMS 일회용 코드를 사용한 인증

간단한 인증 서버 json-server-auth

  1. 설치 및 설정
yarn add -D json-server-auth
  1. auth-db.json을 Route에 생성
  2. json-server를 설정하고 json-server-auth middleware를추가
  3. 서버를 실행
yarn auth

쿠키 관리 기능

  • 쿠키 읽기: 특정 쿠키를 가져오거나 모든 쿠키를 가져올 수 있다.
  • 쿠키 존재 여부 확인: 쿠키의 존재 여부를 확인할 수 있다.
  • 쿠키 설정: 새로운 쿠키를 설정하거나 기존 쿠키를 업데이트할 수 있다.
  • 쿠키 삭제: 쿠키를 삭제할 수 있는 여러 가지 방법을 제공한다.

특정쿠키 읽기

import { cookies } from 'next/headers'

export default function Page() {
  const cookieStore = cookies()
  const theme = cookieStore.get('theme')
  return '...'
}
  • cookies().get(name) 메서드를 사용하여 특정 이름의 쿠키를 가져올 수 있습니다. 만약 쿠키가 없으면 undefined를 반환
  • 모든 쿠키 반환
import { cookies } from 'next/headers'

export default function Page() {
  const cookieStore = cookies()
  return cookieStore.getAll().map((cookie) => (
    <div key={cookie.name}>
      <p>Name: {cookie.name}</p>
      <p>Value: {cookie.value}</p>
    </div>
  ))
}
  • 쿠키 존재여부확인
import { cookies } from 'next/headers'

export default function Page() {
  const cookieStore = cookies()
  const hasCookie = cookieStore.has('theme')
  return '...'
}
  1. cookies().has(name) 메서드를 사용하여 특정 쿠키가 존재하는지 여부를 확인
  • 쿠키설정
'use server'

import { cookies } from 'next/headers'

async function create(data) {
  cookies().set('name', 'lee')
  // or
  cookies().set('name', 'lee', { secure: true })
  // or
  cookies().set({
    name: 'name',
    value: 'lee',
    httpOnly: true,
    path: '/',
  })
}
  1. cookies().set(name, value, options) 메서드를 사용하여 새로운 쿠키를 설정하거나 기존 쿠키를 업데이트, 메서드는 서버 액션이나 라우트 핸들러에서만 사용
  • 쿠키 삭제
    1. 명시적 삭제
'use server'

import { cookies } from 'next/headers'

async function delete(data) {
 cookies().delete('name')
}
2. 빈 값 설정
'use server'

import { cookies } from 'next/headers'

async function delete(data) {
  cookies().set('name', '')
}

3.만료 시간 설정

'use server'

import { cookies } from 'next/headers'

async function delete(data) {
  cookies().set('name', 'value', { maxAge: 0 })
}

☘️ ReactQuery

  1. 캐싱이란?
    특정 데이터의 복사본을 저장하여 이후 동일한 데이터의 재접근 속도를 높이는 것
    React Query의 캐싱 기능을 이용해 불필요한 API호출을 막고 캐싱된 데이터를 이용할 수 있다.
  2. Stale Time, Cache Time

react-query 사용법

  1. react-query 설치
yarn add @tanstack/react-query
  1. src>components>providers>RQProvider.tsx
// In Next.js, this file would be called: app/providers.jsx
"use client";

// Since QueryClientProvider relies on useContext under the hood, we have to put 'use client' on top
import {
  isServer,
  QueryClient,
  QueryClientProvider,
} from "@tanstack/react-query";

function makeQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        // With SSR, we usually want to set some default staleTime
        // above 0 to avoid refetching immediately on the client
        staleTime: 60 * 1000,
      },
    },
  });
}

let browserQueryClient: QueryClient | undefined = undefined;

function getQueryClient() {
  if (isServer) {
    // Server: always make a new query client
    return makeQueryClient();
  } else {
    // Browser: make a new query client if we don't already have one
    // This is very important, so we don't re-make a new client if React
    // suspends during the initial render. This may not be needed if we
    // have a suspense boundary BELOW the creation of the query client
    if (!browserQueryClient) browserQueryClient = makeQueryClient();
    return browserQueryClient;
  }
}

export default function Providers({ children }: { children: React.ReactNode }) {
  // NOTE: Avoid useState when initializing the query client if you don't
  //       have a suspense boundary between this and the code that may
  //       suspend because React will throw away the client on the initial
  //       render if it suspends and there is no boundary
  const queryClient = getQueryClient();

  return (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  );
}

src>app>layout.tsx

import type { Metadata } from "next";
import { Inter } from "next/font/google";

import "./globals.css";
import Providers from "@/components/providers/RQProvider";

const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}
  1. react-query-devtools설치

☘️ Zustand

: 간소화된 Flux원리를 기반으로 한 상태관리 라이브러리

  • 장점
  1. 보일러플레이트의 최소화 : 반복적으로 사용해야 하는 코드 양을 최소화하여 프로젝트의 유지보수를 쉽게 함
  2. 아주작은 크기 : Zustand핵심 로직은 바닐라 자바스크립트 수십줄로 구성되어있는만큼 가벼운 라이브러리이다.
  3. 효율적인 렌더링 : 상태가 변경될 때만 컴포넌트를 렌더링하므로, 불필요한 리렌더링을 방지하여 성능을 향상시킴

Zustand 사용법

yarn add zustand
  1. src>components>ZustandProvider.tsx
  • createStore()로 store만들어주기
import { Product } from "@/type/product";
import { createStore } from "zustand";

export type CartProduct = Product & { quantity: number };

export interface CartProps {
  products: CartProduct[];
}

export interface CartState extends CartProps {
  addProduct: (product: CartProduct) => void;
}

export type CartStore = ReturnType<typeof createCartStore>;

export const createCartStore = (initProps?: Partial<CartProps>) => {
  const DEFAULT_PROPS: CartProps = {
    products: [],
  };
  return createStore<CartState>()((set) => ({
    ...DEFAULT_PROPS,
    ...initProps,
    addProduct: (product: CartProduct) =>
      set((state) => ({
        products: [...state.products, product],
      })),
  }));
};
  1. ZustandProvider.tsx
"use client";

import { useContext, useRef } from "react";
import { CartProps, CartState, CartStore, createCartStore } from "@/cartStore";
import { createContext } from "react";
import { useStore } from "zustand";

export function useCartContext<T>(selector: (state: CartState) => T): T {
  const store = useContext(CartContext);
  if (!store) throw new Error("Missing CartContext.Provider in the tree");
  return useStore(store, selector);
}

export const CartContext = createContext<CartStore | null>(null);

type CartProviderProps = React.PropsWithChildren<CartProps>;
export function CartProvider({ children, ...props }: CartProviderProps) {
  const storeRef = useRef<CartStore>();
  if (!storeRef.current) {
    storeRef.current = createCartStore(props);
  }
  return (
    <CartContext.Provider value={storeRef.current}>
      {children}
    </CartContext.Provider>
  );
}

Next.js 프로젝트 설정 & shardcn-ui사용하기

npx create-next-app@latest my-app --typescript --tailwind --eslint

shadcn/ui설치

npx shadcn-ui@latest init
profile
기술을 넘어 제품의 가치를 만드는 프론트엔드 엔지니어를 지향합니다.

0개의 댓글