remix run tutorial (전편)

dante Yoon·2021년 12월 15일
6

Remix

목록 보기
1/1
post-thumbnail

리믹스 문서의 튜토리얼을 보고 리믹스의 기초 사용방법에 대해 정리한 글입니다.

프로젝트 시작

npx create-remix@latest

다음 화면이 쉘에 표시됩니다.

where do you want to deploy? ... 질문에는 Remix App Server를 사용했는데요,
리믹스 앱 서버는 익스프레스 기반의 노드서버를 사용합니다.
node.js 최소 버전은 >= 14, 권장 버전은 >= 16이며
npm 최소 버전은 >= 7, 권장 버전은 >=8 입니다.

R E M I X

💿 Welcome to Remix! Let's get you set up with a new project.

? Where would you like to create your app? remix-jokes
? Where do you want to deploy? Choose Remix if you're unsure, it's easy to change deployment targets. Remix
 App Server
? TypeScript or JavaScript? TypeScript
? Do you want me to run `npm install`? Yes

생성된 remix-jokes 프로젝트 폴더는 다음과 같은 계층구조를 보입니다.

├── README.md
├── app
│   ├── entry.client.tsx
│   ├── entry.server.tsx
│   ├── root.tsx
│   ├── routes
│   │   ├── demos
│   │   │   ├── about
│   │   │   │   ├── index.tsx
│   │   │   │   └── whoa.tsx
│   │   │   ├── about.tsx
│   │   │   ├── actions.tsx
│   │   │   ├── correct.tsx
│   │   │   ├── params
│   │   │   │   ├── $id.tsx
│   │   │   │   └── index.tsx
│   │   │   └── params.tsx
│   │   └── index.tsx
│   └── styles
│       ├── dark.css
│       ├── demos
│       │   └── about.css
│       └── global.css
├── package-lock.json
├── package.json
├── public
│   └── favicon.ico
├── remix.config.js
├── remix.env.d.ts
└── tsconfig.json

app/은 리믹스 앱의 코드들이 자리하는 곳입니다.
app/entry.client.tsx는 브라우저에서 구동되는 리믹스 앱의 시작점입니다. hydrate가 일어납니다.
app/entry.server.tsx는 노드 서버에서 구동되는 리믹스 앱의 시작점입니다. 서버로 들어온 요청을 받아 클라이언트로 전달되는 응답을 구성하고, 서버사이드 렌더링을 실행합니다.
app/root.tsx 최상단 부모(루트) 컴포넌트가 위치하는 자리입니다. next.js의 _app.tsx와 비슷한 역할을 한다고 보면 됩니다.
app/routes/ url 경로에 따라 페이지를 매핑하는 폴더입니다.
public/ static asset 들이 들어가는 자리입니다.
public/build 빌드된 클라이언트 코드들이 들어가는 폴더입니다.
build 빌드된 서버 코드들이 들어가는 폴더입니다.
remix.config.js next.js의 next.config.js와 같은 역할을 하는 파일입니다. 리믹스 앱의 환경설정을 할 수 있습니다.

페이지 라우트

├── app
│   ...
│   ├── root.tsx
│   ├── routes
│   │   ├── index.tsx
│   │   └── jokes.tsx

리믹스의 페이지 라우트는 routes 폴더 아래 파일 디렉토리 구조로 만들어 설정합니다.

  1. 각 폴더의 index.tsx는 localhost:3000/폴더이름 주소로 접속했을 때 보여지는 페이지들로 routes/{{폴더이름}}/index.tsx와 같은 디렉토리 구조로 만들어야 합니다.

  2. Outlet은 부모 라우트가 자식 라우트를 포함하는 레이아웃을 만들때 사용합니다.

다음과 같이 routes 밑에 jokes.tsx 파일이 있고 routes/jokes 폴더 아래 index.tsx파일이 있다고 한다면,

├── app
│   ...
│   ├── root.tsx
│   ├── routes
│   │   ├── jokes
│   │   │    └── index.tsx
│   │   ├── index.tsx
│   │   └── jokes.tsx

jokes.tsx 파일 내부에 있는 <Outlet/> 태그에 index.tsx 파일에 선언된 jsx가 렌더링 됩니다.

