Manajemen State dengan Redux (tanpa React)

Redux adalah library untuk manajemen state aplikasi JavaScript. Library ini memang lebih sering dipake bareng React, tapi sebenernya bersifat framework agnostic. Jadi bisa dipake dengan atau tanpa framework. Mau pake VueJS, bisa. Pake Angular, ok. Pake Pure JS, boleh.

Dalam tutorial ini saya akan bahas contoh pemakaian Redux dalam aplikasi ES6.

Tapi sebelumnya, apa itu State?

Secara harfiah, state artinya kondisi/keadaan. Jadi yang dimaksud Application State kurang lebih adalah semua informasi/data yang bisa menggambarkan kondisi aplikasi pada suatu waktu.

Redux

Implementasi Redux bisa digambarin pake diagram berikut:

Jadi ada 4 aktor utama di dalam sistem Redux:

  1. Action
  2. Store
  3. Reducer
  4. State

App/UI membuat objek Action dan mengirimkannya ke Store. Oleh Store, objek Action diteruskan ke Reducer yang bertugas melakukan update terhadap State. Kalo ada pembaruan State maka Store mengirim objek State yang baru ke semua bagian App yang jadi subscriber / listener.

Action

Action adalah objek sederhana yang wajib punya properti bernama type & bertipe string. Action boleh berisi data lain yang mungkin diperlukan untuk update state, tapi yang paling pokok adalah type.

const add = {
  type:'ADD',
  value: 100
}

const start = {
  type: 'START'
}

Reducer

Reducer adalah sebuah function yang bertugas memproses Action dan bikin State baru. Reducer punya dua parameter state & action.

const reducer = ( state = {}, action) =>{
  if(action.type === ....) {
    // blah blah
    return newstate;
  } else if(action.type === ....) {
    // blah blah blah
    return otherstate
  }

  return state;
};

Syarat Reducer adalah dia harus berupa pure function. Apa itu ?

Pure Function (PF) adalah function yang:

  1. Selalu memberi nilai balik yang sama selama argumennya sama.
  2. Nggak mengubah (mutate) objek atau variabel lain
  3. Nggak tergantung/terpengaruh objek atau variabel lain

Contoh yang bukan PF:

let ammo = 100;
const shoot = () =>{
  //ngerubah variabel di luar fn
  return ammo--;
}

const canShoot = ( round ) =>{
  //tergantung variabel di luar fn
  if( round >= ammo){    
    return true;
  }
  return false
}

Store

Store adalah objek yang menghubungkan Action & Reducer. Pada intinya, objek ini bertugas:

  1. Menyimpan State
  2. Menyediakan API untuk mengakses State
  3. Menyediakan API untuk update State
  4. Menyediakan API biar objek lain bisa jadi listener / subscriber
  5. Menyediakan API untuk melepas listener / subscriber.

Satu aplikasi hanya boleh punya satu Store. Tapi satu Store bisa punya banyak Reducer. Jadi kalo kita ingin memecah kode yang bertugas menghandel data ke dalam beberapa modul, kita bisa bikin beberapa Reducer & nanti digabung dalam satu Store. Misalnya kayak di bawah ini:

Contoh Aplikasi: Video Player

Redux didistribusikan sebagai modul NPM. Jadi kita instal dulu:

$ yarn init -y && yarn add redux -E -S

Selain Redux, kita pake Poi untuk transpile ES6 & jalanin server lokal.

Template HTML

<!-- file: index.ejs -->
<h1>My Video Player</h1>  
<div id="app">  
  <video id="myvideo"></video>
  <div id="controlbar">
    <div>
      <input type="text" name="video-source" value="" placeholder="video url">
      <button id="load-src-btn">Load</button>
    </div>
    <div>
      <button id="play-pause-btn">Play</button>      
      <button id="vol-up-btn">Vol+</button>
      <button id="vol-down-btn">Vol-</button>
      <div id="time">
        <span>Time: </span>
        <input type="text" name="time" value="0">
        <span>/</span>
        <span id="duration">00</span>
      </div>
    </div>
  </div>
</div>

Stylesheet

Sebenernya nggak penting sih. Tapi ya biar app-nya nggak jelek-jelek amat.

*{
  margin:0;padding:0;
}

body{
  font-size: 1.2em;
  line-height: 1.5em;
  padding:10px;
}

h1{
  margin: 1em 0;
}

video{  
  width:640px;
  height:360px;
  position: relative;    
  background: #66CCFF;
}

#controlbar{

  line-height: 2em;
  font-size:14px;

  input[type=text]{
    width:90%;    
    max-width:640px;
    padding: 5px;
    font-size:100%;
  }

  input[name=time]{
    width:4em;
  }
  button{    
    font-size:0.8em;
    padding:5px; 
  }

  :last-child{
    margin-top:10px;
  }

  #time{
    display:inline-block;    
    text-align: center;        
  }
}

