はじめに
こんにちは。フロントエンド開発課に所属している新卒1年目のm_you_sanと申します。
最近話題のRemixを使って、シンプルなTodoアプリを作成する方法をご紹介します。
Todoアプリの作成を通じて、簡単なフルスタック開発を体験していただければと思います。
プロジェクトの作成
はじめに以下のコマンドを実行して、プロジェクトを作成します。
※Node.js v18以上、npm v7以上がインストールされていることが前提です。
npx create-remix@latest --template remix-run/indie-stack
今回はindie-stackというテンプレートを使用しています。
このテンプレートを使用することで、プロジェクトにSQLite、Prisma、tailwind cssなどが予めインストールされた状態になります。
※今回はデザインはこだわらないので、tailwind cssの設定は削除しています。
さらに詳しくテンプレート機能について知りたい方は、公式のドキュメントを読んでみてください。
npm run dev
を実行し、以下のような画面が表示されれば、プロジェクトの作成は完了です。
モデルの定義
次にprismaにモデルの定義をしていきます。
schema.prisma
を開いて、最終行に以下のコードを追加してください。
model Todo { id String @id @default(cuid()) title String deadline String isDone Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }
保存した後、以下のコマンドでマイグレーションを実行します。
npx prisma migrate dev --name "create todo model"
次に/modelsの直下にtodo.server.ts
を作成します。
todo.server.ts
では、バックエンドのデータを操作するための関数をまとめています。
// todo.server.ts export const getTodos = () => { return prisma.todo.findMany(); }; export const getTodo = (id: string) => { return prisma.todo.findUnique({ where: { id } }); }; export const createTodo = (todo: Pick<Todo, "title" | "deadline">) => { return prisma.todo.create({ data: todo }); }; export const updateTodo = ( id: string, todo: Pick<Todo, "title" | "deadline" | "isDone">, ) => { return prisma.todo.update({ where: { id }, data: todo, }); }; export const deleteTodo = (id: string) => { return prisma.todo.delete({ where: { id } }); };
Root Routeについて
画面を作成する前に、Root Routeについて簡単に解説したいと思います。
Root Routeは、/appにあるroot.tsx
のことを指しています。root.tsx
は、アプリケーション全体の共通設定やヘッダー、フッターといった共通のレイアウトを定義することができます。
また、root.tsx
はアプリケーションのトップレベルで定義され、他のページの親コンポーネントとなっています。
root.tsx
の子コンポーネントは<Outlet />
内でレンダリングされます。
// root.tsx export default function App() { return ( <html lang="en"> <head> <meta charSet="utf-8" /> <meta name="viewport" content="width=device-width,initial-scale=1" /> <Meta /> <Links /> </head> <body> {/* ここで子がレンダリングされます */} <Outlet /> <ScrollRestoration /> <Scripts /> <LiveReload /> </body> </html> ); }
ルーティングについて
Remixでは、ファイルシステムベースのルーティングになっており、app/routes配下に置いたファイルがそのままアプリケーションのURLとなります。
また、動的セグメントを利用したい場合は、$
をファイル名に付けます。
例
ファイル名 | URL |
---|---|
_index.tsx | / |
todos._index.tsx | /todos |
todos.create.tsx | /todos/create |
todos.$todoId.edit.tsx | /todos/{todoId}/edit |
一覧画面の作成
Todo一覧画面を作成していきます。
まず、トップページの方を修正していきます。
// _index.tsx export default function Index() { return ( <main> <h2>トップページ</h2> <Link to="todos">Todo一覧画面へ</Link> </main> ); }
次に/routes直下にtodos._index.tsx
を作成します。
// todos._index.tsx export const loader = async () => { return json({ todos: await getTodos() }); }; const Todos: FC = () => { const { todos } = useLoaderData<typeof loader>(); return ( <> <h2>Todo一覧画面</h2> <ul> {todos.map((todo) => ( <li key={todo.id}> <p>タイトル:{todo.title}</p> <p>期限:{todo.deadline}</p> <p>進捗:{todo.isDone ? "完了" : "未着手"}</p> </li> ))} </ul> </> ); }; export default Todos;
loader
関数は、データ取得する際に使用するバックエンドAPIの役割をしています。 この関数はサーバサイドで呼ばれ、先程todo.server.ts
で定義したgetTodos
で取得したデータを返却します。
コンポーネント内で定義されているuseLoaderData
は、loader
関数で返却されるデータを取得するためのhooksです。 今回はuseLoaderData
でtodoを全件取得し、map関数で展開して、一覧表示させています。
この時点では、todoが登録されていないため、何も表示されません。
新規追加画面の作成
/routes直下にtodos.create.tsx
を作成します。
// todos.create.tsx export const action = async ({ request }: ActionFunctionArgs) => { const formData = await request.formData(); const title = formData.get("title"); const deadline = formData.get("deadline"); const errors = { title: title ? null : "タイトルは必須です", deadline: deadline ? null : "期限は必須です", }; const hasErrors = Object.values(errors).some((errorMessage) => errorMessage); if (hasErrors) return json(errors); invariant(typeof title === "string"); invariant(typeof deadline === "string"); await createTodo({ title, deadline }); return redirect("/todos"); }; const CreateTodo: FC = () => { const errors = useActionData<typeof action>(); return ( <> <h2>新規追加画面</h2> <Form method="post"> <div> <label> タイトル: {errors?.title ? ( <p style={{ color: "red" }}>{errors.title}</p> ) : null} <input type="text" name="title" /> </label> </div> <div> <label> 期限: {errors?.deadline ? ( <p style={{ color: "red" }}>{errors.deadline}</p> ) : null} <input type="date" name="deadline" /> </label> </div> <div> <button type="submit">登録</button> </div> </Form> </> ); }; export default CreateTodo;
一覧画面と異なりloader
関数ではなく、action
関数を使用しています。loader
関数がデータの取得に対して、action
関数は、データの更新やサーバーへのリクエストなどを実行するバックエンドのAPIの役割をしています。
loader
関数と同じくサーバサイドで呼ばれます。
action
関数内では、まずformData.get
でフォームから送られてきたtitle
とdeadline
を取得しています。
※action
関数内で使用しているformData
やrequest
はWeb標準のAPIです。詳細はMDNを参照してみてください。
title
とdeadline
が未入力だった場合は、エラーメッセージを返却します。
invariant
は引数の内の条件式がfalseだった場合、例外を投げて処理を中断します。今回は、title
とdeadline
の型情報を絞り込むために使用しています。
登録完了後は、一覧画面にリダイレクトするようにしています。
コンポーネント側では、useActionData
でaction
関数から返却されるデータを受け取っています。エラーがあった場合は、エラーメッセージをそれぞれ、ラベルの下に表示するようにしています。
次に一覧画面から新規追加画面に遷移できるようにします。
RemixのLink
コンポーネントは、React RouterのLink
コンポーネントをラップしたものなので、相対パスを適用できます。そのため、to="/todos/create"
ではなく、to="create"
と記載するだけで新規追加画面へ遷移することが可能です。
// todos._index.tsx ・・・中略・・・ <h2>Todo一覧画面</h2> <Link to="create">新規追加画面へ</Link> ・・・中略・・・
最後に動作を確認してみます。
登録完了後、一覧画面に遷移されて登録したtodoが表示されていると思います。
編集画面の作成
/routes直下にtodos.$todoId.edit.tsx
を作成します。
// todos.$todoId.edit.tsx export const loader = async ({ params }: LoaderFunctionArgs) => { invariant(params.todoId); const todo = await getTodo(params.todoId); invariant(todo); return json({ todo }); }; export const action = async ({ params, request }: ActionFunctionArgs) => { const formData = await request.formData(); const title = formData.get("title"); const deadline = formData.get("deadline"); const isDone = formData.get("isDone") === "done"; const errors = { title: title ? null : "タイトルは必須です", deadline: deadline ? null : "期限は必須です", }; const hasErrors = Object.values(errors).some((errorMessage) => errorMessage); if (hasErrors) return json(errors); invariant(params.todoId); invariant(typeof title === "string"); invariant(typeof deadline === "string"); await updateTodo(params.todoId, { title, deadline, isDone }); return redirect("/todos"); }; const EditTodo: FC = () => { const { todo } = useLoaderData<typeof loader>(); const errors = useActionData<typeof action>(); return ( <> <h2>編集画面</h2> <Form method="POST"> <div> <label> タイトル: {errors?.title ? ( <p style={{ color: "red" }}>{errors.title}</p> ) : null} <input type="text" name="title" defaultValue={todo.title} /> </label> </div> <div> <label> 期限: {errors?.deadline ? ( <p style={{ color: "red" }}>{errors.deadline}</p> ) : null} <input type="date" name="deadline" defaultValue={todo.deadline} /> </label> </div> <div> <input id="done" type="radio" name="isDone" value="done" defaultChecked={todo.isDone} /> <label htmlFor="done">完了</label> <input id="notDone" type="radio" name="isDone" value="notDone" defaultChecked={!todo.isDone} /> <label htmlFor="notDone">未着手</label> </div> <div> <button type="submit">更新</button> </div> </Form> </> ); }; export default EditTodo;
編集画面では、loader
関数を使って動的セグメントで指定したtodoId
を取得して、それに対応するtodoを取得しています。
更新リクエストにも、todoId
が必要なので、action
関数の方でもtodoId
を取得しています。その他の処理は、新規追加画面のaction
関数とほぼ同じです。
useLoaderData
でデータを取得する流れは一覧画面と同様で、フォームのデフォルト値に取得したデータを設定しています。
編集画面は完成したので、一覧画面から遷移できるようにします。
// todos._index.tsx ・・・中略・・・ <li key={todo.id}> {/* タイトルから編集画面に遷移できるようにする */} <Link to={`${todo.id}/edit`}>タイトル:{todo.title}</Link> ・・・中略・・・
最後に動作を確認してみます。
削除機能の追加
最後に削除機能を追加します。 /routes直下にtodos.$todoId.delete.ts
を作成します。
// todos.$todoId.delete.ts export const action = async ({ params }: ActionFunctionArgs) => { invariant(params.todoId); await deleteTodo(params.todoId); return redirect("/todos"); };
action
関数内では、動的セグメントで指定したtodoId
を取得して、それに対応するtodoを削除しています。削除完了後は、一覧画面にリダイレクトします。
一覧画面からtodoを削除できるようにしたいので、一覧画面に削除ボタンを追加して、押下時に/todos/{todoId}/delete
にリクエストを送るようにします。
// todos._index.tsx ・・・中略・・・ <p>進捗:{todo.isDone ? "完了" : "未着手"}</p> <Form action={`${todo.id}/delete`} method="post"> <button type="submit">削除</button> </Form> ・・・中略・・・
削除ボタンをForm
コンポーネントで囲って、action
にパスを指定しています。これにより、ボタン押下時にtodos.$todoId.delete.ts
のaction
関数が呼び出されます。
なお、Form
コンポーネントのaction
を省略した場合は、同ファイル内のaction
関数が呼ばれます。
動作を確認して、削除ボタン押下時に一覧画面からtodoが削除されていれば、実装完了です。
まとめ
今回はRemixで簡単なTodoアプリを作成する方法について、紹介させていただきました。
Todoアプリの作成を通じて、フロントエンドとサーバーサイドのコードを同時に扱う体験をしていただけたと思います。
また、この記事がRemixをこれから学ぼうとする方々の理解を深める助けになれば幸いです。
私自身もまだRemix初学者なので、引き続き知識を深めていきたいと考えています!