본문 바로가기

FrontEnd

[React][Remix][Jokes App Tutorial][Part1]

반응형

 

Remix | Jokes App

 

Remix | Jokes App

 

remix.run

해당 페이지를 의역 밎 정리한 내용이다.

해당 프로젝트는 youtubue를 보면서 따라할 수 도 있다.

 

내용이 생각보다 꽤 길어서 코드는 제외하려다

3편 정도로 나누어서 전부 다루는게 낫다는 생각이 들어 추후 재작업 예정.

 

Work through this tutorial with Kent in this live stream

 

들어가며


해당 프로젝트는 Remix의 핵심 아이디어에만 집중할 예정임.

불필요한 기본 지식들은 스킵 및 복붙.

remix는 웹 표준을 강조함. MDN 문서를 많이 보게 될 것임.

 

개요


튜토리얼에서 다루는 내용들

  • 프로젝트 생성
  • 컨벤션
  • 라우팅
  • 스타일링
  • 데이터 접근
  • 뮤테이션(커맨드)
  • 검증
  • 인증
  • 에러처리
    • 개발자에게 난감한 에러처리
    • 사용자에게 난감한 에러처리
  • SEO와 Meta Tags
  • 자바스크립트
  • 리소스 라우팅
  • 배포

선수지식


로컬 설치 시 설치할 것

  • Node.js 14 or greater
  • npm 7 or greater
  • A code editor (VSCode is a nice one)

리액트/타입스크립트

 

The Beginner's Guide to React

React got that name for a reason. It’s a component-based JavaScript library that renders smartly and can seriously simplify your work. This course is for React newbies and anyone looking to build a solid foundation. It’s designed to teach you everythin

egghead.io

약간의 HTTP API에 대한 개념

프로젝트 생성


npx create-remix@latest

아래와 같이 입력한다.

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 App Server" 옵션은 가장 기본적인 익스프레스 서버를 의미함.

cd remix-jokes

프로젝트 구조 


remix-jokes
├── README.md
├── app
│   ├── entry.client.tsx
│   ├── entry.server.tsx
│   ├── root.tsx
│   └── routes
│       └── index.tsx
├── 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 - 루트 컴포넌트. <html> 문서 생성
  • app/routes/ - 라우트 모듈 위치. 파일 경로 기반 라우팅. 
  • public/ - 정적 assets 위치 (images/fonts/etc)
  • remix.config.js - Remix 설정파일.
npm run build
Building Remix app in production mode...
Built in 132ms

아래와 같은 세 개의 디랙토리가 새로 생긴다. (.gitignore)

  • .cache/
    • 내부파일
  • build/
    • 서버 사이드 코드 위치
  • public/build
    • 클라이언트 사이드 코드 위치
npm start
Remix App Server started at http://localhost:3000

아래 폴더의 모든 파일을 지운다.

  • app/routes
  • app/styles

app/root.tsx 파일을 아래와 같이 바꾼다.

import { LiveReload } from "remix";

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

 

<LiveReload /> 컴포넌트는 HMR을 위해 사용됨

이제 app 폴더 구조는 다음과 같음.

app
├── entry.client.tsx
├── entry.server.tsx
└── root.tsx
npm run dev

npm run dev

라우터


아래와 같은 라우팅 경로를 추가할 것이다.

/
/jokes
/jokes/:jokeId
/jokes/new
/login

remix.comfig.js에 설정할 수 있지만. 파일 구조 베이스가 더 간단하다.

 

추가로 살펴보기 : 

"Route Module"

 

Remix | Conventions

Conventions A lot of Remix APIs aren't imported from the "remix" package, but are instead conventions and exports from your application modules. When you import from "remix", you are calling Remix, but these APIs are when Remix calls your code. remix.confi

remix.run

the route filename convention

 

Remix | Conventions

Conventions A lot of Remix APIs aren't imported from the "remix" package, but are instead conventions and exports from your application modules. When you import from "remix", you are calling Remix, but these APIs are when Remix calls your code. remix.confi

remix.run

내부적으로 React Router모듈을 활용함.

app/routes/index.tsx 파일을 만들고 default export를 하자. index는 말 그대로 인덱스다.

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

리액트 라우터는 nested route를 지원하는데, 이는 라우터의 부모자식 관계를 의미한다.

app/roots는 app/routes/index.tsx의 부모다.

즉 부모는 자식의 레이아웃을 책임진다.

app/root.tsx를 업데이트한다.

<Outlet/>위치에 해당 경로자식을 렌더링한다.

root에는 이제 routes/index.tsx의 파일이 렌더링된다.

