Berkenalan dengan Remix - 1

Remix, framework fullstack baru dari tim pembuat React Router. Secara fungsional mirip NextJS (SSR, routing, dll), tapi banyak yang bilang kalo framework ini juga bisa dibandingin sama Rails, Laravel, dan sejenisnya karena aplikasi webnya bisa jalan tanpa JavaScript.

Disclaimer: Materi dalam tulisan ini saya ambil dari video tutorial di Youtube buatan Traversy Media di bawah ini. Bagian-bagian yang menurut saya nggak esensial saya lewati biar nggak kepanjangan.

Apa itu Remix?

Remix adalah fullstack framework berbasis React yang punya fitur-fitur standar seperti:

  1. SSR
  2. Routing berbasis dokumen/fail
  3. Routing bersarang (nested)
  4. Fungsi-fungsi server ( Loader & Action )
  5. Akses mudah ke elemen <head> dan dokumen HTML
  6. Penanganan error
  7. Dukungan untuk TypeScript
  8. Dukungan untuk cookies & session

Semua itu udah built-in, jadi kita nggak perlu instal paket NPM sendiri.

Instalasi & Struktur Direktori

Untuk instalasi kita jalanin perintah:

npx create-remix@latest

Skrip instalasinya nanti kasih kita beberapa opsi. Untuk direktori terserah mau dikasih nama apa tapi di sini saya ikuti yang ada di video, remix-blog. Untuk pilihan deploynya samain juga, Remix App Server.

Bahasa pilih JavaScript & langsung jalanin npm install.

Tunggu sampe selesai terus CD ke direktori aplikasi.

Sekarang coba jalanin dev server & buka localhost:3000

npm run dev

Di package.json bisa kita liat beberapa paket yang jadi dependency & skrip npm.

{
  "private": true,
  "name": "remix-app-template-js",
  "description": "",
  "license": "",
  "scripts": {
    "build": "remix build",
    "dev": "remix dev",
    "postinstall": "remix setup node",
    "start": "remix-serve build"
  },
  "dependencies": {
    "@remix-run/react": "^1.1.1",
    "react": "^17.0.2",
    "react-dom": "^17.0.2",
    "remix": "^1.1.1",
    "@remix-run/serve": "^1.1.1"
  },
  "devDependencies": {
    "@remix-run/dev": "^1.1.1"
  },
  "engines": {
    "node": ">=14"
  },
  "sideEffects": false
}

Skrip build dipake untuk bikin production-build. Skrip ini kalo dijalanin akan mengkompail kode kita & men-generate fail output direktori /build yang ada di dua tempat – satu di direktori proyek, satu lagi di direktori /public . Fail yang di direktori proyek untuk dipake di server, yang di /public dipake di klien (browser).

Skrip start dipake untuk jalanin production-server. Dijalanin sesudah deploy ke server. Kode aplikasi kita tempatnya di mana? Di direktori /app.

Fail entry.client.jsx & entry.server.jsx , sesuai namanya masing-masing adalah skrip utama yang dijalanin di klien & server. root.jsx adalah komponen utama (root) aplikasi kita.

Modul Route Utama

Buka root.jsx , hapus semua isinya. Ganti dengan ini:

export default function App(){
  return (
    <h1>My App</h1>
  )
}

Refresh browsernya.

Kalo kita view-source, isi dokumen HTML-nya ya cuma h1 itu. Nggak ada elemen lainnya.

Kita bisa tambahin elemen lainnya langsung di komponen ini.

import { Outlet } from "remix";

export default function App(){
  return (
    <html lang="en">
      <head>
        <title>My Remix Blog</title>
      </head>
      <body>
        <Outlet />
      </body>
    </html>
  )
}

Fungsi komponen <Outlet> adalah untuk menampilkan route berdasarkan path. Kalo path-nya / komponen ini menampilkan app/routes/index.jsx (2).

Live Reload

Kalo kita perhatikan, setiap kali kita ngerubah kode halaman webnya nggak otomatis terupdate. Kita harus refresh halamannya. Untuk mengaktifkan fitur autoreload / livereload, kita harus pake komponen <LiveReload>.

import { Outlet, LiveReload } from "remix";

export default function App(){
  return (
    <html lang="en">
      <head>
        <title>My Remix Blog</title>
      </head>
      <body>
        <Outlet />
        { process.env.NODE_ENV === 'development' ? <LiveReload /> : null }
      </body>
    </html>
  )
}

Kita masih perlu refresh manual sekali lagi tapi setelah itu jadi otomatis.