// routes/jokes.tsx
import { Outlet } from "remix";
function Jokes(){
  return (
    <div>
      jokes page
      <Outlet />
    </div>
  )
}

export default Jokes;
  1. parameterize routes
    localhost:3000/jokes/:paramId 와 같이 특정 파라메터가 jokes 라우트 이하에 붙을 때 보여질 페이지를 만들게 해줍니다.

다음과 같이 프로젝트 디렉토리가 구성되어 있을 때
localhost:3000/jokes 접근 시 jokes/index.tsx를,
localhost:3000/jokes/whatever 접근 시 jokes/$paramId.tsx를 보여줍니다.

├── app
...
│   ├── routes
│   │   ├── jokes
│   │   │   ├── $paramId.tsx
│   │   │   ├── index.tsx

만약 아래와 같이 jokes 아래 $a.tsx $paramId.tsx 두 개의 파일이 있다면 알파벳 순서에 따라 $paramId.tsx는 무시되고 $a.tsx 파일의 내용만 보여집니다.

├── app
...
│   ├── routes
│   │   ├── jokes
│   │   │   ├── $a.tsx
│   │   │   ├── $paramId.tsx
│   │   │   ├── index.tsx
  1. nextjs의 catch-all routes 기능을 사용하기
    nextjs 의 catch all routes 와 같은 기능을 제공하며 이는 $ 달러사인 기호로 이용할 수 있습니다.
    위와 같이 routes/jokes/$.tsx의 구조로 라우트 디렉토리를 구성했다면,
    localhost:3000/jokes/whatever과 같이 jokes 이하에 붙는 모든 경로를 해당 페이지에서 처리합니다.

catch all routes ( 리믹스에서 사용하는 용어는 아닙니다. 설명의 용이성을 위해 nextjs의 용어를 차용합니다.)를 사용하더라도 프로젝트 구조상 jokes/index.tsx 파일이 존재한다면

localhost:3000/jokes 로 접속할 때 $.tsx 파일이 아닌 index.tsx에 정의한 페이지를 가르키게 됩니다.
만약 index.tsx에 export default 구문으로 정의한 함수가 없더라도 index.tsx 파일이 있으면 리믹스는 해당 파일을 바라보고 null을 화면에 표기합니다.

├── app
│   ...
│   ├── root.tsx
│   ├── routes
│   │   ├── jokes
│   │   │   ├── $.tsx
│   │   │   ├── index.tsx
│   │   ├── index.tsx
│   │   └── jokes.tsx

styling

스타일링 쪽은 프레임워크마다 상이해서 새로운 내용이 나오면 이제 좀 새로 학습하는데 피곤한 느낌이 없지 않습니다.

스타일링에 필요한 css 파일들은 프로젝트 디렉토리 어느 곳에 위치해도 상관 없으나, 이번 글에서는 app/styles/ 에 만듭니다.

app/styles/index.css

body {
  color: hsl(0, 0%, 100%);
  background-image: radial-gradient(
    circle,
    rgba(152, 11, 238, 1) 0%,
    rgba(118, 15, 181, 1) 35%,
    rgba(58, 13, 85, 1) 100%
  );
}

스타일링을 적용할 때는 css 파일을 불러와 links 변수명으로 함수를 정의해서 export 시킵니다.
다음과 같이 app/rotues/index.tsx 파일에다가 위에서 작성한 index.css 스타일링을 적용시킵니다.

import type { LinksFunction } from "remix";
import stylesUrl from "../styles/index.css";

export let links: LinksFunction = () => {
  return [{ rel: "stylesheet", href: stylesUrl }];
};

export default function IndexRoute() {
  return <div>Hello Index Route</div>;
}

이때 links 는 리믹스에서 LinksFunction으로 타입을 정의해주므로 타입정의를 가져다가 사용합니다.

스타일링을 적용시키기 위해서는 한가지 선결조건을 더 만족시켜야 하는데, 앱의 렌더링을 담당하는 app/root.tsx에 리믹스 모듈의 Link 태그를 선언해야 합니다.

import { Links, LiveReload, Outlet } from "remix";

export default function App() {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <title>Remix: So great, it's funny!</title>
        <Links />
      </head>
      <body>
        <Outlet />
        {process.env.NODE_ENV === "development" ? (
          <LiveReload />
        ) : null}
      </body>
    </html>
  );
}

