Remix quick experience

Posted by Mr.Fab on Sun, 12 Dec 2021 02:44:06 +0100

Remix experience

The article is based on Remix official website starts quickly Experience and translate. So the content is the same as that on the official website.

  • Create project
  • Your first route
  • Loading Data
  • A little refactoring
  • Pull data from data source
  • Dynamic routing parameters
  • Create blog posts
  • Root routing
  • Submit Form

Create project

Initialize a new Remix project

npx create-remix@latest
# Select Remix App Server
cd [Your own named project directory]
npm run dev
 Copy code

Note that Remix App Server is selected here

Run npx create-remix@latest Then, select Remix App Server, development language TypeScript, and then run npm install. You can then wait for the dependent package to download. After the dependent package is downloaded, the browser opens http://localhost:3000 , you can see the following interface:

Your first route

We will add a new route / posts. Before that, we need to create a Link to jump to this route.

First, open app / root Tsx, find < link to = "/" > Home < / link >, and create a new link to / posts next to it

Add a link to jump to the article

<li>
  <Link to="/posts">Posts</Link>
</li>
Copy code

At this point, if we click this link in the page, we will see a 404 page. Because we haven't added a route yet. Then let's add this route:

Create a new file: APP / routes / posts / index tsx

Routing files are placed under routes. A file represents a route. We can also directly create a posts JSX file, but if there will be routes such as Article Details in the future, we may create a route similar to post - detail Tsx routes or nested routes are not easy to manage. So we can create an index in the posts folder Tsx is used as the route entry under the current folder, just like index HTML as an entry file.

When visiting this link, we will see the following page. Because we haven't added any components yet.

Create blog post page component

export default function Posts() {
  return (
    <div>
      <h1>Posts</h1>
    </div>
  );
}
Copy code

After adding the above code, when we click the Posts link again, we can see that Posts has been rendered in the page.

Load data

Data loading is built into Remix.

In traditional web projects, the API for obtaining data is separated from the front-end component for rendering data. In remix, the front-end component is our API routing. Of course, if the API interface for obtaining data is provided through other services, the routing layer in Remix can also be used as the front-end data rendering controller. Next, we will set some data for our component.

Add useLoaderData for posts route

import { useLoaderData } from "remix";

export const loader = () => {
  return [
    {
      slug: "my-first-post",
      title: "My First Post"
    },
    {
      slug: "90s-mixtape",
      title: "A Mixtape I Made Just For You"
    }
  ];
};

export default function Posts() {
  const posts = useLoaderData();
  console.log(posts);
  return (
    <div>
      <h1>Posts</h1>
    </div>
  );
}
Copy code

Loaders is the API of the current component and has been encapsulated by useLoaderData. If you open both the browser console and the background console, you will find that posts are printed in the log. This is because Remix renders the page on the server and sends the html to the browser for display. At the same time, it will also inject and input log data in the front end.

Add links to the article list

  <ul>
    {posts.map(post => (
      <li key={post.slug}>
        <Link to={post.slug}>{post.title}</Link>
      </li>
    ))}
  </ul>
Copy code

Remember to introduce Link in the header of the file. At this time, TypeScript will report an error. We add some types to solve the problem of error reporting.

import { Link, useLoaderData } from "remix";

type Post = {
  slug: string;
  title: string;
};

export const loader = () => {
  const posts: Post[] = [
    {
      slug: "my-first-post",
      title: "My First Post"
    },
    {
      slug: "90s-mixtape",
      title: "A Mixtape I Made Just For You"
    }
  ];
  return posts;
};

export default function Posts() {
  const posts = useLoaderData<Post[]>();
  return (
    <div>
      <h1>Posts</h1>
      <ul>
        {posts.map(post => (
          <li key={post.slug}>
            <Link to={post.slug}>{post.title}</Link>
          </li>
        ))}
      </ul>
    </div>
  );
}
Copy code

A little refactoring

Based on past experience, it is best to create a module to deal with specific scenarios. In our example, it will involve reading blogs and adding blogs. Let's start creating them. Create a getPosts method and export it in our post module.

// Create a post file: APP / post ts
 Copy code
export type Post = {
  slug: string;
  title: string;
};

export function getPosts() {
  const posts: Post[] = [
    {
      slug: "my-first-post",
      title: "My First Post"
    },
    {
      slug: "90s-mixtape",
      title: "A Mixtape I Made Just For You"
    }
  ];
  return posts;
}
Copy code