Remix nggak mendukung HMR, jadi kalo fitur itu lebih penting buat Anda sebaiknya pake NextJS aja karena dia punya fitur Fast Refresh.

Komponen Document & Layout

Struktur komponen App bisa kita "rapihin". Kita buat komponen Document yang isinya tag HTML dasar. Di komponen ini hanya title nya yang bisa kita kustom.

function Document({children, title}){
  return (
    <html lang="en">
      <head>
        <title>{title}</title>
      </head>
      <body>
        {children} 
        { process.env.NODE_ENV === 'development' ? <LiveReload /> : null }
      </body>
    </html>
  )
}

Satu komponen lagi di bawah Document yang nanti isinya struktur/layout halaman. Sementara isinya hanya navigasi aja.

function Layout({children}){
  return (
    <>
      <nav className="navbar">
        <Link to="/" className="logo">Remix</Link>
        <ul>
          <li>
            <Link to="/posts">Posts</Link>
          </li>
        </ul>
      </nav>
      <div className="container">
        {children}
      </div>
    </>
  )
}
import { Outlet, LiveReload, Link } from "remix";

export default function App(){
  return <Document title="Remix Blog">
    <Layout>
      <Outlet />
    </Layout>
  </Document>
}

function Document({children, title}){
  return (
    <html lang="en">
      <head>
        <title>{title}</title>
      </head>
      <body>
        {children} 
        { process.env.NODE_ENV === 'development' ? <LiveReload /> : null }
      </body>
    </html>
  )
}

function Layout({children}){
  return (
    <>
      <nav className="navbar">
        <Link to="/" className="logo">Remix</Link>
        <ul>
          <li>
            <Link to="/posts">Posts</Link>
          </li>
        </ul>
      </nav>
      <div className="container">
        {children}
      </div>
    </>
  )
}
Komponen Document & Layout ini opsional aja. Nggak pake juga nggak apa-apa. Tapi kalo pake struktur seperti di atas, kita bisa memisahkan komponen berdasarkan fungsinya – istilahnya "Separation of concerns".

Kita buat global.css di direktori app/styles/. Isinya begini (kopas dari repo tutorial di YT):

@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@300;400&display=swap');