export let links: LinksFunction 은 rotues 이하의 페이지 레벨에서만 동작하기 때문에
컴포넌트 단위로 스타일링을 적용할때는 다음과 같은 방법을 참고해보세요.
app/components/button/index.tsx

import React, { PropsWithChildren } from "react";
import buttonStyles from "~/styles/components/button.css";

interface ButtonProps extends React.HTMLAttributes<HTMLButtonElement>{
}

export default function Button({onClick}: PropsWithChildren<ButtonProps>) {
  return (
    <button
      onClick={onClick}
      aria-label="remix-button"
      className="remix__button"
    >
      remix button!
    </button> 
  )
}

Button.styles = buttonStyles;

app/routes/index.tsx

import type { LinksFunction } from "remix";
import stylesUrl from "../styles/index.css";

import Button from "~/components/button";

export let links: LinksFunction = () => {
  return [
    { rel: "stylesheet", href: stylesUrl },
    { rel: "stylesheet", href: Button.styles},
  ];
};

export default function IndexRoute() {
  return (
    <> 
      <div>Hello Index Route</div>
      <Button/>
    </>
  )
}

LinksFunction에 들어갈 key, value 쌍을 한데 모아서 정의한 이후에 상위 라우트에서 일괄적으로 적용시키는 것이 편할 수도 있겠습니다.

database (라고 썼지만 server-side data fetching에 대해)

이번 글에서는 데이터베이스로 sqlite + prisma의 조합을 사용합니다. 리믹스는 모든 종류의 데이터베이스와 함께 사용할 수 있습니다.

prisma는 개발모드에서 디비와 스키마에 접근하기 위해 설치합니다.
@prisma/client는 런타임에 디비에 접근할 수 있게 해줍니다.

npm install --save-dev prisma
npm install @prisma/client

이제 sqlite를 이용해 prisma를 초기화 시켜 앱에서 사용할 데이터 모델링을 할 준비를 마칩니다.

npx prisma init --datasource-provider sqlite

프로젝트 폴더 루트에.env 파일을 생성하고 다음과 같이 작성합니다.
DATABASE_URL은 prisma 폴더 아래 생성할 db 파일 명입니다.

DATABASE_URL="file:./dev.db"

프로젝트 디렉터리에 새로 생긴 prisma/schema.prisma 파일에 데이터 모델을 정의합니다.

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

model Joke {
  id         String   @id @default(uuid())
  createdAt  DateTime @default(now())
  updatedAt  DateTime @updatedAt
  name       String
  content    String
}

다음의 명령어를 입력하면 어떠한 과정이 일어나는지 살펴봅니다.

npx prisma db push

.env에 적힌 DATABASE_URL의 값에 따라 prisma/dev.db 파일이 생성되고, 작성한 스키마에 담긴 변경내역을 dev.db에 저장합니다. 마지막으로 Joke 모델에 대한 typescript의 타입이 생성되어 리믹스 앱 내부에서 타입을 참조할 수 있게 되었습니다.
dev.db는 .gitignore에 추가하여 staged 되지 않도록 합니다.

개발모드에서 hot reload 기능을 제공하는 서버에서 프리즈마를 사용할 시, 코드의 변경이 일어날 때마다 connection을 다시 맺게 되고 10번의 상한선을 넘게 되면 아래와 같은 경고 문구가 나오는데, 이를 피할 수 있게 해주는 함수를 작성합니다.

Warning: 10 Prisma Clients are already running

리믹스는 앱을 번들링 할 때 클라이언트,서버 두가지 js 파일을 번들링하는데, 서버쪽 번들된 js 파일에만 담겨야할 파일은 .server을 파일명에 포함시켜 컴파일러에게 명시적으로 알려줄 수 있습니다.

app/utils/db.server.ts

import { PrismaClient } from "@prisma/client";

let db: PrismaClient;

declare global {
  var __db: PrismaClient | undefined;
}

// this is needed because in development we don't want to restart
// the server with every change, but we want to make sure we don't
// create a new connection to the DB with every change either.
if (process.env.NODE_ENV === "production") {
  db = new PrismaClient();
  db.$connect();
} else {
  if (!global.__db) {
    global.__db = new PrismaClient();
    global.__db.$connect();
  }
  db = global.__db;
}