Modify the posts route. Use our Post module in routing

// posts/index.jsx
import { Link, useLoaderData } from "remix";
import { getPosts } from "~/post";
import type { Post } from "~/post";

export const loader = () => {
  return getPosts();
};

// ...
Copy code

Pull data from data source

In the actual project, we will choose the data storage method according to the actual needs. You will choose to use appropriate databases, such as Postgres, fanadb and supabase. However, in this experience, we will use the file system.

Create the posts folder in the project root directory and create some blog posts in MarkDown format in the folder

mkdir posts

touch posts/my-first-post.md
touch posts/90s-mixtape.md

Copy code

Put some content in these md files at will. However, make sure that there is a front matter attribute with title.

Modify the getPosts method to read the contents from the file system

We will use a node module:

npm add front-matter
 Copy code

Modify the app/posts file as follows:

import path from "path";
import fs from "fs/promises";
import parseFrontMatter from "front-matter";

export type Post = {
  slug: string;
  title: string;
};

// relative to the server output not the source!
const postsPath = path.join(__dirname, "..", "posts");

export async function getPosts() {
  const dir = await fs.readdir(postsPath);
  return Promise.all(
    dir.map(async filename => {
      const file = await fs.readFile(
        path.join(postsPath, filename)
      );
      const { attributes } = parseFrontMatter(
        file.toString()
      );
      return {
        slug: filename.replace(/\.md$/, ""),
        title: attributes.title
      };
    })
  );
}
Copy code

At this time, TypeScript should report an error. Let's solve the mistake.

Because we get the content by reading the file, the type check does not know what type of data is in it. So we need to run-time check. We will introduce invariant to help us deal with this problem more easily.

app/post. The contents of the TS file are as follows:

import path from "path";
import fs from "fs/promises";
import parseFrontMatter from "front-matter";
import invariant from "tiny-invariant";

export type Post = {
  slug: string;
  title: string;
};

export type PostMarkdownAttributes = {
  title: string;
};

const postsPath = path.join(__dirname, "..", "posts");

function isValidPostAttributes(
  attributes: any
): attributes is PostMarkdownAttributes {
  return attributes?.title;
}

export async function getPosts() {
  const dir = await fs.readdir(postsPath);
  return Promise.all(
    dir.map(async filename => {
      const file = await fs.readFile(
        path.join(postsPath, filename)
      );
      const { attributes } = parseFrontMatter(
        file.toString()
      );
      invariant(
        isValidPostAttributes(attributes),
        `${filename} has bad meta data!`
      );
      return {
        slug: filename.replace(/\.md$/, ""),
        title: attributes.title
      };
    })
  );
}
Copy code

Even if we don't use TS, we will want to know where the error is reported by using invariant. We'll visit again at this time http://localhost:3000/posts We can see the list of articles read from the file system. You are free to add other articles to observe the changes of data.

Dynamic routing parameters

Next, let's create a route to access specific articles. We want the following routes to take effect:

/posts/my-first-post
/posts/90s-mix-cdr
 Copy code

We don't have to create a route for each article. Instead, it is processed in the url through dynamic routing identification. Remix parses and passes dynamic parameters to the route.

Create a dynamic routing file: APP / routes / posts / $slug tsx

export default function PostSlug() {
  return (
    <div>
      <h1>Some Post</h1>
    </div>
  );
}
Copy code

Add a loader access parameter

import { useLoaderData } from "remix";

export const loader = async ({ params }) => {
  return params.slug;
};

export default function PostSlug() {
  const slug = useLoaderData();
  return (
    <div>
      <h1>Some Post: {slug}</h1>
    </div>
  );
}
Copy code

The value after the $symbol on the route will be used as the key value in the loader parameter params. Add some type checks for TypeScript:

import { useLoaderData } from "remix";
import type { LoaderFunction } from "remix";

export const loader: LoaderFunction = async ({
  params
}) => {
  return params.slug;
};
Copy code

Next, let's read the content of the article from the file system.

Add the getPost method in the post module

// ...
export async function getPost(slug: string) {
  const filepath = path.join(postsPath, slug + ".md");
  const file = await fs.readFile(filepath);
  const { attributes } = parseFrontMatter(file.toString());
  invariant(
    isValidPostAttributes(attributes),
    `Post ${filepath} is missing attributes`
  );
  return { slug, title: attributes.title };
}
Copy code

