Berkenalan dengan Remix - 2

Lanjutan dari Bagian 1

Loaders

Untuk nampilin daftar postingan di /posts, pasti kita kan perlu ambil data. Bisa dari DB, dari fail, atau dari API lain. Remix nyediain hook useLoaderData & kita tinggal ekspor fungsi loader() di route yang bersangkutan. Fungsi loader() ini dijalanin di server & nantinya otomatis data yang dibalikin fungsi ini akan tersedia untuk server & klien lewat hook itu.

//file: app/routes/posts/index.jsx

import { Link, useLoaderData } from 'remix';

export const loader = ()=>{
  const data = {
    posts:[
      {id: 1, title: 'Post 1', body: 'This is a test post 1'},
      {id: 2, title: 'Post 2', body: 'This is a test post 2'},
      {id: 3, title: 'Post 3', body: 'This is a test post 3'}
    ]
  }
  return data;
}

function PostsIndex() {

  const { posts } = useLoaderData();

  return (
    <div>
      <h1>Ini Route Posts Index</h1>

      <ul className="posts-list">
        {
          posts.map( post => {
            return <li key={post.id}>
              <Link to={post.id}>
                <h3>{post.title}</h3>
              </Link>
            </li>
          })
        }
      </ul>
    </div>
  );
}

export default PostsIndex;

Kita bisa liat listing posts itu di-render di sisi server dari kode HTML-nya. Jadi di sini kita nggak perlu bikin kode sendiri untuk nge-load data untuk SSR. Mirip fungsi getServerSideProps() punya NextJS.

Sebelum lanjut ke Action, kita atur dulu layout posts/index.jsx.

//file: app/routes/posts/index.jsx

function PostsIndex() {

  const { posts } = useLoaderData();

  return (
    <div>
      <div className="page-header">
        <h1>Posts</h1>
        <Link to="/posts/new" className="btn">
          Buat Baru
        </Link>
      </div>

      <ul className="posts-list">
        {
          posts.map( post => {
            return <li key={post.id}>
              <Link to={post.id}>
                <h3>{post.title}</h3>
              </Link>
            </li>
          })
        }
      </ul>
    </div>
  );
}

Actions

Buka routes/posts/new.jsx. Ubah isinya jadi begini:

//file: app/routes/posts/new.jsx

import { Link } from 'remix';

function NewPost() {
  return <div>
    <div className="page-header">
      <h1>Buat Post Baru</h1>
      <Link to="/posts" className="btn">Kembali</Link>
    </div>

    <div className="page-content">
      <form method="POST">
        <div className="form-control">
          <label htmlFor="title">Judul</label>
          <input type="text" name="title" id="title" />
        </div>
        <div className="form-control">
          <label htmlFor="body">Konten</label>
          <textarea name="body" id="body" />
        </div>
        <button type="submit" className="btn btn-block">
          Simpan
        </button>
      </form>
    </div>
    
  </div>;
}

export default NewPost;
Karena yang memproses form ada di halaman/route yang sama, formnya nggak perlu atribut action.

Buka localhost:3000/posts/new.

Action adalah fungsi yang dipake Remix untuk menghandel form submission. Kita buat fungsi action di posts/new.jsx.  Fungsi ini nerima objek sebagai argumennya, yang kita pake adalah objek request. Di dalam fungsi ini kita ambil data yang dikirim dari form lewat request.formData(). Untuk ambil nilai dari masing-masing field datanya ( title & body), kita pake get(nama_field).

//file: app/routes/posts/new.jsx

import { Link, redirect } from 'remix';

export const action = async ({request})=>{
  
  const form = await request.formData();
  const fields = {
    title: form.get('title'),
    body: form.get('body')
  }

  console.log('ACTION', fields)

  return redirect('/posts')
}

function NewPost() {
  //... kode lainnya
}

export default NewPost;

Terus coba isi form & klik tombol Simpan. Sama seperti loader() , fungsi ini jalan di server jadi log-nya bisa diliat di terminal:

ACTION { title: 'ini judul', body: 'ini kontennya' }
POST /posts/new 302 - - 19.256 ms

Prisma & SQLite

npm i prisma @prisma/client

npx prisma init --datasource-provider sqlite
// file : prisma/schema.prisma

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

model Post {
  id String @id @default(uuid())
  title String 
  body String
  createdAt DateTime @default(now)
  updatedAt DateTime @updatedAt
}
npx prisma db push

DB Seeder

// file: prisma/seed.js

const { PrismaClient } = require('@prisma/client')
const db = new PrismaClient();

async function seed(){
    await Promise.all(
        getPosts().map( post => {
            return db.post.create({
                data: post
            })
        })
    )
}

seed();

function getPosts(){
    return [
        {
            title: 'Post 1 seed',
            body: 'body post 1 seed'
        },
        {
            title: 'Post 2 seed',
            body: 'body post 2 seed'
        },
        {
            title: 'Post 3 seed',
            body: 'body post 3 seed'
        },
        {
            title: 'Post 4 seed',
            body: 'body post 4 seed'
        },
        {
            title: 'Post 5 seed',
            body: 'body post 5 seed'
        },
        {
            title: 'Post 6 seed',
            body: 'body post 6 seed'
        }
    ]
}
node prisma\seed.js