export { db };

위에서 만든 util 함수를 이용해 앱이 처음 시작할때 joke db를 만들었습니다.
prisma/seed.ts

import { PrismaClient } from "@prisma/client";
import { db } from "~/utils/db.server"

async function seed() {
  await Promise.all(
    getJokes().map(joke => {
      return db.joke.create({ data: joke });
    })
  );
}

seed();

function getJokes() {
  // shout-out to https://icanhazdadjoke.com/

  return [
    {
      name: "Road worker",
      content: `I never wanted to believe that my Dad was stealing from his job as a road worker. But when I got home, all the signs were there.`
    },
    {
      name: "Frisbee",
      content: `I was wondering why the frisbee was getting bigger, then it hit me.`
    },
    {
      name: "Trees",
      content: `Why do trees seem suspicious on sunny days? Dunno, they're just a bit shady.`
    },
    {
      name: "Skeletons",
      content: `Why don't skeletons ride roller coasters? They don't have the stomach for it.`
    },
    {
      name: "Hippos",
      content: `Why don't you find hippopotamuses hiding in trees? They're really good at it.`
    },
    {
      name: "Dinner",
      content: `What did one plate say to the other plate? Dinner is on me!`
    },
    {
      name: "Elevator",
      content: `My first time using an elevator was an uplifting experience. The second time let me down.`
    }
  ];
}

Read from database in Remix loader

리믹스는 각 라우트에서 사용할 수 있는 loader 함수를 제공하는데, 서버에서 렌더링 하기 전 데이터를 가져와 렌더링 하는 코드 쪽에 제공할 수 있습니다.

렌더 함수내에서 데이터를 가져오기 위해 useLoaderData를 사용합니다.
loader 함수 타입을 위해 LoaderFunction을 사용합니다.

routes/jokes.tsx

import { LinksFunction, LoaderFunction, useLoaderData } from "remix";
import { Outlet, Link } from "remix";
import { db } from "~/utils/db.server";
import stylesUrl from "../styles/jokes.css";

export let links: LinksFunction = () => {
  return [
    {
      rel: "stylesheet",
      href: stylesUrl
    }
  ];
};

type LoaderData =  {
  jokeListItems: Array<{id: string; name: string}>
}

export const loader: LoaderFunction = async () => {
  const data: LoaderData = {
    jokeListItems: await db.joke.findMany(),
    }
  return data;
}

export default function JokesRoute() {
  const data = useLoaderData<LoaderData>();

  return (
    <div className="jokes-layout">
      <header className="jokes-header">
        <div className="container">
          <h1 className="home-link">
            <Link
              to="/"
              title="Remix Jokes"
              aria-label="Remix Jokes"
            >
              <span className="logo">🤪</span>
              <span className="logo-medium">J🤪KES</span>
            </Link>
          </h1>
        </div>
      </header>
      <main className="jokes-main">
        <div className="container">
          <div className="jokes-list">
            <Link to=".">Get a random joke</Link>
            <p>Here are a few more jokes to check out:</p>
            <ul>
              {data.jokeListItems.map(joke => (
                <li key={joke.id}>
                  <Link to={joke.id}>{joke.name}</Link>
                </li>
              ))}
            </ul>
            <Link to="new" className="button">
              Add your own
            </Link>
          </div>
          <div className="jokes-outlet">
            <Outlet />
          </div>
        </div>
      </main>
    </div>
  );
}

뷰를 그릴 때 필요한 데이터보다 더 많은 데이터가 REST API의 응답값으로 전달될 때, 클라이언트 번들링에 불필요한 데이터를 포함시키지 않는 필터 기능을 loader 함수 내부에 작성할 수 있어 앱 사이즈가 커지더라도 불필요하게 번들링 크기가 커지는 것을 방지할 수 있습니다.

아래 코드에서 paramterized routes 에서 param 속성을 이용해 필요한 데이터를 불러오고 있습니다.
params.paramsId를 이용해 $paramId를 조회하고 있습니다.
params 키 값은 routes/jokes/$paramId.tsx 뿐만 아니라 routes/jokes.tsx에서도 조회가 가능합니다.