Terus bikin file entry buat appnya sendiri:

//file: src/index.js
import './styles.scss'

Nyalain Poi:

$ poi src/index.js

Terus buka localhost:4000.

Lanjut ke “kodingannya” ( bahasa programmer jaman NOW ).

State, Reducer & Store Minimalistik

Pertama kita bikin object State-nya. Sederhana aja,

//file: src/index.js
import './styles.scss'

const defaultVideoState = {
  source: undefined,
  status: undefined,
  volume: 1,
  duration: 0,
  time: 0
};

Lanjut bikin Reducer, masih di file src/index.js.

const videoStateReducer = (state = defaultVideoState, action) => {
  return state;
};

Terus bikin Store & subscriber:

//import dulu 
import { createStore } from 'redux';

const videoStore = createStore( videoStateReducer );
//ekspos ke global NS biar bisa dipanggil di konsol
window.videoStore = videoStore;

//tambah subscriber buat ngetes
videoStore.subscribe(()=>{
  console.log('VideoStore:current state', videoStore.getState());
})

Sekarang kita tes dulu koneksi antara Store, Reducer & subscribernya. Buka konsol & coba kirim Action ke videoStore:

window.videoStore.dispatch({ type:'HELLO' });

Jadi dari hasil testing di atas, bisa diliat prosesnya:

  1. videoStore menerima Action berupa objek { type: 'HELLO'}
  2. Action diterusin ke videoStateReducer
  3. videoStore jalanin listener/subscriber

Action

Video player ini butuh 6 action (kemungkinan nanti nambah):

  1. SET_SOURCE
  2. PLAY
  3. PAUSE
  4. SEEK
  5. VOLUME_UP
  6. VOLUME_DOWN

Kalo setiap kali butuh Action kita harus bikin objek secara manual pasti repot & resiko error ( typo dsb). Jadi kita bikin yang namanya Action Generator/ Action Factory.

//file: src/actions.js
//konstanta buat Action type biar nggak salah ketik
export const SET_SOURCE = 'SET_SOURCE';
export const PLAY = 'PLAY';
export const PAUSE = 'PAUSE'
export const SEEK = 'SEEK';
export const VOLUME_UP = 'VOLUME_UP';
export const VOLUME_DOWN = 'VOLUME_DOWN';

export const setSource = (url) =>({
  type:SET_SOURCE,
  url
});

export const play = () =>({
  type:PLAY
});

export const pause = () =>({
  type:PAUSE
});

export const seek = (time) =>({
  type:SEEK,
  time
});

export const volumeUp = () =>({
  type:VOLUME_UP
});

export const volumeDown = () =>({
  type:VOLUME_DOWN
});

Set-Source

Di file src/index.js, kita update videoStateReducer untuk handel action SET_SOURCE.

//file: src/index.js
//import dulu semuanya
import * as Actions from './actions';

const videoStateReducer = (state = defaultVideoState, action) => {
  console.log('VideoStateReducer','state',state,'action', action);
  switch(action.type){
    case Actions.SET_SOURCE:
      return {
        ...state,
        source: action.url
      }
  }
  return state;
};

Di baris paling bawah sendiri, setelah subscribe, kita coba dispatch action SET_SOURCE.

videoStore.subscribe((state)=>{
  console.log('VideoStore:current state', videoStore.getState());
});

//DISPATCH ACTION
videoStore.dispatch(Actions.setSource('http://google.com'));

Berikutnya, bikin file src/video_wrapper.js untuk logic playernya :

//file: src/video_wrapper.js
const VideoWrapper = (store) => {
  const videoEl = document.getElementById('myvideo');

  //subscribe
  store.subscribe(( )=>{
    let state = store.getState();
    if(state.status === 'new_source'){
      videoEl.src = state.source;
    } 
  });
}

export default VideoWrapper;

Dan edit dikit Reducer-nya, tambahin status:'new_source'

//file: src/index.js

const videoStateReducer = (state = defaultVideoState, action) => {
  console.log('VideoStateReducer','state',state,'action', action);
  switch(action.type){
    case Actions.SET_SOURCE:
      return {
        ...state,
        status:'new_source', //BARIS BARU       
        source: action.url
      }
  }
  return state;
};

Inisialisasi VideoWrapper & coba dispatch action pake URL video:

import VideoWrapper from './video_wrapper';

//dst

//init videowrapper
VideoWrapper(videoStore);

//coba
videoStore.dispatch(Actions.setSource('//vjs.zencdn.net/v/oceans.mp4'));

Karena statusnya nanti variatif, biar lebih aman kita bikin konstanta buat status:

//file: status.js
export const NEW_SOURCE = 'new_source';
export const PLAY_REQUESTED = 'play_requested';
export const PLAYING = 'playing';
export const PAUSE_REQUESTED = 'pause_requested';
export const PAUSED = 'paused';

Update src/index.js & src/video_wrapper.js, pake konstan di atas:

//file: src/index.js
import * as Status from './status';

//dsb

//di videoStateReducer():
switch(action.type){
  case Actions.SET_SOURCE:
    return {
      ...state,
      status: Status.NEW_SOURCE, //pake konstanta
      source: action.url
    }
}

//dsb
//file: src/video_wrapper.js
import * as Status from './status';

//dsb

  store.subscribe(( )=>{
    const state = store.getState();
    if(state.status === Status.NEW_SOURCE){
      videoEl.src = state.source;
    } 
  });

//dsb

Input UI

Bikin file src/ui/source-input.js.

//file: src/ui/source-input.js
import { setSource } from '../actions';

const SourceInput = (store)=>{

  const input = document.querySelector('input[name=video-source]');
  //default video
  input.setAttribute('value','//vjs.zencdn.net/v/oceans.mp4');

  const btn = document.getElementById('load-src-btn');
  btn.addEventListener('click', (e)=>{
    e.preventDefault();
    //kalo input nggak kosong,
    //dispatch action SET_SOURCE
    if(input.value){
      store.dispatch(setSource(input.value));
    }
  })
}

export default SourceInput;

Balik ke src/index.js:

//file: src/index.js

//import dulu
import SourceInput from './ui/source-input';

//dsb

//scroll ke bawah ...

//init videowrapper
VideoWrapper(videoStore);

//init SourceInput
SourceInput(videoStore);

//komen atau hapus baris ini
//videoStore.dispatch(Actions.setSource('//vjs.zencdn.net/v/oceans.mp4'));

Play & Pause

Sekarang bikin logic utk play & pause video. Mulai dari reducer.

//file: src/index.js
const videoStateReducer = (state = defaultVideoState, action) => {
  console.log('VideoStateReducer','state',state,'action', action);
  switch(action.type){
    case Actions.SET_SOURCE:
      return {
        ...state,
        status: Status.NEW_SOURCE,
        source: action.url
      }      
    case Actions.PLAY: // handel action PLAY di sini
      return {
        ...state,
        //update state.status
        status: Status.PLAY_REQUESTED
      }
    case Actions.PAUSE: //handel action PAUSE
      return{
        ...state,
        status: Status.PAUSE_REQUESTED
      }
  }
  return state;
};

Update src/video_wrapper.js, tambahin blok else-if untuk handel PLAY_REQUESTED.

store.subscribe(() => {
    const state = store.getState();
    if (state.status === Status.NEW_SOURCE) {
      videoEl.src = state.source;

    } else if (state.status === Status.PLAY_REQUESTED) {
      //blok if baru utk handle status PLAY_REQUESTED
      if (videoEl.src) {
        //kalo source udah diset, maenin videonya
        videoEl.play();
      }
    } else if(state.status === Status.PAUSE_REQUESTED){
      if(videoEl.src) {
        //pause videonya
        videoEl.pause();
      }
    }

  });

Play – Pause UI

Lanjutin bikin UI. Tombol Play bukan cuma untuk play, tapi juga dipake untuk pause. Jadi kita bikin file src/ui/playpause-btn.js.

//file: src/ui/playpause-btn.js

import * as Status from '../status';
import { play, pause } from '../actions';

const PlayPauseBtn = (store) => {

  const btn = document.getElementById('play-pause-btn');
  btn.addEventListener('click', (e) => {
    e.preventDefault();
    const status = store.getState().status;
    if (status === Status.PLAYING) {
      store.dispatch(pause());
    } else {
      store.dispatch(play());
    }
  })

  //update label
  store.subscribe(() => {
    const state = store.getState();
    if (state.status === Status.PLAYING) {
      btn.innerHTML = 'Pause';
    } else if(state.status === Status.PAUSED) {
      btn.innerHTML = 'Play';
    }
  })
};
export default PlayPauseBtn;

Ternyata kita belum punya action untuk update video state ( PLAYING & PAUSE ). Jadi bikin dulu generatornya:

//file: src/actions.js
export const VIDEO_STATUS_CHANGED = 'VIDEO_STATUS_CHANGED'

//...scroll ke bawah

//bikin action generator
export const videoStatusChanged = ( status )=>({
  type: VIDEO_STATUS_CHANGED,
  status
})

Nanti yang dispatch action ini adalah VideoWrapper karena dia yang punya elemen <video>, jadi kita update dulu:

//file: src/video_wrapper.js

import * as Status from './status';
//import action yg baru
import { videoStatusChanged } from './actions';