Untuk berinteraksi dengan DB, Prisma nyediain tool namanya studio yang terinstal otomatis pas kita instal paket NPM. Tinggal jalanin aja.

 npx prisma studio

DB File

Berikutnya kita buat fungsi utilitas untuk mengakses instance database. Nama failnya pake .server  untuk ngasih tau Remix bahwa modul ini hanya untuk dijalanin di server.

// file: app/utils/db.server.js
// utility untuk akses DB instance

const { PrismaClient } = require('@prisma/client');
let db;

if(!global.__db) {
    global.__db = new PrismaClient();
    global.__db.$connect();
}

db = global.__db;

export {
    db
}

Memuat Daftar Posts Lewat Loader

Buka fail routes/posts/index.jsx. Hapus isi bagian loader() & ganti dengan kode berikut untuk memuat data dari database.

// file: app/routes/posts/index.jsx

import { Link, useLoaderData } from 'remix';
import {db} from '~/utils/db.server'

export const loader = async ()=>{
  const data = {
    posts: await db.post.findMany({
      orderBy: {
        createdAt: 'desc'
      }
    })
  }
  return data;
}

function PostsIndex() {

  const { posts } = useLoaderData();

  return (
    <div>
      
      {/*... kode lainnya */}

      <ul className="posts-list">
        {
          posts.map( post => {
            return <li key={post.id}>
              <Link to={post.id}>
                <h3>{post.title}</h3>
                <p>{new Date(post.createdAt).toLocaleString()}</p>
              </Link>
            </li>
          })
        }
      </ul>
    </div>
  );
}

Buka halaman /posts.

http://localhost:3000/posts

Mengirim Post Lewat Action

Buka fail routes/posts/new.jsx. Ubah kode di bagian action() untuk membuat baris baru di DB lewat fungsi db.post.create(), terus redirect ke posts/[id].

//file: app/routes/posts/new.jsx

import { Link, redirect } from 'remix';
import { db } from '~/utils/db.server';

export const action = async ({request})=>{
  
  //... kode yang lain

  const post = await db.post.create({
    data: fields
  })

  return redirect(`/posts/${post.id}`)
}

// kode yang lain

Memuat Postingan Berdasarkan ID-nya

Kembali ke posts/$postId.jsx. Kita buat fungsi loader() untuk memuat data dari DB berdasarkan parameter postId. Ingat nama parameter postId ini sesuai dengan nama failnya $postId.jsx. Kalo diubah jadi $id.jsx , nama parameternya juga ngikutin, jadi id.

//file: app/routes/posts/$postId.jsx

import { useLoaderData, Link } from "remix";
import { db } from "~/utils/db.server";

export const loader = async ({ params }) => {
  const post = await db.post.findUnique({
    where: {
      id: params.postId,
    },
  });

  if (!post) {
    throw new Error("Post not found");
  }

  return {
    post,
  };
};

function Post() {
  const { post } = useLoaderData();

  return (
    <div>
      <div className="page-header">
        <h1>{post.title}</h1>
        <Link to="/posts" className="btn btn-reverse">
          Kembali
        </Link>
      </div>
      <div className="page-content">{post.body}</div>
    </div>
  );
}

export default Post;

Hapus Post Lewat Action

Untuk hapus postingan, kita pake action. Karena <form> nggak bisa dipake untuk kirim rekues DELETE, kita tetep kirim POST hanya kita tambahin hidden input yang nilainya "delete".

Masih di fail routes/posts/$postId.jsx. Kita buat action() untuk ngehapus data di DB berdasarkan parameter postId.

//file: app/routes/posts/$postId.jsx

import { useLoaderData, Link, redirect } from "remix";
import { db } from "~/utils/db.server";

export const loader = async ({ params }) => {
  // ... kode yang lain
};

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

  // cek nilai dari input bernama _method.
  // kalo nilainya "delete", hapus postingan ini
  if (form.get("_method") === "delete") {
    const post = await db.post.findUnique({
      where: {
        id: params.postId,
      },
    });

    if (!post) {
      throw new Error("Post not found");
    }

    // hapus postingan dari DB
    await db.post.delete({
      where: {
        id: params.postId,
      },
    });
  }

  return redirect('/posts')
};

function Post() {
  const { post } = useLoaderData();

  return (
    <div>
      
      {/* kode yang lain */}
           
      <div className="page-footer">
        <form method="POST">
          <input type="hidden" name="_method" value="delete" />
          <button className="btn btn-delete">Hapus</button>
        </form>
      </div>
      
    </div>
  );
}

export default Post;

Sekian tutorial singkat untuk kenalan dengan Remix. Banyak miripnya dengan NextJS ya? Mana yang lebih bagus? Menurut saya ya sama-sama bagus tapi kalo ada proyek baru yang dibangun dari nol & cocok untuk aplikasi fullstack mungkin saya pribadi pilih Remix karena saya suka API-nya :).