routes/jokes/$paramId.tsx

import type { LoaderFunction } from "remix";
import { Link, useLoaderData } from "remix";
import type { Joke } from "@prisma/client";
import { db } from "~/utils/db.server";

type LoaderData = { joke: Joke };

export const loader: LoaderFunction = async ({
  params
}) => {
  console.log(params);
  const joke = await db.joke.findUnique({
    where: { id: params.paramId }
  });
  if (!joke) throw new Error("Joke not found");
  const data: LoaderData = { joke };
  return data;
};

export default function JokeRoute() {
  const data = useLoaderData<LoaderData>();

  return (
    <div>
      <p>Here's your hilarious joke:</p>
      <p>{data.joke.content}</p>
      <Link to=".">{data.joke.name} Permalink</Link>
    </div>
  );
}

Data overfetching

클라이언트에서 필요 이상의 데이터를 사용하는 것을 방지하기 위한 방법으로 그래프 큐엘을 사용할 수 있는데요, react-dom보다 큰 번들 사이즈 때문에 무작정 도입하기 부담스럽습니다.

대안으로 리믹스 loader 함수 내부에서 클라이언트에서만 사용할 데이터를 필터링하는 방법을 문서에서 제시합니다.

클라이언트에 포함되는 데이터 용량을 줄인다는 부분에서 장점이라고 하나, 사실 네트워크 용량을 줄이는게 아니라 서버사이드 렌더링 시에 필터링만 해주는 것이기 때문에 딱히 리믹스만의 장점이라고 생각이 들지는 않았습니다. 개인적으로 Data overfetching 문제의 근원적인 해결방법은 되지는 않는다고 생각합니다.

type LoaderData = {
  jokeListItems: Array<{ id: string; name: string }>;
};

export const loader: LoaderFunction = async () => {
  const data: LoaderData = {
    jokeListItems: await db.joke.findMany({
      take: 5,
      select: { id: true, name: true },
      orderBy: { createdAt: "desc" }
    })
  };
  return data;
};

Mutations (Form)

/jokes/new url 접근 시 새로운 퀴즈를 입력할 수 있는 페이지를 만들겠습니다.

Form building

form의 method 속성에 따라 호출되는 함수가 다른데요,

get 타입이면 loader 함수가 호출되고 post 타입이면 action 함수가 호출됩니다.

아래에서 method="post" 타입의 폼 제출방식을 사용해서 폼을 정의했습니다.
아래 파일은 action 함수가 정의되어있지 않기 때문에 별다른 동작을 하지 않고 에러를 발생시킵니다.

리믹스에서 폼의 속성 중 method="post"로 되었을 때 action 함수가 정의되어있지 않으면 에러가 발생합니다.

export default function NewJokeRoute() {
  return (
    <div>
      <p>Add your own hilarious joke</p>
      <form method="post">
        <div>
          <label>
            Name: <input type="text" name="name" />
          </label>
        </div>
        <div>
          <label>
            Content: <textarea name="content" />
          </label>
        </div>
        <div>
          <button type="submit" className="button">
            Add
          </button>
        </div>
      </form>
    </div>
  );
}

리믹스는 action 함수라는 라우트 모듈을 설정해서 폼 기능을 정의할 수 있습니다.
action 함수의 타입으로 ActionFunction을 사용합니다.

import type { ActionFunction } from "remix";
import { redirect } from "remix";
import { db } from "~/utils/db.server";

export let action: ActionFunction = async ({ request }) => {
  let form = await request.formData();
  let name = form.get("name");
  let content = form.get("content");
  // 타입 체크를 action 함수 내부에서 수행합니다.
  if (
    typeof name !== "string" ||
    typeof content !== "string"
  ) {
    throw new Error(`Form not submitted correctly.`);
  }

  let fields = { name, content };

  let joke = await db.joke.create({ data: fields });
  return redirect(`/jokes/${joke.id}`);
};

action 함수의 인자 중 request에서 조회할 수 있는 formData 함수를 호출함으로써 입력된 form 값들을 조회할 수 있습니다.

타입 체크를 진행하고, 에러 처리를 할 수 있습니다.