* {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

body {
  font-family: 'Poppins', sans-serif;
}

a {
  text-decoration: none;
  color: #000;
}

ul {
  list-style: none;
}

p {
  margin: 10px 0;
}

.container {
  width: 100%;
  max-width: 960px;
  margin: auto;
  padding: 0 30px;
}

.logo {
  font-size: x-large;
  text-transform: uppercase;
}

.navbar {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 30px;
  background-color: #f4f4f4;
  padding: 10px 30px;
  text-transform: uppercase;
}

.navbar ul {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.navbar li {
  margin-left: 20px;
}

.navbar ul li a {
  color: #000;
  text-transform: uppercase;
  font-size: 14px;
  font-weight: bold;
}

.navbar ul li a:hover {
  color: #333;
  border-bottom: 1px solid #333;
}

.btn {
  display: inline-block;
  background: #000;
  color: #fff;
  border: none;
  padding: 10px 20px;
  margin: 5px;
  border-radius: 5px;
  cursor: pointer;
  text-decoration: none;
  font-size: 15px;
  font-family: inherit;
}

.btn-reverse {
  background: #fff;
  color: #000;
  border-color: #000;
}

.btn:focus {
  outline: none;
}

.btn:active {
  transform: scale(0.98);
}

.btn-block {
  display: block;
  width: 100%;
}

.btn-delete {
  background: darkred;
  color: #fff;
  border-color: darkred;
  font-size: 13px;
  padding: 5px 10px;
}

.page-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 30px;
}

.page-footer {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-top: 30px;
}

.form-control {
  margin: 20px 0;
}

.form-control label {
  display: block;
}

.form-control input,
.form-control textarea {
  width: 100%;
  height: 40px;
  margin: 5px;
  padding: 3px 7px;
  font-size: 17px;
}

.form-control textarea {
  height: 200px;
}

.form-control-check {
  display: flex;
  align-items: center;
  justify-content: space-between;
}

.form-control-check label {
  flex: 1;
}

.form-control-check input {
  flex: 2;
  height: 20px;
}

.posts-list {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
  gap: 20px;
  justify-content: space-between;
  align-items: flex-start;
}

.posts-list li {
  padding: 20px;
  border: 1px #333 solid;
  border-radius: 5px;
}

.posts-list li h3 {
  margin-bottom: 5px;
}

.error {
  color: darkred;
}

.auth-container {
  max-width: 600px;
  margin: auto;
}

.auth-container h1 {
  text-align: center;
}

.auth-container .pageHeader {
  display: block;
}

.auth-container fieldset {
  padding: 15px;
  border-radius: 5px;
}

.auth-container fieldset label {
  margin-right: 10px;
}

Kembali ke root.jsx. Kita buat fungsi links() yang balikannya adalah array berisi objek yang dipake oleh Remix untuk men-generate <link>.

import { Outlet, LiveReload, Link, Links } from "remix";
import globalStyleUrl from './styles/global.css'

export const links = () => ([
  {
    rel:'stylesheet',
    href: globalStyleUrl
  }
])

// kode yang lain

Di atas kita impor komponen baru namanya Links (pake s). Komponen ini kita pake di komponen Document di bagian <head> . Komponen ini otomatis mengambil balikan fungsi links() di atas & membuat <link> .

function Document({children, title}){
  return (
    <html lang="en">
      <head>
        <title>{title}</title>
        <Links />
      </head>
      <body>
        {/* kode yang lain*/}
      </body>
    </html>
  )
}

Meta

Selain fungis links() ada juga fungsi meta() . Dari namanya udah jelas ya kalo fungsi ini nanti dipake Remix untuk men-generate tag <meta>. Balikan fungsi ini adalah objek dengan key description & keywords. Untuk memasukkannya ke HTML kita pake komponen Meta.

import { Outlet, LiveReload, Link, Links, Meta } from "remix";
import globalStyleUrl from './styles/global.css'

export const links = () => ([
  // ... kode lainnya
])

export const meta = ()=>{
  return {
    description: 'Blog built with Remix',
    keywords: 'remix, react, javascript'
  }
}

// ... kode lainnya

function Document({children, title}){
  return (
    <html lang="en">
      <head>
        <title>{title}</title>
        <Meta />
        <Links />
      </head>
      <body>
        {/* kode lainnya */}
      </body>
    </html>
  )
}

Routing Berbasis Fail

Mirip NextJS, Remix mendukung routing berbasis fail. app/routes/index.jsx disebut default route atau index route.

Remix pake React Router di internalnya, jadi kalo ada konsep atau istilah yang asing / susah dipahami silakan baca-baca ini dulu: React Router v6 - Concepts.

Di bagian navbar, ada link ke /posts yang kalo kita buka munculnya 404 karena belum ada route-nya. Kita buat fail app/routes/posts.jsx yang isinya:

import React from 'react';

function Posts() {
  return (
    <div>
       <h1>Ini Route Posts</h1>
    </div>
  )
};

export default Posts;

Sekarang kalo kita buka localhost:3000/posts, halamannya udah nggak 404 lagi:

Nested Routes

// file: app/routes/posts.jsx

import {Outlet} from 'remix';

function Posts() {
    return (
        <div>
            <h1>Ini Route Posts</h1>

            <Outlet />
        </div>
    )
}

export default Posts
// file: app/routes/posts/new.jsx

function NewPost() {
    return (
        <div>
            Ini routes/new.jsx
        </div>
    )
}

export default NewPost
localhost:3000/posts/new

Kita bisa kosongin isi routes/posts.jsx hanya ninggalin Outlet aja. Sedangkan isinya yang lain kita pindah ke routes/posts/index.jsx. Jadi fungsi komponen ini berubah mirip komponen Layout.

// file: routes/posts.jsx

import { Outlet } from "remix";

function Posts() {
  return (
    <div>
      <Outlet />
    </div>
  );
}

export default Posts;

// file: routes/posts/index.jsx

function PostsIndex() {
  return (
    <div>
      <h1>Ini Route Posts Index</h1>
    </div>
  );
}

export default PostsIndex;
localhost:3000/posts

Jadi hubungannya posts.jsx, Outlet, posts/index.jsx & posts/new.jsx kurang lebih begini:

Route Dinamis

Untuk route dinamis, kita bikin fail yang namanya diawali $. Bagian yang di belakangnya jadi nama parameter balikan hook useParams. Jadi kalo kita bikin $postId.jsx, bagian yang dinamis itu nanti bisa kita akses lewat params.postId.

//file: routes/posts/$postId.jsx
import { useParams } from 'remix';

function Post() {

    const params = useParams();

    return (
        <div>
            <h1>Post id: {params.postId}</h1>
        </div>
    )
}

export default Post;
localhost:3000/posts/123

Bagian 1 cukup sampe sini dulu karena udah lumayan panjang. Nanti lanjut di bagian 2, kita bahas tentang Loader & Action.