import { 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>
      </head>
      <body>
        <Outlet />
        {process.env.NODE_ENV === "development" ? (
          <LiveReload />
        ) : null}
      </body>
    </html>
  );
}
npm run dev로 dev 서버를 실행해야 함을 기억하라.

Outlet이 없으면

Outlet위치에 자식 컴포넌트가 들어온게 보인다.
Outlet이 있으면
 

/jokes 경로를 만들자.

app/routes/jokes.tsx

이번에도 Outlet이 있다. 자식 렌더링의 placeholder다.

해당 파일은 /jokes경로로 가면 메인으로 보인다.

import { Outlet } from "remix";

export default function JokesRoute() {
  return (
    <div>
      <h1>J🤪KES</h1>
      <main>
        <Outlet />
      </main>
    </div>
  );
}

 

app/routes/jokes/index.tsx
export default function JokesIndexRoute() {
  return (
    <div>
      <p>Here's a random joke:</p>
      <p>
        I was wondering why the frisbee was getting bigger,
        then it hit me.
      </p>
    </div>
  );
}

이제 /jokes경로로 가면, jokes 컴포넌트 내의 index.js가 보일 것이다.

  • app/routes/{folder} with app/routes/{folder}.tsx: When a route has the same name as a folder, it becomes a "layout route" for the child routes inside the folder. Render an <Outlet /> and the child routes will appear there. This is how you can have multiple levels of persistent layout nesting associated with URLs.
  • app/routes/index.tsx: Routes named "index" will render when the parent layout route's path is matched exactly.

이제 root > jokes > jokes/index.tsx가 반영되어 렌더링된다.

title을 보면 root가 반영되어 있음을 볼 수 있다.

이제 농담을 추가할 수 있는 컴포넌트를 만들어보자.

app/routes/jokes/new.tsx

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>
  );
}

이제 root > jokes > new 컴포넌트가 중첩되어 렌더링된다.

중첩 렌더링.

파라미터를 포함한 라우터

/jokes/:jokeId같이 동적으로 경로를 만들어보자.

파일명을 $jokeId.tsx처럼 만든다.

app/routes/jokes/$jokeId.tsx
export default function JokeRoute() {
  return (
    <div>
      <p>Here's your hilarious joke:</p>
      <p>
        Why don't you find hippopotamuses hiding in trees?
        They're really good at it.
      </p>
    </div>
  );
}

위 사진같이 아무 경로나 입력해보자.

기본 경로 설정이 끝났다.

스타일링

보통 웹 개발 시 css를 한번에 가져온다.

remix에서는 routes단위로 css를 적용할 수 있다.

해당 경로의 css파일만 적용하게 된다. (자식 x)

<link rel="stylesheet" href="/path-to-file.css" />

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%
  );
}

app/routes/index.tsx

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

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

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

하나 더 해줄게 남았는데,

Outlet으로 컴포넌트 렌더링 요소를 지정했던 것처럼,

Link 위치를 root에서 지정해준다.

app/root.tsx

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>
  );
}

이제 root 경로 접근 시 해당 스타일 시트를 자신 포함 자손 라우트 컴포넌트들에 전부 적용한다.

css가 적용된 모습

참고 : 

/* A gradient at the center of its container,
   starting red, changing to blue, and finishing green */
radial-gradient(circle at center, red 0, blue, green 100%)

하지만 css는 해당 경로(exact match)에만 적용되므로, 다른 페이지로 이동하면 link가 삭제된다.

(css 충돌방지)

css가 없네.

글로벌 css를 사용하려면 아래와 같이 한다.

(css 코드는 생략)

app/root.tsx
import type { LinksFunction } from "remix";
import { Links, LiveReload, Outlet } from "remix";

import globalStylesUrl from "./styles/global.css";
import globalMediumStylesUrl from "./styles/global-medium.css";
import globalLargeStylesUrl from "./styles/global-large.css";

export const links: LinksFunction = () => {
  return [
    {
      rel: "stylesheet",
      href: globalStylesUrl
    },
    {
      rel: "stylesheet",
      href: globalMediumStylesUrl,
      media: "print, (min-width: 640px)"
    },
    {
      rel: "stylesheet",
      href: globalLargeStylesUrl,
      media: "screen and (min-width: 1024px)"
    }
  ];
};

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>
  );
}

해당 파트는 본문의 많은 부분을 생략하였지만, 설정은 어떤 식으로 이루어지는지 알 것이다.

추가로 해당 내용을 곱씹어보면 styled-component 사용이 어려워 보일 것이다.