php, node.js,spring&jsp 조합등 서버사이드 렌더링 앱의 경우,
기본 html 폼인 <form method="post" action="/submit">...</form> 의 action 속성에 있는 경로를 통해 제출된 폼이 서버 api 라우트에 전달된후 클라이언트에게 리다이렉트 경로를 전달해줘야 클라이언트가 적절하게 동작할 수 있는데, SPA 앱을 사용하면서 기존에 서버 사이드 렌더링 앱에서 가졌던 폼 처리에 대한 기능을 같이 누릴 수 있다는 것이 리믹스의 장점입니다.

Note that no matter what kind of SPA you may have built in the past, you always need a server-side action and a form to get data from the user. The difference with Remix is that's all you need (and that's how the web used to be, too.)

Validation check

보통 SPA를 개발할 때 폼 유효성 검사의 많은 부분을 클라이언트에서 담당하는데요,
클라이언트에서는 잘못된 폼 입력에 따른 안내를 해줘야 하기 때문에 필수적으로 유효성 검사가 들어가야 합니다.
리믹스를 사용한다면 클라이언트 사이드, 서버 사이드 두 부분에서 나눠져 진행되던 유효성 검사를 action함수 한 곳에서 모두 담당할 수 있습니다. 클라이언트 사이드에서 더 이상 유효성 검사를 진행하지 않고 서버 사이드로 책임을 위임하는 것입니다.

export let action: ActionFunction = async ({ request }) => {
  let form = await request.formData();
  let name = form.get("name");
  let content = form.get("content");
  // 타입 체크를 action 함수 내부에서 수행합니다.
  if (
    typeof name !== "string" ||
    typeof content !== "string"
  ) {
    throw new Error(`Form not submitted correctly.`);
  }
  // 따른 백엔드 API 호출에 따른 유효성 검사를 실행합니다.
  const [errors, project] = await createProject(formData);

  if (errors) {
    const values = Object.fromEntries(newProject);
    return { errors, values };
  }
  
  let fields = { name, content };
  let joke = await db.joke.create({ data: fields });
  return redirect(`/jokes/${joke.id}`);
};

loader 함수에서 반환된 값을 사용하기 위해 useLoaderData 함수를 사용했던 것처럼 action 함수에서 반환된 값을 사용하기 위해 useActionData를 사용해야 합니다.

최초 페이지 렌더링 이후(폼이 아직 제출되지 않았을 때) useActionData()는 undefined 이기 때문에 이 부분을 유의하여 사용해야 합니다.

import { redirect, useActionData } from "remix";

export const action: ActionFunction = async ({
  request
}) => {
  // ...
};

export default function NewProject() {
  const actionData = useActionData();

  return (
    <form method="post" action="/projects/new">
      <p>
        <label>
          Name:{" "}
          <input
            name="name"
            type="text"
            defaultValue={actionData?.values.name}
          />
        </label>
      </p>

      {actionData?.errors.name && (
        <p style={{ color: "red" }}>
          {actionData.errors.name}
        </p>
      )}

      <p>
        <label>
          Description:
          <br />
          <textarea
            name="description"
            defaultValue={actionData?.values.description}
          />
        </label>
      </p>

      {actionData?.errors.description && (
        <p style={{ color: "red" }}>
          {actionData.errors.description}
        </p>
      )}

      <p>
        <button type="submit">Create</button>
      </p>
    </form>
  );
}

폼 내부의 각 필드에 defaultValue를 설정해주었습니다. 이를 통해 폼 제출 이후 유효성 검사를 통과하지 못하더라도, 유저는 본인이 입력한 값을 그대로 유지할 수 있습니다.

Pending UI

폼이 등록되고 리다이렉트 되기 전 현재 무슨 일이 진행되고 있다는 것을 유저에게 나타내주기 위해 기본적으로 favicon 아이콘에 작은 스피너가 돌아갑니다. 이것은 브라우저가 기본적으로 제공하는 기능이며, 리믹스는 이 스피너 대신 자체적인 Pending UI를 쉽게 적용하게 도와줍니다.

이를 위해 먼저 html의 <form> 태그를 리믹스에서 제공하는 대문자 <Form>으로 변경해야 합니다.

