Skip to content

kich555/To-Do-List-with-Remix.run

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 

Repository files navigation

Stack

TS

Utility

Envioronment

NodeJS
NPM

CLI (예정)

Netlify

Style

Style & UI Library

Remix

Remix
server state
Drag & Drop
Forms

DB

가벼움과 단숨함을 이유로 아래와 같은 세팅을 선택했습니다.
풀스택으로 전반적인 웹 흐름을 이해해보고자 하는 욕구도 있었지만,
기존 목표는 풀스택이 아닌 SSR이었기 때문에,
레퍼런스가 있고 비교적 심플하고 간단한 SQLite + Prisma 조합을 선택하였습니다.

DMBS
ORM


Tree (Directory)

.
├── README.md
├── app
│   ├── components <-- 공통 컴포넌트를 관리합니다.
│   │   └── Layout.tsx
│   ├── entry.client.tsx  <-- 브라우저 번들의 진입점입니다. entry.server.tsx에서 생성된 마크업을 ReHydrate하는데 사용됩니다.
│   ├── entry.server.tsx  <-- SSR이 이루어지는 진입점입니다. 기본적인 마크업, HTTP Response를 생성하는데 사용됩니다. 
│   ├── models  <-- db model을 관리합니다.
│   │   ├── todo.server.ts
│   │   └── user.server.ts
│   ├── pages   <-- 각 라우터 별 필요한 구성요소들을 관리합니다. 해당 요소들은 해당 라우터에서만 사용됩니다. 
│   │   ├── auth
│   │   │   ├── AuthModal.tsx
│   │   │   ├── controller  <-- 비즈니스 로직을 관리합니다. 순수 함수, 커스텀 훅, contextAPI를 각각 utils, hooks, context 폴더에 넣어 관리합니다.
│   │   │   │   ├── context
│   │   │   │   │   └── AuthUXProvider.tsx
│   │   │   │   └── utils
│   │   │   │       └── authUtils.ts
│   │   │   ├── errors  <-- 라우터의 에러를 핸들링 할 컴포넌트를 관리합니다
│   │   │   │   └── AuthErrorContainer.tsx
│   │   │   └── styles  <-- 라우터의 스타일을 관리합니다.
│   │   │       └── authStyles.tsx
│   │   ├── errors  <-- 글로벌 에러 모듈을 관리합니다.
│   │   │   ├── ErrorHandler.tsx
│   │   │   └── styles
│   │   │       └── errorHandlerStyles.tsx
│   │   ├── home
│   │   │   ├── HomContent.tsx
│   │   │   ├── components
│   │   │   ├── errors
│   │   │   │   └── DefaultErrorContainer.tsx
│   │   │   └── styles
│   │   │       └── homeContentStyles.tsx
│   │   ├── todo
│   │   │   ├── TodoDetail.tsx
│   │   │   ├── components
│   │   │   │   ├── DefaultDescriptionContainer.tsx
│   │   │   │   └── UpdateDescriptionContainer.tsx
│   │   │   ├── controller
│   │   │   │   ├── context
│   │   │   │   │   └── TodoModalProvider.tsx
│   │   │   │   ├── hook
│   │   │   │   │   └── useDropList.tsx
│   │   │   │   └── utils
│   │   │   │       └── handleArray.ts
│   │   │   ├── errors
│   │   │   │   └── TodoErrorContainer.tsx
│   │   │   └── styles
│   │   │       └── todoDetailStyles.tsx
│   │   └── todos
│   │       ├── TodoCard.tsx
│   │       ├── TodoList.tsx
│   │       ├── TodoProgress.tsx
│   │       ├── errors
│   │       │   └── ListErrorContainer.tsx
│   │       └── styles
│   │           ├── todoCardStyles.tsx
│   │           └── todoProgressStyles.tsx
│   ├── root.tsx  
│   ├── routes  <— 라우터의 집합소입니다. 해당 라우터의 action / loader / Error등 서버상태를 관리하며 뼈대의 역할을 합니다.
│   │   ├── auth
│   │   │   ├── login.tsx
│   │   │   └── register.tsx
│   │   ├── auth.tsx
│   │   ├── delete.tsx
│   │   ├── index.tsx
│   │   ├── logout.tsx
│   │   ├── todos
│   │   │   └── $id.tsx
│   │   └── todos.tsx
│   ├── styles  <— 글로벌 스타일을 관리합니다
│   │   ├── commonStyles.ts
│   │   └── reset.css
│   ├── types  <— 글로벌 타입을 관리합니다
│   │   └── commontypes.ts
│   └── utils  <— 서버측을 관리합니다. session, db, action handler등을 관리합니다.
│       ├── actionHandler.server.ts
│       ├── db.server.ts
│       └── session.server.ts
├── emotion.d.ts
├── jsconfig.json
├── netlify.toml
├── package-lock.json
├── package.json
├── prisma
│   ├── dev.db
│   ├── migrations
│   │   ├── 20220922174416_
│   │   │   └── migration.sql
│   │   ├── 20220926185647_
│   │   │   └── migration.sql
│   │   └── migration_lock.toml
│   ├── schema.prisma
│   └── seed.ts  <— 개발용 초기 목 데이터를 관리합니다.
├── remix.config.js
├── remix.env.d.ts
├── server.js
└── tsconfig.json