const VideoWrapper = (store) => {
  const videoEl = document.getElementById('myvideo');
  //pasang listener buat 'playing' event
  videoEl.addEventListener('playing', (e) => {
    store.dispatch(videoStatusChanged(Status.PLAYING));
  });
  //pasang listener buat 'pause' event  
  videoEl.addEventListener('pause', (e) => {
    store.dispatch(videoStatusChanged(Status.PAUSED));
  });

  // ...dsb

  });

}

export default VideoWrapper; 

Time Label

Untuk update time-label ( yang nunjukin waktu sekarang & durasi video), kita bikin dulu actionnya.

//file: src/actions.js

//...dsb
export const UPDATE_TIME = 'UPDATE_TIME';

//... dsb
//scroll ke bawah

export const updateTime = ({time,duration})=>({
  type: UPDATE_TIME,
  time,
  duration
});

Terus update reducer.

const videoStateReducer = (state = defaultVideoState, action) => {
  // ...dsb
  switch(action.type){
    //...dsb
    //bikin case baru buat action UPDATE_TIME
    case Actions.UPDATE_TIME:
      return {
        ...state,
        time: action.time,
        duration: action.duration
      }
  }
  return state;
};

Sekarang bikin modul UI nya.

//file: src/ui/time-ui.js
import * as Status from '../status';

const TimeUI = (store)=>{

  const timeInput = document.querySelector('input[name=time]');
  const duration = document.getElementById('duration');

  store.subscribe(()=>{    
    const state = store.getState();
    //update time & duration
    timeInput.value = state.time;
    duration.innerHTML = state.duration;
  })
};

export default TimeUI;

Hasilnya:

Seek / Scrub

Kenapa time label saya bikin pake <input>? Karena mau saya pake untuk seeking/scrubbing video.

Kayak yg sebelumnya, bikin reducernya dulu:

//file: src/index.js
const videoStateReducer = (state = defaultVideoState, action) => {

  switch(action.type){
    //... dsb
    case Actions.SEEK:
      return{
        ...state,
        status: Status.SEEK_REQUESTED,
        time: action.time
      }
  }
  return state;
};

Bikin juga konstanta buat status nya:

//file: src/status.js
//...dsb
export const SEEK_REQUESTED = 'seek_requested';

Kembali ke modul TimeUI. Kita pake <input> untuk seeking. Jadi kita ketikin mau forward/rewind ke detik ke berapa. Pas input-nya nggak fokus ( focusout) , modul ini kirim action SEEK_REQUESTED.

//file: src/ui/time-ui.js
import * as Status from '../status';
import { seek } from '../actions';

const TimeUI = (store) => {

  //----- HANDEL FOCUS IN & OUT -----//

  //buat simpan value yang lama sebelum fokus 
  let valueBeforeFocused = undefined;

  const timeInput = document.querySelector('input[name=time]');
  timeInput.addEventListener('focusin', () => {
    //nilai yang lama sebelum focus
    valueBeforeFocused = timeInput.value;
  });

  timeInput.addEventListener('focusout', () => {
    //kalo value yang baru nggak sama dengan yang lama
    if (timeInput.value != valueBeforeFocused) {
      //dispatch seek action
      store.dispatch(seek(timeInput.value));
    }
  });
  const duration = document.getElementById('duration');

  //... dsb

};

export default TimeUI;

Karena value diupdate secara otomatis selama video berjalan, kita perlu stop update kalo inputnya lagi fokus (focusin). Kita bikin flag isFocused yang nilainya tergantung status focus elemen <input>. Jadi kita update beberapa baris:

//file: src/ui/time-ui.js
import * as Status from '../status';
import { seek } from '../actions';

const TimeUI = (store) => {

  let isFocused = false;

  timeInput.addEventListener('focusin', () => {
    //set isFocused 
    isFocused = true;
    //..dsb
  });

  timeInput.addEventListener('focusout', () => {
    //reset isFocused 
    isFocused = false;
    //...dsb
  });

  //...dsb

  store.subscribe(() => {
    const state = store.getState();
    //update input value kalo 
    //lagi nggak fokus
    if (!isFocused) {
      timeInput.value = state.time;
    }
    duration.innerHTML = state.duration;
  })
};

export default TimeUI;

Hasilnya begini.

Penutup

Saya rasa udah cukup panjang tutorialnya. Tapi ada fitur yang belum kita kerjain, Vol+ & Vol-.

Silakan kerjain sendiri buat latihan 🙂 Kalo macet, ada yg nggak ngerti silaken tanya.

Source code bisa diunduh/checkout di Gitlab: ES6-Redux-Sample

Also in this category ...


Leave a Reply

Your email address will not be published. Required fields are marked *