Use the new getPost method in routing

// routes/posts/$slug.tsx
import { useLoaderData } from "remix";
import type { LoaderFunction } from "remix";
import { getPost } from "~/post";
import invariant from "tiny-invariant";

export const loader: LoaderFunction = async ({
  params
}) => {
  invariant(params.slug, "expected params.slug");
  return getPost(params.slug);
};

export default function PostSlug() {
  const post = useLoaderData();
  return (
    <div>
      <h1>{post.title}</h1>
    </div>
  );
}
Copy code

Since the parameters in params are not necessarily values and may not be slug s, we still use invariant for error judgment. At the same time, it can also prevent TS from reporting errors.

We use marked to parse the markdown.

npm add marked
# if using typescript (if typescript is used, the following packages need to be installed)
npm add @types/marked
 Copy code

Render HTML in route

// ...
export default function PostSlug() {
  const post = useLoaderData();
  return (
    <div dangerouslySetInnerHTML={{ __html: post.html }} />
  );
}
Copy code

At this point, we can sprinkle flowers and open champagne to celebrate. We have our own blog!

Create blog posts

The above blog system has been developed and can be deployed. But our best way is to store the blog post data in the database, so that we don't have to modify the system. So we need an entry to create an article, and we will use the form submission.

Create an admin route

touch app/routes/admin.tsx
 Copy code
import { Link, useLoaderData } from "remix";
import { getPosts } from "~/post";
import type { Post } from "~/post";

export const loader = () => {
  return getPosts();
};

export default function Admin() {
  const posts = useLoaderData<Post[]>();
  return (
    <div className="admin">
      <nav>
        <h1>Admin</h1>
        <ul>
          {posts.map(post => (
            <li key={post.slug}>
              <Link to={`/posts/${post.slug}`}>
                {post.title}
              </Link>
            </li>
          ))}
        </ul>
      </nav>
      <main>...</main>
    </div>
  );
}
Copy code

In addition to adding some additional html structures, you will find admin Most of the content in TSX is copied from the posts route. We will make some style changes next.

Create an admin CSS style file

touch app/styles/admin.css
 Copy code
.admin {
  display: flex;
}

.admin > nav {
  padding-right: 2rem;
}

.admin > main {
  flex: 1;
  border-left: solid 1px #ccc;
  padding-left: 2rem;
}

em {
  color: red;
}
Copy code

Associate style files in admin routes

import { Link, useLoaderData } from "remix";
import { getPosts } from "~/post";
import type { Post } from "~/post";
import adminStyles from "~/styles/admin.css";

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

// ...
Copy code

Preview the admin route. The effect is as follows:

Each route can export a links method that returns the link array. We use {rel: "stylesheet", href: adminStyles} instead of < link rel = "stylesheet" href = "..." / >. This allows Remix to merge the already rendered route collection and render it in < links / > at the top of the page. Now we can see a page with a list of articles on the left and a booth on the right. You can access it manually http://localhost:3000/admin This route.

Root routes (Index Routes)

Let's create an index route for admin. We will introduce the use of nested routing.

Create a folder for the sub routes of admin route, and create an index tsx

mkdir app/routes/admin
touch app/routes/admin/index.tsx
 Copy code
import { Link } from "remix";

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

At this time, if we refresh the browser, we can't see the content just created. Each route under app/routes/admin / will be displayed in app/routes/admin. When their routes match Rendered in TSX. You have to control how to display these matching routes in admin.

Add an outlet in the admin page

// admin.tsx

import { Outlet, Link, useLoaderData } from "remix";

//...
export default function Admin() {
  const posts = useLoaderData<Post[]>();
  return (
    <div className="admin">
      <nav>
        <h1>Admin</h1>
        <ul>
          {posts.map(post => (
            <li key={post.slug}>
              <Link to={`/posts/${post.slug}`}>
                {post.title}
              </Link>
            </li>
          ))}
        </ul>
      </nav>
      <main>
        <Outlet />
      </main>
    </div>
  );
}
Copy code

When the URL matches the path of the parent route, index routes will be rendered to the outlet. Next, let's add the / admin/new route and click Create a New Post to see what happens.

Create app / routes / admin / new Tsx routing

touch app/routes/admin/new.tsx
 Copy code