실제로 remix에서는 쓸 수 없는 것은 아니지만 Tailwind와 같은 조금 더 low level approach를 장려한다.

  • Remix가 캐싱 및 로드/언로드를 위해 브라우저 플랫폼을 활용할 수 있기 때문에 일반적으로 좋은 접근 방식입니다.
  • 이러한 스타일링 솔루션의 생성으로 이어진 많은 문제는 Remix에서 문제가 아니므로 종종 더 간단한 스타일 접근 방식을 사용할 수 있습니다.

Database


인메모리 db인 sqlite와 prisma(their docs)를 사용해본다.

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

npx prisma init --datasource-provider sqlite
✔ Your Prisma schema was created at prisma/schema.prisma
You can now open it in your favorite editor.

warn You already have a .gitignore. Don't forget to exclude .env to not commit any secret.

Next steps:
1. Set the DATABASE_URL in the .env file to point to your existing database. If your database has no tables yet, read https://pris.ly/d/getting-started
2. Run prisma db pull to turn your database schema into a Prisma schema.
3. Run prisma generate to generate the Prisma Client. You can then start querying your database.

More information in our documentation:
https://pris.ly/d/getting-started

prisma/schema.prisma

// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

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
Environment variables loaded from .env
Prisma schema loaded from prisma/schema.prisma
Datasource "db": SQLite database "dev.db" at "file:./dev.db"
🚀 Your database is now in sync with your schema. Done in 194ms

✔ Generated Prisma Client (3.5.0) to ./node_modules/
@prisma/client in 26ms

prisma/dev.db에 db 파일을 만듬.

그런 다음 우리가 제공한 스키마와 일치하도록 데이터베이스에 필요한 모든 변경 사항을 푸시함.

마지막으로 Prisma의 TypeScript 유형을 생성하므로

데이터베이스와 상호 작용하기 위해 API를 사용할 때 뛰어난 자동 완성 및 유형 검사를 얻을 수 있음.

데이터베이스가 엉망이 되면 언제든지 prisma/dev.db 파일을 삭제하고 npx prisma db push를 다시 실행할 수 있습니다.

이제 가라데이터를 만든다.

prisma/seed.ts

import { PrismaClient } from "@prisma/client";
const db = new PrismaClient();

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.`
    }
  ];
}
npm install --save-dev esbuild-register
node --require esbuild-register prisma/seed.ts

package.json에 해당 라인 추가.

// ...
  "prisma": {
    "seed": "node --require esbuild-register prisma/seed.ts"
  },
  "scripts": {
// ...

개발 환경에서는 커넥션을 공유하거나 live-reload시 커넥션을 닫았다 다시 열어야함.

이것은 리믹스 전용 문제가 아닙니다. 서버 코드를 "라이브 다시 로드"할 때마다 데이터베이스에 연결을 끊었다가 다시 연결해야 합니다(느릴 수 있음).
아니면 제가 보여드릴 해결 방법을 수행해야 합니다.

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 };
파일 이름에 .server를 추가하는 것은
컴파일러가 브라우저에 번들링할 때 이 모듈이나 모듈의 가져오기에 대해 걱정하지 않도록 하는 힌트입니다.

Remix loader로 데이터 가져오기.

Remix 경로 모듈에서 데이터를 로드하려면 로더를 사용합니다.

이것은 응답을 반환하고 useLoaderData 후크를 통해 구성 요소에서 액세스하는 단순히 내보내는 비동기 함수입니다. 

아래는 간단한 예제임.

// this is just an example. No need to copy/paste this 😄
import type { LoaderFunction } from "remix";
import type { User } from "@prisma/client";
import { db } from "~/utils/db.server";

type LoaderData = { users: Array<User> };
export let loader: LoaderFunction = async () => {
  const data: LoaderData = {
    users: await db.user.findMany()
  };
  return { data };
};

export default function Users() {
  const data = useLoaderData<LoaderData>();
  return (
    <ul>
      {data.map(user => (
        <li>{user.name}</li>
      ))}
    </ul>
  );
}

app/routes/jokes.tsx에 아래 세 줄의 코드를 적용해 보자.

type LoaderData = {
  jokeListItems: Array<{ id: string; name: string }>;
};
////////
export const loader: LoaderFunction = async () => {
  const data: LoaderData = {
    jokeListItems: await db.joke.findMany()
  };
  return data;
};
///////
{data.jokeListItems.map(joke => (
  <li key={joke.id}>
  <Link to={joke.id}>{joke.name}</Link>
  </li>
))}

데이터를 불러온다.

 

2편에서 계속

반응형