Action / Loader

우리는 서버를 빠르게 만들 수는 있지만 유저의 네트워크를 제어할 순 없습니다.
리믹스가 선택한 방법은 네트워크를 통해 보내는 양을 줄이는 것 입니다.

모든 request response는 서버에서 이루어지며,
client에는 필요한 데이터만 보낼 수 있습니다.
클라이언트에서는 useActionData , useLoaderData API로
결과값을 넘겨받기만 합니다.
심플합니다.

routes/todos/$id.tsx

loader

export const loader = async ({ params }: LoaderArgs) => {
  const { id } = params;

  if (!id) {
    throw new Response('User Not Found', {
      status: 404,
      statusText: `User Not Found`,
    });
  }
  const todo = await getSingleTodo(id);

  if (!todo) {
    throw new Response('Todo Not Found', {
      status: 404,
      statusText: `Todo Not Found`,
    });
  }

  return json<TodoLoaderData>({ ...todo });
};

action

export const action: ActionFunction = async ({ request }: ActionArgs) => {
  const formData = await request.formData();
  const { _action, _id, ...values } = Object.fromEntries(formData);

  invariant(typeof _action === 'string', `${_action} must be a string`);
  invariant(typeof _id === 'string', `${_id} must be a string`);

  if (_action === 'update') {
    await updateSingleTodo({ _id, ...values });
    return redirect(`/todos/${_id}`);
  }
  if (_action === 'delete') {
    await deleteTodo({ _id });
    return redirect('/todos');
  }

  if (_action === 'categoryUpdate') {
    await updateSingleTodo({ _id, ...values });
    return redirect(`/todos/${_id}`);
  }
  return;
};

route

export default function $idRoute() {
  const { ...data } = useLoaderData() as TodoLoaderData;

  return <TodoDetail todo={data} />;
}


Table Models

prisma를 통해 건내줄 데이터를
쉽고 간단하게 가공합니다.
Remix에서 server.ts와 같이 파일 명에 server가 붙는다면
해당 파일은 서버에서만 실행됩니다.
반대로 client.ts도 마찬가지입니다.

models/user.server.ts

import { db } from '~/utils/db.server';

interface userParams {
  username: string;
  passwordHash: string;
}

export async function createUser({ username, passwordHash }: userParams) {
  return db.user.create({
    data: { username, passwordHash },
  });
}

export async function checkUser(username: string) {
  return db.user.findFirst({
    where: { username },
  });
}

export async function findUser(username: string) {
  return db.user.findUnique({
    where: { username },
  });
}

export async function findUserWithId(userId: string) {
  return db.user.findUnique({
    where: { id: userId },
    select: { id: true, username: true },
  });
}


Server state handler

useTransition, useFetcher, useCatch, useActionData, useLoaderData 등
remix의 서버 상태관리 API들은 정말 쉽고 간단하게
server state를 관리해줍니다.

routes/auth/login.tsx

export default function LoginRoute() {
  const actionData = useActionData() as AuthBadRequestResponse;
  const { classes } = authStyles();
  const { wrapper, label, input, errorInput, errorMessage, button } = classes;
  const { setActionData } = useAuthUX();

  useEffect(() => {
    if (!actionData) return;
    setActionData(actionData);
  }, [setActionData, actionData]);

  return (
    <Form method="post">
      <Box className={wrapper}>
        <Space h={20} />
        <Input.Label htmlFor="username-input" mt={4} className={label}>
          <Input
            id="username-input"
            name="username"
            type="text"
            defaultValue={actionData?.fields?.username}
            aria-label="username"
            aria-invalid={Boolean(actionData?.fieldErrors?.username) || Boolean(actionData?.formError)}
            aria-errormessage={(actionData?.fieldErrors?.username && 'username-error') || (actionData?.formError && 'User does not exist.')}
            placeholder="username"
            className={input}
            //@ts-ignore
            styles={{ input: (actionData?.fieldErrors?.username || actionData?.formError) && errorInput }}
          />
          <Input.Error className={errorMessage}>{actionData?.fieldErrors?.username || actionData?.formError} </Input.Error>
        </Input.Label>
        <Input.Label htmlFor="password-input" mt={4} className={label}>
          <Input
            id="password-input"
            name="password"
            type="password"
            defaultValue={actionData?.fields?.password}
            aria-label="password"
            aria-invalid={Boolean(actionData?.fieldErrors?.password) || Boolean(actionData?.formError)}
            aria-errormessage={(actionData?.fieldErrors?.password && 'password-error') || (actionData?.formError && 'User does not exist.')}
            placeholder="password"
            className={input}
            //@ts-ignore
            styles={{ input: (actionData?.fieldErrors?.password || actionData?.formError) && errorInput }}
          />
          <Input.Error className={errorMessage}>{actionData?.fieldErrors?.password || actionData?.formError} </Input.Error>
        </Input.Label>
        <Button type="submit" variant="gradient" className={button} mt={8}>
          Log in
        </Button>
        <Space h={60} />
      </Box>
    </Form>
  );
}