아래처럼 <Form/> 태그를 사용한다면 더 이상 브라우저에서 제공하는 스피너가 돌아가지 않습니다. 이 상태에서 작업을 마친다면, 유저에게는 더 이상 폼 제출에 대한 시각적인 정보가 전달되지 않아 오히려 UX에 악영향을 미칠 수 있습니다.

import { redirect, useActionData, Form } from "remix";

// ...

export default function NewProject() {
  const actionData = useActionData();

  return (
    // note the capital "F" <Form> now
    <Form method="post">{/* ... */}</Form>
  );
}1

만약 pending state를 적용할 시점이 미래라면, <Form reloadDocument>을 이용해 브라우저의 기본 스피너 동작을 보장해주는 것이 최선의 방법이 될 수 있습니다.

입력된 form을 useActionData 함수 값을 통해 참조했듯이 pending state를 useTransition 함수를 호출함으로 참조할 수 있습니다.

form 입력 시 get method 타입은 쿼리스트링이 붙여진다는 것만 제외한다면 a link를 통한 다른 페이지로의 이동과 동일하다는 것을 기억한다면, useTransition이 단순히 폼 제출에만 사용되는 것이 아닌, 페이지 네비게이션에서도 사용된다는 것을 유추할 수 있습니다.

useTransition()은 Transition 타입을 반환하며, 이 Transition 타입은 key,value 쌍을 가진 Record 타입으로 state, type, submission, location을 키값으로 가집니다.

state
폼 제출 과정에서 현재 어디에 state가 있는지를 알려줍니다.

state는 idle, submitting, loading 중 하나가 되며

idle:현재 아무런 transition이 일어나지 않는 유휴 상태입니다.
submitting: 앞서 method="get"으로 폼이 입력될 떄는 loader 함수가, method="post"가 입력될 때는 action 함수가 호출된다고 했는데, submitting 상태일 때 이러한 함수들이 호출됩니다.
loading: 페이지 네비게이션 시 다음 라우트 페이지에 정의되어 있는 loader 함수가 호출됩니다.

각 시나리오에서 state 변화과정을 추적해보면 다음과 같습니다.

보통의 페이지 네비게이션 시

idle → loading → idle

GET폼 입력 시

idle → submitting → idle

POST폼 입력 시

idle → submitting → loading → idle

import {
  redirect,
  useActionData,
  Form,
  useTransition
} from "remix";

// ...

export default function NewProject() {
  // when the form is being processed on the server, this returns different
  // transition states to help us build pending and optimistic UI.
  const transition = useTransition();
  const actionData = useActionData();

  return (
    <Form method="post">
      <fieldset
        disabled={transition.state === "submitting"}
      >
        <p>
          <label>
            Name:{" "}
            <input
              name="name"
              type="text"
              defaultValue={
                actionData
                  ? actionData.values.name
                  : undefined
              }
            />
          </label>
        </p>

        {actionData && actionData.errors.name && (
          <p style={{ color: "red" }}>
            {actionData.errors.name}
          </p>
        )}

        <p>
          <label>
            Description:
            <br />
            <textarea
              name="description"
              defaultValue={
                actionData
                  ? actionData.values.description
                  : undefined
              }
            />
          </label>
        </p>

        {actionData && actionData.errors.description && (
          <p style={{ color: "red" }}>
            {actionData.errors.description}
          </p>
        )}

        <p>
          <button type="submit">
            {transition.state === "submitting"
              ? "Creating..."
              : "Create"}
          </button>
        </p>
      </fieldset>
    </Form>
  );
}

버튼 안에 있는 transition.state 부분을 좀 더 뜯어보자면, 폼이 입력되고 action 함수가 호출되는 동안 Creating... 문구가 노출됨을 유추해볼 수 있습니다.

  {
    transition.state === "submitting"
      ? "Creating..."
      : "Create"
  }

이번 글에서는 스타일링 부터 Form mutation까지 알아보았습니다. 다음 편에서는 authentication, error handling에 대해 알아보겠습니다. 리믹스를 학습하는 분들에게 본 글이 도움이 되기를 바랍니다.

reference
1.https://remix.run/docs/en/v1/tutorials/jokes
2.https://remix.run/docs/en/v1/api/remix

profile
성장을 향한 작은 몸부림의 흔적들

0개의 댓글