Remix

Created
TypeStack
LanguageJavascript
Last Edit

Tutorial

Up and Running with Remix
What is Remix? > Remix is a seamless server and browser runtime that provides snappy page loads and instant transitions by leveraging distributed systems and native browser features instead of clunky static builds. > -- remix.run But that summary only scratches the surface.
https://egghead.io/courses/up-and-running-with-remix-b82b6bb6

Basics

Description

Remix is an edge native, full stack JavaScript framework for building modern, fast, and resilient user experiences. It unifies the client and server with web standards

Prerequisites

Setup

Empty Project

npx create-remix@latest <project-name>

Official Stacks

Template Project

npx create-remix@latest --template remix-run/indie-stack blog-tutorial

Run

npm run dev

Routes

Each page in Remix will be rendered through routes.

Create

touch app/routes/posts/index.ts

export default function Posts() {
  return (
    <main>
      <h1>Posts</h1>
    </main>
  );
}
Link To Created Route
import { Link } from "@remix-run/react";

const Index = () => {
			<Link to="/posts">
			     Blog Posts
			</Link>
}
Dynamic Routes

Example Routes

/posts/my-first-post
/posts/90s-mixtape

Access Params

import type { LoaderArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";

import invariant from "tiny-invariant";

import { getPost } from "~/models/post.server";

export const loader = async ({ params }: LoaderArgs) => {
  invariant(params.slug, `params.slug is required`);

  const post = await getPost(params.slug);
  invariant(post, `Post not found: ${params.slug}`);

  return json({ post });
};

export default function PostSlug() {
  const { post } = useLoaderData<typeof loader>();
  return (
    <main>
      <h1>{post.title}</h1>
    </main>
  );
}

The part of the filename attached to the $ becomes a named key on the params object that comes into your loader.

Here file is named as $slug.txt

Invariant

For validation.

Validation required by TypeScript.

Because params comes from the URL, not totally sure that params.slug will be defined : Also happens when file name is changed.

export async function getPost(slug: string) {
  return prisma.post.findUnique({ where: { slug } });
}
Nested Routing

Relative Routing

The link will route to /posts/admin even though link to is just admin

<Link to="admin">
  Admin
</Link>

Index Routes

import { json } from "@remix-run/node";
import {
  Link,
  Outlet,
  useLoaderData,
} from "@remix-run/react";

import { getPosts } from "~/models/post.server";

export const loader = async () => {
  return json({ posts: await getPosts() });
};

export default function PostAdmin() {
  const { posts } = useLoaderData<typeof loader>();
  return (
    <div>
      <h1>
        Blog Admin
      </h1>
      <div>
        <na>
          <ul>
            {posts.map((post) => (
              <li key={post.slug}>
                <Link
                  to={post.slug}
                >
                  {post.title}
                </Link>
              </li>
            ))}
          </ul>
        </nav>
        <main>
          <Outlet />
        </main>
      </div>
    </div>
  );
}

Every route inside of app/routes/posts/admin/ can now render inside of app/routes/posts/admin.tsx when their URL matches.

You get to control which part of the admin.tsx layout the child routes render.

Now the admin page will render the list of posts and create new post link.

import { Link } from "@remix-run/react";

export default function AdminIndex() {
  return (
    <p>
      <Link to="new">
        Create a New Post
      </Link>
    </p>
  );
}
export default function NewPost() {
  return <h2>New Post</h2>;
}

Once Create a New Post link is clicked, Remix outlet automatically swaps index.tsx to new.tsx

This is because when the URL matches the parent route’s path, the index will render inside the outlet.

Data

Load Data
import { json } from "@remix-run/node";
import { Link, useLoaderData } from "@remix-run/react";

import { getPosts } from "~/models/post.server";

export const loader = async () => {
  return json({
    posts: await getPosts(),
  });
};

export default function Posts() {
  const { posts } = useLoaderData<typeof loader>();
  console.log(posts);
  return (
    <main>
      <h1>Posts</h1>
      <ul>
        {posts.map((post) => (
          <li key={post.slug}>
            <Link to={post.slug} className="text-blue-600 underline">
              {post.title}
            </Link>
          </li>
        ))}
      </ul>
    </main>
  );
}

import { prisma } from "~/db.server";

export async function getPosts() {
  return prisma.post.findMany();
}

Send Data

Form

import type { Post } from "@prisma/client";
export async function createPost(
  post: Pick<Post, "slug" | "title" | "markdown">
) {
  return prisma.post.create({ data: post });
}
import { Form, useActionData, useTransition } from "@remix-run/react";
import type { ActionArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";

import invariant from "tiny-invariant";

import { createPost } from "~/models/post.server";

export const action = async ({ request }: ActionArgs) => {
  // TODO: remove me
  await new Promise((res) => setTimeout(res, 1000));

  const formData = await request.formData();

  const title = formData.get("title");
  const slug = formData.get("slug");
  const markdown = formData.get("markdown");

  const errors = {
    title: title ? null : "Title is required",
    slug: slug ? null : "Slug is required",
    markdown: markdown ? null : "Markdown is required",
  };
  const hasErrors = Object.values(errors).some((errorMessage) => errorMessage);
  if (hasErrors) {
    return json(errors);
  }

  invariant(typeof title === "string", "title must be a string");
  invariant(typeof slug === "string", "slug must be a string");
  invariant(typeof markdown === "string", "markdown must be a string");

  await createPost({ title, slug, markdown });

  return redirect("/posts/admin");
};

const inputClassName = `w-full rounded border border-gray-500 px-2 py-1 text-lg`;

export default function NewPost() {
  const errors = useActionData<typeof action>();

	// Loading UI
  const transition = useTransition();
  const isCreating = Boolean(transition.submission);

  return (
    <Form method="post">
      <p>
        <label>
          Post Title:{" "}
          {errors?.title ? (
            <em className="text-red-600">{errors.title}</em>
          ) : null}
          <input type="text" name="title" className={inputClassName} />
        </label>
      </p>
      <p>
        <label>
          Post Slug:{" "}
          {errors?.slug ? (
            <em className="text-red-600">{errors.slug}</em>
          ) : null}
          <input type="text" name="slug" className={inputClassName} />
        </label>
      </p>
      <p>
        <label htmlFor="markdown">
          Markdown:{" "}
          {errors?.markdown ? (
            <em className="text-red-600">{errors.markdown}</em>
          ) : null}
        </label>
        <br />
        <textarea
          id="markdown"
          rows={20}
          name="markdown"
          className={`${inputClassName} font-mono`}
        />
      </p>
      <p className="text-right">
        <button
          type="submit"
          disabled={isCreating}
          className="rounded bg-blue-500 py-2 px-4 text-white hover:bg-blue-600 focus:bg-blue-400 disabled:bg-blue-300"
        >
          {isCreating ? "Creating..." : "Create Post"}
        </button>
      </p>
    </Form>
  );
}

Update Data
export async function updatePost(
  slug: string,
  post: Pick<Post, "title" | "markdown">
) {
  return prisma.post.update({ data: post, where: { slug } });
}

Show default values that is fetched using useLoaderData.

Form require key so that whenever key changes, form is rerendered. If no key is provided, then the form won’t be updated with the new values.

import {
  Form,
  useActionData,
  useLoaderData,
  useTransition,
} from "@remix-run/react";

import invariant from "tiny-invariant";
import type { LoaderArgs, ActionArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";

import { updatePost, getPost } from "~/models/post.server";

export const action = async ({ request }: ActionArgs) => {
  // TODO: remove me
  await new Promise((res) => setTimeout(res, 1000));

  const formData = await request.formData();

  const title = formData.get("title");
  const slug = formData.get("slug");
  const markdown = formData.get("markdown");

  const errors = {
    title: title ? null : "Title is required",
    slug: slug ? null : "Slug is required",
    markdown: markdown ? null : "Markdown is required",
  };
  const hasErrors = Object.values(errors).some((errorMessage) => errorMessage);
  if (hasErrors) {
    return json(errors);
  }

  invariant(typeof title === "string", "title must be a string");
  invariant(typeof slug === "string", "slug must be a string");
  invariant(typeof markdown === "string", "markdown must be a string");

  await updatePost(slug, { title, markdown });

  return redirect("/posts/admin");
};

const inputClassName = `w-full rounded border border-gray-500 px-2 py-1 text-lg`;

export const loader = async ({ params }: LoaderArgs) => {
  invariant(params.slug, `params.slug is required`);

  const post = await getPost(params.slug);
  invariant(post, `Post not found: ${params.slug}`);

  return json({ post });
};

export default function PostSlug() {
  const { post } = useLoaderData<typeof loader>();

  const errors = useActionData<typeof action>();
  const transition = useTransition();
  const isUpdating = Boolean(transition.submission);

  return (
    <Form method="post" key={post.title}>
      <p>
        <label>
          Post Title:{" "}
          {errors?.title ? (
            <em className="text-red-600">{errors.title}</em>
          ) : null}
          <input
            defaultValue={post.title}
            type="text"
            name="title"
            className={inputClassName}
          />
        </label>
      </p>
      <p>
        <label htmlFor="markdown">
          Markdown:{" "}
          {errors?.markdown ? (
            <em className="text-red-600">{errors.markdown}</em>
          ) : null}
        </label>
        <br />
        <textarea
          id="markdown"
          defaultValue={post.markdown}
          rows={20}
          name="markdown"
          className={`${inputClassName} font-mono`}
        />
      </p>
      <p className="text-right">
        <button
          type="submit"
          disabled={isUpdating}
          className="rounded bg-blue-500 py-2 px-4 text-white hover:bg-blue-600 focus:bg-blue-400 disabled:bg-blue-300"
        >
          {isUpdating ? "Updating..." : "Update Post"}
        </button>
      </p>
    </Form>
  );
}