Error handler

React의 ErrorBoundary와 같이 정말 쉽고 간단하게 Remix도 에러를 가둘 수 있습니다.
더욱더 나아가
예상치 못한 에러 (500 status, server error)
예상 가능한 에러 (400 status, client error)
모두 나누어 독립적으로 관리가 가능합니다.
이는 remix의 중첩 라우팅과 어우러져 강력한 nesting 기능을 갖춥니다.


routes/todos/$id.tsx

export default function $idRoute() {
  const { ...data } = useLoaderData() as TodoLoaderData;

  return <TodoDetail todo={data} />;
}

export function CatchBoundary() {
  const caught = useCatch();
  const params = useParams();

  const error = { ...caught, ...{ name: caught.data.name || '', message: `Todo id : ${params.id} does not exist.` } };

  if (caught.status === 404) {
    return <TodoErrorContainer error={error} />;
  }
  throw new Error(`Unhandled error: ${caught.status}`);
}

export function ErrorBoundary({ error }: { error: CaughtError }) {
  return <TodoErrorContainer error={error} />;
}


Session / Cookie

Remix는 Full Stack Framework이기 때문에
혼자서도 간단한 로그인 인증, 인가도 구현할 수 있습니다.

utils/session.server.ts

import { createCookieSessionStorage, redirect } from '@remix-run/node';
import { findUser, findUserWithId, createUser } from '~/models/user.server';
import bcrypt from 'bcryptjs';
import invariant from 'tiny-invariant';
import { User } from '@prisma/client';

interface authParameter {
  username: string;
  password: string;
}

invariant(process.env.SESSION_SECRET, 'SESSION_SECRET must be set');

export const sessionStorage = createCookieSessionStorage({
  cookie: {
    name: 'Remix_TodoList_session',
    /**
     * Safari localhost에서는 `secure: true`가 동작하는 않는 이슈가 있음
     *  https://web.dev/when-to-use-local-https/
     **/
    secure: process.env.NODE_ENV === 'production',
    secrets: [process.env.SESSION_SECRET],
    sameSite: 'lax',
    path: '/',
    maxAge: 60 * 60 * 24 * 30,
    httpOnly: true,
  },
});

const USER_SESSION_KEY = 'userId';

function getUserSession(request: Request) {
  const cookie = request.headers.get('Cookie');
  return sessionStorage.getSession(cookie);
}

export async function login({ username, password }: authParameter) {
  // find User
  const user = await findUser(username);

  if (!user) return;

  // password validation
  const isCorrectPassword = await bcrypt.compare(password, user.passwordHash);

  if (!isCorrectPassword) return;

  return { id: user.id, username };
}

export async function logout(request: Request) {
  const session = await getUserSession(request);

  return redirect('/', {
    headers: {
      'Set-Cookie': await sessionStorage.destroySession(session),
    },
  });
}

export async function register({ username, password }: authParameter) {
  const passwordHash = await bcrypt.hash(password, 10);
  const user = await createUser({ username, passwordHash });
  const { id } = user;
  return { id, username };
}

export async function getUserId(request: Request): Promise<User['id'] | undefined> {
  const session = await getUserSession(request);
  const userId = session.get(USER_SESSION_KEY);
  if (!userId || typeof userId !== 'string') return;
  return userId;
}

export async function getUser(request: Request) {
  const userId = await getUserId(request);

  if (typeof userId !== 'string') return;

  try {
    const user = await findUserWithId(userId);
    return user;
  } catch {
    throw logout(request);
  }
}

export async function requireUserId(request: Request, redirectTo = new URL(request.url).pathname) {
  const userId = await getUserId(request);
  if (!userId || typeof userId !== 'string') {
    const searchParams = new URLSearchParams([['redirectTo', redirectTo]]);
    throw redirect(`/auth/login?${searchParams}`);
  }
  return userId;
}

export async function createUserSession(userId: string, redirectTo: string) {
  const session = await sessionStorage.getSession();

  session.set(USER_SESSION_KEY, userId);
  return redirect(redirectTo, {
    headers: {
      'Set-Cookie': await sessionStorage.commitSession(session),
    },
  });
}

About

Remix.run을 이용한 dnd TodoList

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published