export default function NewPost() {
  return <h2>New Post</h2>;
}
Copy code

When we click < link to = "new" > create a new post < / link >, we will find that the route has been routed to admin/new, and the content has also changed. The content except admin/new is rendered in the outlet.

Form submission (Actions)

Next, we will do a big thing to create a form in the new route to submit new blog posts.

Add a form in the new route

import { Form } from "remix";

export default function NewPost() {
  return (
    <Form method="post">
      <p>
        <label>
          Post Title: <input type="text" name="title" />
        </label>
      </p>
      <p>
        <label>
          Post Slug: <input type="text" name="slug" />
        </label>
      </p>
      <p>
        <label htmlFor="markdown">Markdown:</label>
        <br />
        <textarea id="markdown" rows={20} name="markdown" />
      </p>
      <p>
        <button type="submit">Create Post</button>
      </p>
    </Form>
  );
}
Copy code

This is no different from the submission form we wrote before. Let's post Create the necessary code to submit an article in the TS module.

In APP / post Add the createPost method anywhere in TS

// ...
export async function createPost(post) {
  const md = `---\ntitle: ${post.title}\n---\n\n${post.markdown}`;
  await fs.writeFile(
    path.join(postsPath, post.slug + ".md"),
    md
  );
  return getPost(post.slug);
}
Copy code

Calling createPost method in action of new routing

import { redirect, Form } from "remix";
import { createPost } from "~/post";

export const action = async ({ request }) => {
  const formData = await request.formData();

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

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

  return redirect("/admin");
};

export default function NewPost() {
  // ...
}
Copy code

Resolve TS error reporting:

// app/post.ts
type NewPost = {
  title: string;
  slug: string;
  markdown: string;
};

export async function createPost(post: NewPost) {
  const md = `---\ntitle: ${post.title}\n---\n\n${post.markdown}`;
  await fs.writeFile(
    path.join(postsPath, post.slug + ".md"),
    md
  );
  return getPost(post.slug);
}

//...
Copy code
import { Form, redirect } from "remix";
import type { ActionFunction } from "remix";
import { createPost } from "~/post";

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

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

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

  return redirect("/admin");
};
Copy code

Whether we use TS or not, there will be a problem submitting when users do not enter form fields. Before we submit the form, let's add some checks.

Verify whether the form contains the data we need. If the verification fails, an error message will be returned

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

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

  const errors = {};
  if (!title) errors.title = true;
  if (!slug) errors.slug = true;
  if (!markdown) errors.markdown = true;

  if (Object.keys(errors).length) {
    return errors;
  }

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

  return redirect("/admin");
};
Copy code

Note that we do not return the redirect message at this time. Instead, an error message is returned. In the component, this information can be accessed through useActionData. It is very similar to useLoaderData. However, only the data is obtained through action after the form is submitted.

Add verification information display on UI

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

// ...

export default function NewPost() {
  const errors = useActionData();

  return (
    <Form method="post">
      <p>
        <label>
          Post Title:{" "}
          {errors?.title && <em>Title is required</em>}
          <input type="text" name="title" />
        </label>
      </p>
      <p>
        <label>
          Post Slug:{" "}
          {errors?.slug && <em>Slug is required</em>}
          <input type="text" name="slug" />
        </label>
      </p>
      <p>
        <label htmlFor="markdown">Markdown:</label>{" "}
        {errors?.markdown && <em>Markdown is required</em>}
        <br />
        <textarea rows={20} name="markdown" />
      </p>
      <p>
        <button type="submit">Create Post</button>
      </p>
    </Form>
  );
}
Copy code

The interesting thing is: when JavaScript is disabled in the developer tool, try again. Because Remix is built based on HTTP and HTML, the program can still work well in the browser after we disable JavaScript. This is not the point. When we slow down the processing of data, we add some loading UI to the form.

Slow down our action through a simulated delay

// ...
export const action: ActionFunction = async ({
  request
}) => {
  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");
  // ...
};
//...
Copy code

Add UI in load through useTransition

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

// ...

export default function NewPost() {
  const errors = useActionData();
  const transition = useTransition();

  return (
    <Form method="post">
      {/* ... */}

      <p>
        <button type="submit">
          {transition.submission
            ? "Creating..."
            : "Create Post"}
        </button>
      </p>
    </Form>
  );
}
Copy code

Now users can have a good experience without JavaScript support.