Sebelumnya saya sempat menerbitkan tulisan serupa yang bisa dikunjungi disini jika belum sempat membaca. Estimasi bacanya sekitar 14 menit, tapi bisa dilewati ke bagian yang menarik saja. Di tulisan itu saya memberi sedikit pengenalan untuk menulis Gleam di ujung belakang, dan sebagai seorang mantan pengembang frontend juga, rasanya tidak adil jika tidak membahas untuk bagian depannya juga.
Saat membuat program menggunakan bahasa pemrograman Gleam, target runtime nya per tulisan ini diterbitkan ada dua: Erlang dan JavaScript. Untuk Erlang, pengembang akan menggunakan perintah gleam export erlang-shipment
untuk membuat precompiled/artefak yang nantinya akan dijalankan di BEAM dan jika targetnya JavaScript, maka akan menghasilkan kode JavaScript dengan format ES6 Module dan terserah ingin diapakan.
Tapi pertanyaan mendasarnya adalah satu: mengapa menggunakan Gleam jika ujungnya menjadi kode JavaScript juga? Meskipun kasus ini tidak terbatas di Gleam—bisa di ReScript, TypeScript, Reason, Clojure, dan masih banyak lagi—di tulisan ini saya akan mencoba untuk menjawab pertanyaan mendasar itu.
Yang mungkin bisa menjawab juga untuk bahasa pemrograman lain yang tadi saya sebut seperti ReScript, Reason dan Clojure.
Sebelum masuk ke topik inti, mari kita mulai dari topik yang paling sulit dalam pengembangan front-end: berurusan dengan state.
State this, state that
Aplikasi adalah tentang menampilkan informasi dari kumpulan data, sehingga data tersebut bisa berguna. Dan aplikasi seharusnya interaktif, karena jika tidak, tidak ada bedanya dengan dokumen biasa yang sama-sama menampilkan informasi.
Kumpulan data ini diterima dari sebuah "sistem" yang populer disebut backend. Backend bertugas untuk mengatur permintaan pengguna aplikasi, memberikan data yang diinginkan, sekaligus menolaknya jika harus. Aplikasi seperti ini menggunakan model client-server, meskipun di beberapa kasus, peran server tidak diperlukan: seperti aplikasi kalkulator ataupun aplikasi lain yang menggunakan model peer-to-peer misalnya seperti kalkulator. Bercanda, seperti aplikasi perpesanan instan misalnya yang hanya membutuhkan peran server sebagai "jembatan" untuk menghubungkan dua atau lebih peer (klien).
Lalu di mana letak tantangannya?
Cukup beragam. Misal, di aplikasi kalkulator. Oke oke misal di aplikasi dasbor dan terdapat kolom pencarian. Saat pengguna mengetik di kolom tersebut, pengguna mengharapkan apa yang dia ketik muncul, dan jika tidak, mengharapkan sebuah informasi alasan tidak munculnya.
Dan, uh, pengguna diyakini tidak sabaran dan sering kali membutuhkan distraksi saat proses menunggu.
Berdasarkan kasus diatas, setidaknya ada 5 kondisi yang harus kita pikirkan:
- Saat data diminta
- Saat data berhasil diterima
- Saat data gagal diterima
- Saat data diterima sesuai harapan
- Saat data diterima tidak sesuai harapan
Yang pertama, ini paling umum: loading. Biasanya kita menambahkan spinner ataupun skeleton element untuk mendistraksi si pengguna.
Yang kedua & ketiga, ini umum juga: fulfilled/failed. Rentang status code ada di 200-530. Jika diatas 500, biasanya bisa melakukan retry, dan http client library yang digunakan biasanya sudah mengaturnya. Tentu jika membuat http client sendiri karena anti importir engineer club seharusnya sudah memikirkan ini juga :))
Yang keempat, ini masih umum. Data yang diterima sesuai harapan sehingga masalah undefined
tidak ditemukan.
Yang kelima, tidak terlalu umum. Di kasus yang mantap ada yang menaruh status code di response body atau juga karena response yang diberikan tidak sesuai dengan kontrak yang disetujui sebelumnya.
Pengembang front-end harus mengatur itu semua, tapi, tidak perlu adu nasib. Backend pun melakukan hal serupa namun mungkin tidak serumit di depan.
Baiklah, dari berbagai pola yang digunakan dalam membuat UI, rumus paling populer adalah seperti ini:
UI = fn(state)
Rumus ini mungkin dipopulerkan oleh Guillermo Rauch di Pure UI nya atau oleh Karolis Narkevicius di UI as a function of data nya, entahlah, kita sedang tidak membahas sejarah.
Ide nya sederhana: UI hanyalah sebuah function yang menerima data dan mengembalikan tampilan sebagai keluarannya, misal seperti ini:
function FeedView(state) {
if (state.isFetching) <Spinner />
if (state.isFulfilled) <Feed state={state} />
if (state.isFailed) <FeedError state={state} />
}
Yang menjadi masalah adalah bagaimana state diatur dan mengalir, dan ada belasan (jika bukan puluhan) cara. Yang cukup populer adalah menggunakan pola Flux, yang intinya kurang lebih menggunakan paradigma reactive programming. Jika menggunakan Redux, Mobx, Vuex, Pinia, dsb — yes, mereka pun menggunakan paradigma itu.
Berbekal rumus UI = fn(state)
, khususnya di pengembangan frontend modern, kemungkinan waktu kita akan dihabiskan banyak dalam menyusun UI dan mengatur state.
Dan semoga produktif serta terasa menyenangkan! *sambil flashback saat masih menjadi frontend engineer*
The Elm Architecture (TEA)
Berbicara jika UI = fn(state)
tentu tidak adil jika tidak membahas efek samping alias side-effect. Ini menjadi salah satu alasan inti mengapa state harus berubah.
Di beberapa aplikasi, kita menggunakan event emitter dengan sebuah konsep bernama component lifecycle. Ingat componentDidMount
dan componentWillMount
untuk flashback? Atau ada mounted
dan beforeDestroy
juga bila di framework lain.
Di UI library modern, kita menggunakan pendekatan effect yang populer digunakan untuk berinteraksi dengan "sistem external". Dan ini contoh yang saya ambil dari dokumentasi resmi React dengan sedikit adjustment di spacing dan penggunaan semicolon:
useEffect(() => {
async function startFetching() {
setBio(null)
const result = await fetchBio(person)
if (!ignore) {
setBio(result)
}
}
let ignore = false
startFetching()
return () => {
ignore = true
}
}, [person])
Dari kode diatas, kita tahu jika:
- effect dijalankan bila state
person
berubah - state
person
berubah saat terjadi perubahan di input<select>
Variable ignore
diatas hanya sebagai contoh untuk menghindari race condition (mengambil dari halaman dokumentasinya).
Siapa yang bisa mengubah state person
? Secara teknis, siapapun. Mungkin input dari select
mungkin perubahan di url bar — tergantung bagaimana membuatnya.
Di kasus nyata, umumnya kita akan menggunakan state manager karena yakin state yang akan kita atur nanti cukup kompleks. Beruntung jika menggunakan library state manager yang battle-tested dan wished you the best of luck jika membuat state manager mandiri.
Dari kode di atas, itu relevan jika state tersebut hanya digunakan di komponen tersebut (atau yang dibawahnya). Bagaimana jika state tersebut pun digunakan di komponen lain yang se-level atau mungkin diatasnya?
Tentu saja dengan memindahkankan effectnya! Tapi menyimpan state disini dan disana cukup ribet, dan dengan menghindari penggunaan state manager yang ada kita akan berakhir dengan membuat state manager sendiri. Konsep state manager ini sederhananya adalah: buat state menjadi global dan tersimpan di satu tempat. Untuk mengambilnya, nilainya bisa menggunakan API tertentu seperti .getState()
dan untuk mengubahnya bisa menggunakan API tertentu juga seperti .dispatch({ type: 'setBio', data })
dan biarkan reducer yang mengaturnya.
Pola ini dipopulerkan oleh Elm yang menggunakan konsep "Model View Update" yang bisa merujuk ke The Elm Architecture yang sederhananya:
- view menampilkan tampilan berdasarkan data yang sudah dibentuk
- view memberitahu runtime untuk melakukan perubahan (update) data lama dengan data baru (model)
- runtime memberitahu view untuk menampilkan tampilan berdasarkan data yang sudah dibentuk (kembali ke step 1)
Alurnya terus begitu dan selalu begitu. Pertanyaannya, mengapa ini menarik?
- Dalam segi simplicity, disini separation of concerns nya cukup jelas
- Dalam segi predictability,
update
adalah pure function yang memproses state yang ada lalu membuat state baru - Dalam segi scalability, well, ini cukup subjektif. Tapi cukup fokus dengan tiga pilar utama: model, update, view.
Di kode Elm sederhana, misal seperti ini:
import Browser
import Html exposing (Html, button, div, text)
import Html.Events exposing (onClick)
-- MAIN
main =
Browser.sandbox {
init = init
, update = update
, view = view
}
-- MODEL
type alias Model =
{ thing: String, anotherThing: String }
init : Model
init =
{ thing = "world", anotherThing = " " }
-- UPDATE
type Msg =
UpdateWorld String
update : Msg -> Model -> Model
update msg model =
case msg of
UpdateWorld newThing ->
({ model | thing = newThing, anotherThing = "... " })
-- VIEW
view : Model -> Html Msg
view model =
div []
[div [] [text ("hello" ++ model.anotherThing ++ model.thing)]
, (buttonOfLife model)
]
buttonOfLife: Model -> Html Msg
buttonOfLife model =
if model.thing == "world" then
button [onClick (UpdateWorld "my world?")] [text "click me"]
else
div [] []
Mungkin sintaks nya terlihat sedikit memusingkan begitu pula dengan penamaan variable ataupun untuk update nya. Tapi gambarannya seperti itu! Kode diatas kita belum menyertakan "side-effect" karena nantinya akan dibahas di topik utama kita, jika Elm architecture diatas sudah terlihat masuk akal, mari kita mulai membahas pemeran utama kita!
Lustre
Ladies and gentlemen, I present to you: Lustre.
Lustre adalah jawaban untuk yang menginginkan Elm di Gleam. Mengapa tidak membawa React ke Gleam aja, pak ustadz? Well, tentu, jika memang memiliki alasan sendiri mengapa menginginkan React di Gleam. Mungkin seperti Reason dan/atau ReScript yang mencoba untuk terlihat ataupun bertindak seperti React.
Saya pribadi tidak mengharapkan ada "seperti React" di Gleam, atau di Swift, atau di Rust. Biarlah React berada di dunianya sendiri :D
Berikan saya waktu 5 menit untuk memperkenalkan Lustre, lalu silahkan nilai sendiri apakah merasa cocok dengan Lustre. Pertama, saya akan mengubah kode Elm sebelumnya menjadi Lustre di Gleam, dan kurang lebih seperti ini:
import lustre
import lustre/effect.{type Effect}
import lustre/element.{type Element}
import lustre/element/html.{button, div, text}
import lustre/event.{on_click}
// -- INIT
pub fn main() {
let app = lustre.application(init, update, view)
let assert Ok(_) = lustre.start(app, "#app", Nil)
Nil
}
fn init(_) -> #(Model, Effect(Msg)) {
#(Model(thing: "world", another_thing: " "), effect.none())
}
// -- MODEL
pub type Model {
Model(thing: String, another_thing: String)
}
// -- UPDATE
pub type Msg {
UpdateWorld(String)
}
fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
case msg {
UpdateWorld(world) -> #(
Model(..model, thing: world, another_thing: "... "),
effect.none(),
)
}
}
// -- VIEW
fn view(model: Model) -> Element(Msg) {
div([], [
div([], [text("hello" <> model.another_thing <> model.thing)]),
button_of_life(model),
])
}
fn button_of_life(model: Model) -> Element(Msg) {
case model.thing {
"world" -> button([on_click(UpdateWorld("my world?"))], [text("click me")])
_ -> div([], [])
}
}
Bukankah sangat mirip? Menurut saya pribadi sintaks yang Gleam lebih mudah dipahami dari yang Elm, YMMV.
Mungkin sintaks diatas terlihat sedikit... familiar? Pernah menggunakan React.createElement('div', {}, [])
saat Webpack masih keren?
The Elm Architecture (TEA) ini cukup banyak memberi pengaruh, dari charmbracelet/bubbletea
, Redux, iced-rs/iced
, dan tentu saja Lustre itu sendiri. Yang berarti, cukup aman untuk dikatakan jika pendekatan/pola TEA ini dapat diandalkan.
Jika merasa terjual, kita akan lanjut ke sedikit perkenalan terkait Lustre untuk kasus nyata yang umum digunakan.
SPA di Host Springs Lustre
Untuk mempermudah, kita akan menggunakan model Single Page Application (SPA) alias rendering & routing dilakukan client-side — meskipun Lustre mendukung SSR dan "Server Components" yang masih WIP.
Untuk memulai, bisa merujuk ke halaman ini. Pastikan menggunakan lustre_dev_tools
juga untuk mendapatkan fitur live reload dan dukungan TailwindCSS.
Kasus umum untuk aplikasi frontend adalah berbicara ke dunia luar dan bertransaksi untuk mendapatkan sesuatu bernama JSON yang nantinya akan ditampilkan dalam bentuk format yang lebih indah daripada sesuatu itu. Untuk bahan percobaan, salah satu yang menjadi favorit pengembang frontend adalah Starwars API dan mari kita gunakan itu.
Kita mulai dari endpoint /people
, per tulisan ini diterbitkan, skema dari response nya adalah seperti ini:
[
{
"name": "Luke Skywalker",
"height": "172",
"mass": "77",
"hair_color": "blond",
"skin_color": "fair",
"eye_color": "blue",
"birth_year": "19BBY",
"gender": "male",
"homeworld": "https://swapi.info/api/planets/1",
"films": [
"https://swapi.info/api/films/1",
"https://swapi.info/api/films/2",
"https://swapi.info/api/films/3",
"https://swapi.info/api/films/6"
],
"species": [],
"vehicles": [
"https://swapi.info/api/vehicles/14",
"https://swapi.info/api/vehicles/30"
],
"starships": [
"https://swapi.info/api/starships/12",
"https://swapi.info/api/starships/22"
],
"created": "2014-12-09T13:50:51.644000Z",
"edited": "2014-12-20T21:17:56.891000Z",
"url": "https://swapi.info/api/people/1"
}
]
Karena data di atas berasal dari luar Gleam, kita perlu melakukan decoding karena pada akhirnya kita akan menyimpan data tersebut di Gleam.
Melihat ke skema nya, data di atas dapat direpresentasikan menggunakan Records seperti ini:
pub type People {
People(
name: String,
height: String,
mass: String,
hair_color: String,
skin_color: String,
eye_color: String,
birth_year: String,
gender: String,
homeworld: String,
films: List(String),
species: List(String),
vehicles: List(String),
starships: List(String),
created: String,
edited: String,
url: String,
)
}
Sebagai contoh, untuk si Luke, bisa dibuat seperti ini:
let luke =
People(
name: "Luke Skywalker",
height: "172",
mass: "77",
hair_color: "blond",
skin_color: "fair",
eye_color: "blue",
birth_year: "19BBY",
gender: "male",
homeworld: "https://swapi.info/api/planets/1",
films: [
"https://swapi.info/api/films/1", "https://swapi.info/api/films/2",
"https://swapi.info/api/films/3", "https://swapi.info/api/films/6",
],
species: [],
vehicles: [
"https://swapi.info/api/vehicles/14",
"https://swapi.info/api/vehicles/30",
],
starships: [
"https://swapi.info/api/starships/12",
"https://swapi.info/api/starships/22",
],
created: "2014-12-09T13:50:51.644000Z",
edited: "2014-12-20T21:17:56.891000Z",
url: "https://swapi.info/api/people/1",
)
Lalu kita perlu membuat decoder nya, dan ini bagian menariknya: field diatas berjumlah 16 dan untuk melakukan decode
, yang paling mendekati adalah dynamic.decode9
. Yang berarti, sisa 7 nya perlu kita lakukan mandiri :))
Dan berikut decodernya:
fn decode_starwars_people(
data: dynamic.Dynamic,
) -> Result(People, List(dynamic.DecodeError)) {
let decode_people =
dynamic.decode9(
fn(
name: String,
height: String,
mass: String,
hair_color: String,
skin_color: String,
eye_color: String,
birth_year: String,
gender: String,
home_world: String,
) {
fn(
films: List(String),
species: List(String),
vehicles: List(String),
starships: List(String),
created: String,
edited: String,
url: String,
) {
People(
name,
height,
mass,
hair_color,
skin_color,
eye_color,
birth_year,
gender,
home_world,
films,
species,
vehicles,
starships,
created,
edited,
url,
)
}
},
dynamic.field("name", dynamic.string),
dynamic.field("height", dynamic.string),
dynamic.field("mass", dynamic.string),
dynamic.field("hair_color", dynamic.string),
dynamic.field("skin_color", dynamic.string),
dynamic.field("eye_color", dynamic.string),
dynamic.field("birth_year", dynamic.string),
dynamic.field("gender", dynamic.string),
dynamic.field("homeworld", dynamic.string),
)(data)
case decode_people {
Ok(rest) -> decode_rest(data, rest)
Error(e) -> Error(e)
}
}
fn decode_rest(
data: dynamic.Dynamic,
handler: fn(
List(String),
List(String),
List(String),
List(String),
String,
String,
String,
) ->
People,
) -> Result(People, List(dynamic.DecodeError)) {
case
dynamic.field("films", dynamic.list(dynamic.string))(data),
dynamic.field("species", dynamic.list(dynamic.string))(data),
dynamic.field("vehicles", dynamic.list(dynamic.string))(data),
dynamic.field("starships", dynamic.list(dynamic.string))(data),
dynamic.field("created", dynamic.string)(data),
dynamic.field("edited", dynamic.string)(data),
dynamic.field("url", dynamic.string)(data)
{
Ok(films),
Ok(species),
Ok(vehicles),
Ok(starships),
Ok(created),
Ok(edited),
Ok(url)
-> Ok(handler(films, species, vehicles, starships, created, edited, url))
Error(e), _, _, _, _, _, _ -> Error(e)
_, Error(e), _, _, _, _, _ -> Error(e)
_, _, Error(e), _, _, _, _ -> Error(e)
_, _, _, Error(e), _, _, _ -> Error(e)
_, _, _, _, Error(e), _, _ -> Error(e)
_, _, _, _, _, Error(e), _ -> Error(e)
_, _, _, _, _, _, Error(e) -> Error(e)
}
}
fn decode_starwars_people_response(
data: dynamic.Dynamic,
) -> Result(List(People), List(dynamic.DecodeError)) {
dynamic.list(decode_starwars_people)(data)
}
HAHAHA. Beautiful (menggunakan nada pegasus).
Saya belum menemukan cara "elegan" untuk menghadapi kasus diatas, dan jika memiliki, please let me know!
Sekarang, kita mulai ritual nya. Disini saya akan menggunakan lustre_http
sebagai HTTP client nya dan ingin memanggil endpoint tersebut ketika init
, jadi kodenya seperti ini:
fn get_starwars_people() -> Effect(Msg) {
let endpoint = "https://swapi.info/api/people"
lustre_http.get(
endpoint,
lustre_http.expect_json(
decode_starwars_people_response,
SWAPIPeopleReturned,
),
)
}
fn init(_) -> #(Model, Effect(Msg)) {
#(Model(starwars_people: []), get_starwars_people())
}
// -- MODEL
pub type Model {
Model(starwars_people: List(People))
}
// -- UPDATE
pub type Msg {
SWAPIPeopleReturned(Result(List(People), HttpError))
}
fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
case msg {
SWAPIPeopleReturned(Ok(people)) -> #(
Model(starwars_people: people),
effect.none(),
)
SWAPIPeopleReturned(Error(_)) -> #(Model(..model), effect.none())
}
}
Dan mari membuat sedikit UI nya:
// -- VIEW
fn view(model: Model) -> Element(Msg) {
div([class("min-h-screen bg-gray-800 lg:p-8")], [
html.h1([class("text-center text-8xl text-yellow-500 font-bold mb-10")], [
text("the people"),
]),
div(
[class("flex flex-wrap justify-center items-start")],
model.starwars_people
|> list.map(fn(people) { starwars_people_view(people) }),
),
])
}
fn starwars_people_view(people: People) -> Element(Msg) {
div([class("max-w-sm rounded-lg overflow-hidden bg-gray-700 p-6 m-4")], [
div([class("font-bold text-2xl mb-3 text-yellow-500")], [text(people.name)]),
div([class("uppercase font-bold text-gray-300 text-lg")], [
text(people.gender),
]),
])
}
Dan pratinjau nya:
Yeah bukan sesuatu yang bisa dibanggakan haha.
Sekarang kita buat kondisi bagaimana jika response nya somehow berisi array kosong? Atau terjadi error? Atau mungkin skema nya berubah?
Karena kita sudah bermain kondisi, mari kita tambahkan "state" nya:
fn init(_) -> #(Model, Effect(Msg)) {
#(
Model(starwars_people: [], starwars_people_status: PeopleLoading),
get_starwars_people(),
)
}
// -- MODEL
pub type SWAPIStatus {
PeopleLoading
PeopleEmpty
PeopleFulfilled
PeopleError
}
pub type Model {
Model(starwars_people: List(People), starwars_people_status: SWAPIStatus)
}
Lalu di update nya:
// -- UPDATE
fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
case msg {
SWAPIPeopleReturned(Ok(people)) ->
case people {
[] -> #(
Model(..model, starwars_people_status: PeopleEmpty),
effect.none(),
)
_ -> #(
Model(
starwars_people: people,
starwars_people_status: PeopleFulfilled,
),
effect.none(),
)
}
SWAPIPeopleReturned(Error(_)) -> #(
Model(..model, starwars_people_status: PeopleError),
effect.none(),
)
}
}
Dan view nya:
// -- VIEW
fn view(model: Model) -> Element(Msg) {
div([class("min-h-screen bg-gray-800 lg:p-8")], [
html.h1([class("text-center text-8xl text-yellow-500 font-bold mb-10")], [
text("the people"),
]),
div([class("flex flex-wrap justify-center items-start")], case
model.starwars_people_status
{
PeopleLoading -> [starwars_people_view_loading()]
PeopleFulfilled ->
model.starwars_people
|> list.map(fn(people) { starwars_people_view(people) })
PeopleEmpty -> [starwars_people_view_empty()]
PeopleError -> [starwars_people_view_error()]
}),
])
}
fn starwars_people_view_empty() -> Element(Msg) {
div([class("text-white text-2xl font-bold")], [text("where's everyone???")])
}
fn starwars_people_view_error() -> Element(Msg) {
div([class("text-red-700 text-2xl font-bold")], [
text("something went wrong with the people"),
])
}
fn starwars_people_view_loading() -> Element(Msg) {
div([class("text-white")], [text("loading")])
}
fn starwars_people_view(person: People) -> Element(Msg) {
div([class("max-w-sm rounded-lg overflow-hidden bg-gray-700 p-6 m-4")], [
div([class("font-bold text-2xl mb-3 text-yellow-500")], [text(person.name)]),
div([class("uppercase font-bold text-gray-300 text-lg")], [
text(person.gender),
]),
])
}
Untuk kode lengkapnya seperti ini:
import lustre
import gleam/dynamic
import gleam/list
import lustre/effect.{type Effect}
import lustre/element.{type Element}
import lustre_http.{type HttpError}
import lustre/attribute.{class}
import lustre/element/html.{div, text}
// -- MAIN
fn get_starwars_people() -> Effect(Msg) {
let endpoint = "https://swapi.info/api/people"
lustre_http.get(
endpoint,
lustre_http.expect_json(
decode_starwars_people_response,
SWAPIPeopleReturned,
),
)
}
pub fn main() {
let app = lustre.application(init, update, view)
let assert Ok(_) = lustre.start(app, "#app", Nil)
Nil
}
fn init(_) -> #(Model, Effect(Msg)) {
#(
Model(starwars_people: [], starwars_people_status: PeopleLoading),
get_starwars_people(),
)
}
// -- MODEL
pub type SWAPIStatus {
PeopleLoading
PeopleEmpty
PeopleFulfilled
PeopleError
}
pub type People {
People(
name: String,
height: String,
mass: String,
hair_color: String,
skin_color: String,
eye_color: String,
birth_year: String,
gender: String,
homeworld: String,
films: List(String),
species: List(String),
vehicles: List(String),
starships: List(String),
created: String,
edited: String,
url: String,
)
}
pub type Model {
Model(starwars_people: List(People), starwars_people_status: SWAPIStatus)
}
// -- UPDATE
pub type Msg {
SWAPIPeopleReturned(Result(List(People), HttpError))
}
fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
case msg {
SWAPIPeopleReturned(Ok(people)) ->
case people {
[] -> #(
Model(..model, starwars_people_status: PeopleEmpty),
effect.none(),
)
_ -> #(
Model(
starwars_people: people,
starwars_people_status: PeopleFulfilled,
),
effect.none(),
)
}
SWAPIPeopleReturned(Error(_)) -> #(
Model(..model, starwars_people_status: PeopleError),
effect.none(),
)
}
}
// -- VIEW
fn view(model: Model) -> Element(Msg) {
div([class("min-h-screen bg-gray-800 lg:p-8")], [
html.h1([class("text-center text-8xl text-yellow-500 font-bold mb-10")], [
text("the people"),
]),
div([class("flex flex-wrap justify-center items-start")], case
model.starwars_people_status
{
PeopleLoading -> [starwars_people_view_loading()]
PeopleFulfilled ->
model.starwars_people
|> list.map(fn(people) { starwars_people_view(people) })
PeopleEmpty -> [starwars_people_view_empty()]
PeopleError -> [starwars_people_view_error()]
}),
])
}
fn starwars_people_view_empty() -> Element(Msg) {
div([class("text-white text-2xl font-bold")], [text("where's everyone???")])
}
fn starwars_people_view_error() -> Element(Msg) {
div([class("text-red-700 text-2xl font-bold")], [
text("something went wrong with the people"),
])
}
fn starwars_people_view_loading() -> Element(Msg) {
div([class("text-white")], [text("loading")])
}
fn starwars_people_view(person: People) -> Element(Msg) {
div([class("max-w-sm rounded-lg overflow-hidden bg-gray-700 p-6 m-4")], [
div([class("font-bold text-2xl mb-3 text-yellow-500")], [text(person.name)]),
div([class("uppercase font-bold text-gray-300 text-lg")], [
text(person.gender),
]),
])
}
fn decode_starwars_people(
data: dynamic.Dynamic,
) -> Result(People, List(dynamic.DecodeError)) {
let decode_people =
dynamic.decode9(
fn(
name: String,
height: String,
mass: String,
hair_color: String,
skin_color: String,
eye_color: String,
birth_year: String,
gender: String,
home_world: String,
) {
fn(
films: List(String),
species: List(String),
vehicles: List(String),
starships: List(String),
created: String,
edited: String,
url: String,
) {
People(
name,
height,
mass,
hair_color,
skin_color,
eye_color,
birth_year,
gender,
home_world,
films,
species,
vehicles,
starships,
created,
edited,
url,
)
}
},
dynamic.field("name", dynamic.string),
dynamic.field("height", dynamic.string),
dynamic.field("mass", dynamic.string),
dynamic.field("hair_color", dynamic.string),
dynamic.field("skin_color", dynamic.string),
dynamic.field("eye_color", dynamic.string),
dynamic.field("birth_year", dynamic.string),
dynamic.field("gender", dynamic.string),
dynamic.field("homeworld", dynamic.string),
)(data)
case decode_people {
Ok(rest) -> decode_rest(data, rest)
Error(e) -> Error(e)
}
}
fn decode_rest(
data: dynamic.Dynamic,
handler: fn(
List(String),
List(String),
List(String),
List(String),
String,
String,
String,
) ->
People,
) -> Result(People, List(dynamic.DecodeError)) {
case
dynamic.field("films", dynamic.list(dynamic.string))(data),
dynamic.field("species", dynamic.list(dynamic.string))(data),
dynamic.field("vehicles", dynamic.list(dynamic.string))(data),
dynamic.field("starships", dynamic.list(dynamic.string))(data),
dynamic.field("created", dynamic.string)(data),
dynamic.field("edited", dynamic.string)(data),
dynamic.field("url", dynamic.string)(data)
{
Ok(films),
Ok(species),
Ok(vehicles),
Ok(starships),
Ok(created),
Ok(edited),
Ok(url)
-> Ok(handler(films, species, vehicles, starships, created, edited, url))
Error(e), _, _, _, _, _, _ -> Error(e)
_, Error(e), _, _, _, _, _ -> Error(e)
_, _, Error(e), _, _, _, _ -> Error(e)
_, _, _, Error(e), _, _, _ -> Error(e)
_, _, _, _, Error(e), _, _ -> Error(e)
_, _, _, _, _, Error(e), _ -> Error(e)
_, _, _, _, _, _, Error(e) -> Error(e)
}
}
fn decode_starwars_people_response(
data: dynamic.Dynamic,
) -> Result(List(People), List(dynamic.DecodeError)) {
dynamic.list(decode_starwars_people)(data)
}
Menyimpan semua kode di satu berkas tentu bukanlah pilihan yang bijak, nantinya kita akan coba melakukan sedikit refactor!
Fearless Refactor
Pertama, perihal selera (preferensi). Untuk struktur direktori saya senangnya mengaturnya seperti ini:
src
├── components
├── data
├── effects
├── pages
└── views
Dengan catatan:
-
components
berisi reusable components sepertiNavbar
,Card
dsb yang return nyaElement(t)
-
data
berisi hal-hal yang bersifat... data. Model, msg, dsb yang return nyaModel
-
effects
berisi hal-hal yang return nyaEffect(Msg)
termasuk pemanggilan jaringan -
pages
untuk menyimpan top-level module yang berkaitan dengan halaman tertentu sepertiHome
,StaticPage
dsb yang return nyaElement(Msg)
-
views
ini lebih seperti ke fragments. Misal layout, FeedView, dan terkadang component-component kecil yang bersifat "state" seperti Loading, Empty, Error dsb yang return nyaElement(t)
juga
Pendekatan diatas tujuannya (tujuan saya) biar tidak terlalu memusingkan taro ini di mana, cukup merujuk ke return nya, dan that's it.
Ada file-file yang biasanya saya taruh di top-level (src) seperti update.gleam
untuk menampung segala hal yang return nya #(Model, Effect(Msg))
, hal-hal yang bersifat FFI, dan tentu saja utils
kebanggaan kita.
Pertama, kita memindahkan Model. Ini yang paling mudah:
// data/model.gleam
pub type SWAPIStatus {
PeopleLoading
PeopleEmpty
PeopleFulfilled
PeopleError
}
pub type People =
List(Person)
pub type Person {
Person(
name: String,
height: String,
mass: String,
hair_color: String,
skin_color: String,
eye_color: String,
birth_year: String,
gender: String,
homeworld: String,
films: List(String),
species: List(String),
vehicles: List(String),
starships: List(String),
created: String,
edited: String,
url: String,
)
}
pub type Model {
Model(starwars_people: People, starwars_people_status: SWAPIStatus)
}
Disini sekaligus mengubah nama record People
menjadi Person
agar tidak membingungkan sekaligus sepertinya lebih relevan dan People
menjadi List(Person)
sebagai alias.
Umumnya model.gleam
tidak memiliki dependensi (kecuali stdlib) untuk menghindari circular deps.
Lalu msg
yang akan selalu bertambah:
// data/msg.gleam
import lustre_http.{type HttpError}
import data/model.{type People}
pub type Msg {
SWAPIPeopleReturned(Result(People, HttpError))
}
Dilanjutkan effect
yang diawali dengan api.gleam
:
// api.gleam
import gleam/dynamic
import lustre_http
import lustre/effect.{type Effect}
import data/model.{type People, type Person}
import data/msg.{type Msg, SWAPIPeopleReturned}
pub fn get_starwars_people() -> Effect(Msg) {
let endpoint = "https://swapi.info/api/people"
lustre_http.get(
endpoint,
lustre_http.expect_json(
decode_starwars_people_response,
SWAPIPeopleReturned,
),
)
}
fn decode_starwars_people(
data: dynamic.Dynamic,
) -> Result(Person, List(dynamic.DecodeError)) {
let decode_people =
dynamic.decode9(
fn(
name: String,
height: String,
mass: String,
hair_color: String,
skin_color: String,
eye_color: String,
birth_year: String,
gender: String,
home_world: String,
) {
fn(
films: List(String),
species: List(String),
vehicles: List(String),
starships: List(String),
created: String,
edited: String,
url: String,
) {
model.new_person(
name,
height,
mass,
hair_color,
skin_color,
eye_color,
birth_year,
gender,
home_world,
films,
species,
vehicles,
starships,
created,
edited,
url,
)
}
},
dynamic.field("name", dynamic.string),
dynamic.field("height", dynamic.string),
dynamic.field("mass", dynamic.string),
dynamic.field("hair_color", dynamic.string),
dynamic.field("skin_color", dynamic.string),
dynamic.field("eye_color", dynamic.string),
dynamic.field("birth_year", dynamic.string),
dynamic.field("gender", dynamic.string),
dynamic.field("homeworld", dynamic.string),
)(data)
case decode_people {
Ok(rest) -> decode_rest(data, rest)
Error(e) -> Error(e)
}
}
fn decode_rest(
data: dynamic.Dynamic,
handler: fn(
List(String),
List(String),
List(String),
List(String),
String,
String,
String,
) ->
Person,
) -> Result(Person, List(dynamic.DecodeError)) {
case
dynamic.field("films", dynamic.list(dynamic.string))(data),
dynamic.field("species", dynamic.list(dynamic.string))(data),
dynamic.field("vehicles", dynamic.list(dynamic.string))(data),
dynamic.field("starships", dynamic.list(dynamic.string))(data),
dynamic.field("created", dynamic.string)(data),
dynamic.field("edited", dynamic.string)(data),
dynamic.field("url", dynamic.string)(data)
{
Ok(films),
Ok(species),
Ok(vehicles),
Ok(starships),
Ok(created),
Ok(edited),
Ok(url)
-> Ok(handler(films, species, vehicles, starships, created, edited, url))
Error(e), _, _, _, _, _, _ -> Error(e)
_, Error(e), _, _, _, _, _ -> Error(e)
_, _, Error(e), _, _, _, _ -> Error(e)
_, _, _, Error(e), _, _, _ -> Error(e)
_, _, _, _, Error(e), _, _ -> Error(e)
_, _, _, _, _, Error(e), _ -> Error(e)
_, _, _, _, _, _, Error(e) -> Error(e)
}
}
fn decode_starwars_people_response(
data: dynamic.Dynamic,
) -> Result(People, List(dynamic.DecodeError)) {
dynamic.list(decode_starwars_people)(data)
}
Untuk penempatan si decoder cukup fleksibel, yang penting ga bingung harus nyari kemana haha.
Lalu kita ke update (sekaligus si data/model.gleam
dan nambahin new_person
juga) buat nambahin si init:
// update.gleam
import gleam/pair
import lustre/effect.{type Effect}
import data/model.{type Model}
import data/msg.{type Msg}
import effects/api
pub fn init() -> #(Model, Effect(Msg)) {
pair.new(model.init(), api.get_starwars_people())
}
// data/model.gleam
pub fn new_person(
name: String,
height: String,
mass: String,
hair_color: String,
skin_color: String,
eye_color: String,
birth_year: String,
gender: String,
home_world: String,
films: List(String),
species: List(String),
vehicles: List(String),
starships: List(String),
created: String,
edited: String,
url: String,
) -> Person {
Person(
name,
height,
mass,
hair_color,
skin_color,
eye_color,
birth_year,
gender,
home_world,
films,
species,
vehicles,
starships,
created,
edited,
url,
)
}
pub fn init() -> Model {
Model(starwars_people: [], starwars_people_status: PeopleLoading)
}
Perlu diketahui bila Gleam melakukan type inference jadi menulis "type signature/annotation" di return seharusnya tidak diwajibkan kecuali di bagian parameter agar tidak ambigu.
Lalu bagian init
di main
kita sekarang menjadi seperti ini:
fn init(_) -> #(Model, Effect(Msg)) {
update.init()
}
Yeaa initialization pada dasarnya sebuah update, kan?
Dan terakhir, bagian si update
di main:
fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
case msg {
msg.SWAPIPeopleReturned(Ok(people)) -> update.set_starwars_people(people)
msg.SWAPIPeopleReturned(Error(_)) -> update.set_starwars_people_error()
}
}
Daan perlu memperbarui data/model.gleam
dan update.gleam
kita:
// data/model.gleam
/// buyer alert: idealnya klo model udah kompleks
/// perlu bawa bawa existing state seperti ini: Model(..model, starwars_people:, starwars_people_status:)
/// agar tidak ter-override
pub fn set_starwars_people(
starwars_people: People,
starwars_people_status: SWAPIStatus,
) {
Model(starwars_people:, starwars_people_status:)
}
/// buyer alert: idealnya klo model udah kompleks
/// perlu bawa bawa existing state seperti ini: Model(..model, starwars_people:, starwars_people_status:)
/// agar tidak ter-override
pub fn starwars_people_error() {
Model(starwars_people: [], starwars_people_status: PeopleError)
}
// update.gleam
pub fn set_starwars_people(people: People) {
case people {
[] -> model.set_starwars_people(people, PeopleEmpty)
people -> model.set_starwars_people(people, PeopleFulfilled)
}
|> pair.new(effect.none())
}
pub fn set_starwars_people_error() {
pair.new(model.starwars_people_error(), effect.none())
}
Lumayan tertata, kan?
Dan jika ingin menambah "fitur" baru:
- update
data/msg.gleam
, compiler error, harus menambah pattern tersebut diupdate
- update
update.gleam
- update
model.gleam
- compiler happy
- repeat
Sekarang refactor terakhir, view:
// -- VIEW
fn view(model: Model) -> Element(Msg) {
views.starwars_people_view(model)
}
// views.gleam
import gleam/list
import lustre/attribute.{class}
import lustre/element/html.{div, text}
import data/model.{type Model, type People}
import components/card
import views/empty
import views/error
import views/loading
pub fn starwars_people_view(model: Model) {
div([class("min-h-screen bg-gray-800 lg:p-8")], [
html.h1([class("text-center text-8xl text-yellow-500 font-bold mb-10")], [
text("the people"),
]),
div([class("flex flex-wrap justify-center items-start")], [
case model.starwars_people_status {
model.PeopleLoading -> loading.starwars_people()
model.PeopleFulfilled -> people(model.starwars_people)
model.PeopleEmpty -> empty.starwars_people()
model.PeopleError -> error.starwars_people()
},
]),
])
}
fn people(people: People) {
div(
[class("flex flex-wrap justify-center items-start")],
people |> list.map(fn(person) { card.person(person) }),
)
}
// components/card.gleam
import lustre/attribute.{class}
import lustre/element.{type Element}
import lustre/element/html.{div, text}
import data/model.{type Person}
pub fn person(person: Person) -> Element(t) {
div([class("max-w-sm rounded-lg overflow-hidden bg-gray-700 p-6 m-4")], [
div([class("font-bold text-2xl mb-3 text-yellow-500")], [text(person.name)]),
div([class("uppercase font-bold text-gray-300 text-lg")], [
text(person.gender),
]),
])
}
// views/empty.gleam
import lustre/attribute.{class}
import lustre/element.{type Element}
import lustre/element/html.{div, text}
pub fn starwars_people() -> Element(t) {
div([class("text-white text-2xl font-bold")], [text("where's everyone???")])
}
// views/error.gleam
import lustre/attribute.{class}
import lustre/element.{type Element}
import lustre/element/html.{div, text}
pub fn starwars_people() -> Element(t) {
div([class("text-red-700 text-2xl font-bold")], [
text("something went wrong with the people"),
])
}
// views/loading.gleam
import lustre/attribute.{class}
import lustre/element.{type Element}
import lustre/element/html.{div, text}
pub fn starwars_people() -> Element(t) {
div([class("text-white")], [text("loading")])
}
Dan hasilnya:
Benar sekali, tetap sama.
Terakhir, mari kita buat bahan untuk simulasi state! Tambahkan ini di api.gleam
yang literally harmless:
// effects/api.gleam
// dengan gaya (pipeline)
pub fn starwars_people_empty() -> Effect(Msg) {
Ok([])
|> SWAPIPeopleReturned
|> fn(msg) { fn(dispatch) { dispatch(msg) } }
|> effect.from
}
// tanpa gaya
pub fn starwars_people_error() -> Effect(Msg) {
effect.from(fn(dispatch) {
dispatch(SWAPIPeopleReturned(Error(lustre_http.NetworkError)))
})
}
Lalu di update.init
tinggal ganti misal jadi ini:
// update.gleam
pub fn init() -> #(Model, Effect(Msg)) {
pair.new(model.init(), api.starwars_people_empty())
}
Daaan:
Atau untuk yang error nya (sekalian pamer editor yang lain):
Perfect.
Kode final main kita:
import lustre
import lustre/effect.{type Effect}
import lustre/element.{type Element}
import data/model.{type Model}
import data/msg.{type Msg}
import update
import views
// -- MAIN
pub fn main() {
let app = lustre.application(init, update, view)
let assert Ok(_) = lustre.start(app, "#app", Nil)
Nil
}
fn init(_) -> #(Model, Effect(Msg)) {
update.init()
}
// -- UPDATE
fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
case msg {
msg.SWAPIPeopleReturned(Ok(people)) -> update.set_starwars_people(people)
msg.SWAPIPeopleReturned(Error(_)) -> update.set_starwars_people_error()
}
}
// -- VIEW
fn view(model: Model) -> Element(Msg) {
views.starwars_people_view(model)
}
Sangat SoC.
Mengapa Gleam di platform web?
Sebagian besar—jika memang tidak semua—bahasa pemrograman yang menggunakan type system static memiliki goal utama dan yang paling utama: safety. Yang maksudnya, setiap kemungkinan masalah yang akan terjadi di runtime dapat dihindari karena sudah di cek di compile time.
Abaikan dulu candaan lama tentang this
di JavaScript, salah satu "insiden" populer yang sampai tahun ini terjadi di dunia sopwer engineering adalah perihal null. Null menyebabkan yang tidak diketahui tidak diketahui, dan mengakses null pointer adalah tindakan yang tabu, dan memberikan nilai null dengan kesadaran penuh? Sudah pasti butuh seseorang untuk bicara.
Null mungkin berguna sebagai wadah untuk nilai di masa depan nanti, dan nilai di masa depan tersebut pada akhirnya memiliki tipe juga.
Baiklah, sedikit sangkalan: memanggil undefined
sebagai function ataupun mengakses undefined
sebagai objek tidak membuat dunia berhenti. Tidak membuat server kebakaran dan tidak membuat perangkat pengguna meledak. Bagian buruknya, paling membuat aplikasi crash dan user tidak bisa menggunakan. Itupun jika ada user nya. Hehe canda user.
Tidak jarang saya menemukan aplikasi web yang crash yang seringnya karena content blocker di peramban saya. Dan itu tidak normal, karena, apalagi selanjutnya? Karena saya pakai Safari? Karena navigator.language
saya undefined?
Di bahasa pemrograman yang menggunakan static typing, kebanyakan (tidak semua) mereka tidak memiliki konsep null. Karena, yaa null melanggar kontrak dari typing itu sendiri, kan? Beberapa bahasa pemrograman yang masih memiliki konsep null adalah Swift, JAVa, Kotlin, dan TypeScript.
Kemungkinan penggunaan null adalah saat berinteraksi dengan dunia luar, yang kemungkinan besar, null dianggap sebagai "nilai yang tidak bernilai". Misal, 2 hal ini secara teknis melakukan hal yang sama:
let one = 1
one = getUser()
console.log(one.username) // foltebels
let two = null
two = getUser()
console.log(two.username) // foltebels
Penulis kode yang static typing centric pasti berdalih "bang kaga safe itu, gimana klo properti username
ga ada atau si tipe two
adalah Promise
"? Dan itu exactly yang dihindari oleh bahasa pemrograman yang static type checking:
interface User {
username: string
description: any
}
function getUser(): Promise<User> {
return Promise.resolve({
username: "foltebels", description: 3
})
}
let one = null
getUser().then(a => a).then(b => b).then(c => c).then(d => d).then(j => j).then(k => k).then(lol => {
one = lol
})
console.log(one.username) // bro berbalapan
// versi bener (ga ada compiler error, tapi you know)
let one = { username: "", description: 0 }
Kode di atas dijamin safe karena bakal terjadi compiler error karena si one
type nya adalah any
dengan nilai null
bukan User
sebagaimana yang diharapkan oleh return dari getUser
Di bahasa yang lebih strict/strong seperti Gleam, kode of course akan lebih panjang dari yang di atas. Yang pertama, tidak ada konsep Promise
di Gleam tapi ada Result
yang harus return 2 kondisi: sukses dan error. Promise pun menjalankan 2 callback: resolve
dan reject
, di contoh di atas kita hanya pakai resolve
tapi kita masih bisa menggunakan .catch
di promise chain tersebut karena bukan sesuatu yang illegal anyway.
Contoh kode Gleam nya adalah seperti ini:
type User {
User(username: String, description: Int)
}
type UserError {
UnknownError
}
fn get_user() -> Result(User, UserError) {
case Nil == Nil {
True -> Ok(User(username: "foltebels", description: 3))
False -> Error(UnknownError)
}
}
let one = case get_user() {
Ok(user) -> user
Error(_) -> User(username: "", description: 0)
}
io.debug(one)
Mungkin kode di atas curang, tapi struktur data Gleam immutable: tidak ada konsep "re-assign" nilai karena yang ada hanyalah "membuat referensi" baru.
Gleam pada dasarnya memiliki nilai Nil
, dan itu adalah sebuah nilai. Pattern match ekspresi case di atas akan selalu match ke True
karena Nil == Nil
dan faktanya memang begitu.
Oke oke, sekarang kita ke yang praktikal: penggunaan Option. Di banyak kasus—kecuali mungkin berurusan dengan GC, misalnya—sebenarnya ini yang diinginkan oleh penggunaan null di luar sana.
Masalahnya, Option
pun harus diberikan tipe nya. Jika properti description
kondisinya adalah "bisa jadi ada, bisa jadi tidak" berarti tipe nya Option(Int)
, karena "klo ada, sudah pasti Int".
Dan sekali lagi, Option
akan berguna jika berurusan dengan dunia di luar Gleam, dari akses database file atau pemanggilan melalui jaringan. Selainnya, pada dasarnya kita tidak membutuhkan Option
karena besar kemungkinan kita tahu apa yang kita ketik, well, kecuali, mungkin, hmm kodenya tiba-tiba ada di clipboard.
Oke oke, kembali ke topik, harusnya pertanyaannya tidak sebatas "Gleam" di "platform web", subjek Gleam bisa di ganti begitupula dengan kata di platform nya. Dari semua bahasa pemrograman yang sempat saya pelajari dari Reason, ReScript, Clojure dan TypeScript (berkali-kali), yang paling "masuk" dengan saya hanya Gleam. Go dan Rust mungkin termasuk juga, tapi saya tidak yakin perannya sama seperti Gleam.
Dan of course juga karena di Gleam ada Lustre yang memungkinkan untuk membuat aplikasi menggunakan TEA.
Kekurangan
Kurang lebih sama seperti yang ada di tulisan sebelumnya. Dan saya rasa tidak cocok jika Frontend menggunakan Gleam sedangkan Backend nya menggunakan bahasa yang lain kecuali jika memang hanya ingin menggunakan Lustre nya.
Jika ada ingin membuat personal project berjenis web app, sudah pasti akan saya tulis menggunakan Gleam, dan sudah ada yang on-going juga.
Dan menurut opini pribadi saya, jika membandingkan dengan bahasa pemrograman lain, Gleam lebih mudah dipahami dan cukup sederhana. Misal ambil contoh Reason:
- Kapan harus pakai
myMutableNumber^
dan kapan tidak (perihal ref) - Mengapa ada
{js|🌍|js}
dan ada{j|hello, $world|j}
juga? - Mengapa
~
di~labeled_argument=
penting? - Dan masih banyak lagi
Tentu Gleam pun tidak sempurna. Tapi entahlah, belum menemukan sesuatu yang sampai bikin stress selain membuat decoder haha.
Penutup
Tulisan ini sekaligus mengakhiri series Gleam dari saya di tahun ini. Kurang lebih mungkin sama dengan yang part sebelumnya ya.
Nah, waktunya kembali buidl™
Top comments (0)