Introduction to Redux: ไม่ได้ยากอย่างที่คิด (หรอ)

Frontend 22 ม.ค. 2022

Redux เป็น libary ตัวหนึ่งที่ช่วยเราจัดการ states หรือว่าข้อมูลต่างใน application ของเรา แต่ถ้าหากใครเขียน React มาก็จะรู้ว่า เราสามารถจัดการ states ได้ผ่าน useState hook หรือ this.state ใน class component อยู่แล้ว ทำไมเราจำเป็นต้องใช้ libary อันอื่นที่ช่วยจัดการข้อมูลในส่วนนี้ด้วย

การเก็บ states ใน component นั้น มีปัญหาอย่างแรกเลยก็คือ states นั้นจะไม่ถูก share ให้กับ component ถึงเราจะสามารถส่ง states ลงไปใน component ลูกได้ผ่าน props แต่ถ้าเราต้องทำการส่ง states ลงไปสัก 3-4 ชั้น ก็จะทำให้ code ของเรารกไม่ใช่น้อย รวมไปถึงการจะแชร์ states ให้หลายๆ component ที่ไม่ได้อยู่ภายใต้ parent เดียวกัน ก็จะทำได้ยากและวุ่นวาย Redux จึงเกิดมาเพื่อแก้ไขปัญหานี้

Redux ทำการเก็บ states ในรูปแบบ centralize หรือรวมศูนย์กลาง ข้อมูลทุกอย่างจะถูกเก็บไว้ที่ Redux ไม่ใช่เป็นการไปผูกกับ component ไหนเลย คิดง่ายๆ ว่ามันเป็นที่เก็บข้อมูลที่อยู่แยกกับ components ต่างๆของเราก็ได้

Redux Lifecycle

สิ่งที่ทำให้หลายๆคนรู้สึกยากกับ Redux ก็คือหลักการทำงานของมันนั้นเอง การจะกำหนด states ใหม่ให้กับ Redux นั้น ไม่สามารถทำได้แบบตรงๆ และการดึงเอา states ออกมาใช้ ก็ไม่ได้ง่ายเช่นเดียวกัน ในบทความนี้ผมจะพูดถึง concept และการใช้งาน Redux แบบง่ายที่สุดก่อน โดยยังไม่นำไปใช้คู่กับ React

การเปลี่ยนแปลง states ที่เก็บไว้ใน Redux จะเริ่มจากการที่ Action ถูกสร้างขึ้นมา ส่วนใหญ่แล้วจะเกิดจากการที่ผู้ใช้คลิกอะไรสักอย่างใน UI อย่างเช่นกดปุ่ม Login หรือ Logout โดย Action จะถูกdispatch หรือส่งต่อไปให้กับ Reducer ทุกๆตัว โดยตัว Reducer จะทำหน้าที่เปลี่ยนแปลง states ใน Redux ดังนั้นใน Reducer จึงมี logic ที่เอาไว้สำหรับประมวลผล Action ที่เข้ามาและทำการเปลี่ยน states นั้นเอง

Action and Action Creator

Action คือ JavaScript Object ธรรมดาที่มี key อย่างน้อยหนึ่ง key ซึ่งก็คือ type เพื่อบ่งบอกว่า Action อันนี้มี type เป็นอะไร โดยนอกจาก type แล้ว เรายังสามารถเพิ่ม key อื่นๆ เข้าไปได้ด้วยถ้าหากเราต้องการใช้ข้อมูลนั้น โดยส่วนใหญ่แล้วจะเก็บใน key ชื่อ payload

// This is an action
{
    type: "SAMPLE_ACTION" // Can be any string
    payload: "Some userful information" // Can by anything
}

// This is also an action
{
    type: "ACTION_WITH_NO_PAYLOAD"
}
Example of Action

ถึงแม้ว่าเราจะสามารถสร้าง Action ขึ้นมาได้จากการสร้าง object ธรรมดา วิธีนี้ไม่ใช่วิธีที่ควรทำนัก เนื่องจากเราอาจจะต้องมีการสร้าง Action ตัวนี้ในหลายๆ ที่ ดังนั้นเราควรจะสร้างฟังก์ชั่น Action Creator เพื่อช่วยสร้าง Action ขึ้นมาใช้แทน เพื่อป้องกับ typo ที่อาจจะเกิดขึ้นได้

const deposit = (amount) => {
    return {
        type: "DEPOSIT",
        payload: amount
    }
}

const withdraw = (amount) => {
    return {
        type: "WITHDRAW",
        payload: amount
    }
}

const userLogin = (userID) => {
    return {
        type: "USER_LOGIN",
        payload: userID
    }
}

const userLogout = () => {
    return {
        type: "USER_LOGOUT"
    }
}
Action Creator

Code ด้านบนนี้เป็นตัวอย่างของฟังก์ชั่น Action Creator ซึ่งท้ายที่สุดแล้วจะ return object ที่มี key type ออกมา ซึ่งเราเรียกมันว่า Action โดยในตัวอย่างนี้จะเป็นการเก็บ states ของ userID ในกรณีที่ผู้ใช้เข้าสู่ระบบอยู่ กับ balance ของผู้ใช้ โดยจะมีการทำงาน 2 อย่างก็คือ deposit และ withdraw

Dispatch

หลังจากการเรียกฟังก์ชั่น Action Creator และเราได้ Action ออกมาแล้ว เราจะทำการส่ง Action ต่อไปภายใน Redux ด้วยการเรียกฟังก์ชั่น dispatch() ทำให้ Action ของเราถูกส่งไปยัง Reducer ต่อไป

Reducer

Action ทุกตัวที่ถูก dispatch จะถูกส่งเข้ามาที่ Reducer โดย Reducer ก็เป็น function ที่รับ parameter 2 ตัวคือ

  1. State ปัจจุบัน
  2. Action ที่ได้รับมา

โดยเมื่อสิ้นสุด function จะต้องทำการ return state ใหม่ออกไป ซึ่งจะเป็นอันเดิมก็ได้ หรือว่าจะเป็นอันใหม่ก็ได้ โดยถ้าเป็นอันใหม่ จะส่งผลให้ component ที่ใข้งาน state นั้นทำการ re-render ใหม่ด้วย

คำว่า state ใหม่นั้นหมายถึงค่า หรือ pointer ค่าใหม่ ถ้าหากเราแค่เพิ่ม key value ลงไปใน object แล้ว return ออกไป pointer ของ object นั้นก็จะไม่ถูกเปลี่ยน ดังนั้น Redux จะมองว่าค่า state นั้นไม่ได้มีการเปลี่ยนแปลง ส่งผลให้ component ที่ใช้งาน state นั้นไม่ทำการ re-render
const balanceReducer = (balance = 0, action) => {
    switch (action.type) {
        case "DEPOSIT":
            return balance += action.payload
        case "WITHDRAW":
            return balance -= action.payload
        default:
            return balance
    }
}

const authReducer = (userID = null, action) => {
    switch (action.type) {
        case "USER_LOGIN":
            return action.payload
        case "USER_LOGOUT":
            return null
        default:
            return userID
    }
}

จากตัวอย่าง code ด้านบน จะเห็นได้ว่า Reducer ตัวนี้สนใจเฉพาะ Action ที่มี type เป็น DEPOSIT, WITHDRAW และ RESET_BALANCE เท่านั้น ถ้าหากเป็น  type อื่น ก็จะ return balance ค่าเดิมออกไป ซึ่งก็คือไม่มีการเปลี่ยนแปลงอะไรนั้นเอง

เราควรจะใส่ค่า default ให้กับ parameter ตัวแรก (State ปัจจุบัน) เนื่องจากในการรัน Reducer ครั้งแรก ค่าที่เข้ามาอาจจะเป็น null และจะทำให้เกิด error ได้

ซึ่งในส่วนของ Reducer นี่แหละ ที่จะมี logic ในการจัดการกับ Action ที่เข้ามายัง Reducer ให้นึกภาพว่า Action ที่ถูก dispatch นั้นเป็นเหมือนกับ HTTP Request ที่เข้ามายัง Backend server ของเรา และส่วนของ Reducer ก็จะทำหน้าที่เหมือนกับ Business Logic ท่ีเราเขียนไว้ใน Backend เพื่อประมวลผล request ที่เข้ามา

States initialization

เมื่อ Redux เริ่มทำงาน เราต้องประกาศว่าเราต้องการใช้ Reducer อะไรบ้างใน application ของเรา โดยสามารถทำได้จากฟังก์ชั่น combineReducers() โดยจะต้องใส่ object เป็น parameter ที่มี key คือชื่อ state กับ value เป็น reducer ที่เราต้องการ

const reducers = combineReducers({
    balance: balanceReducer,
    userID: authReducer
})

หลักจากเรารวม Reducer ทั้งหมดของเราเข้าด้วยกัน ขั้นตอนสุดท้ายคือการสร้าง store สำหรับเก็บ state ของเราจาก reducer ที่เราเพิ่งรวมไปเมื่อกี้นี้ผ่านฟังก์ชั่น createStore()

const store = createStore(reducers)

เพียงเท่านี้ Redux ก็พร้อมที่จะทำงานแล้ว

Let's try it

เมื่อมีเหตุการณ์ให้เราต้องการเปลี่ยน states ที่เก็บเอาไว้ใน Redux สิ่งแรกที่เราต้องทำก็คือการสร้าง Action ที่เราต้องการขึ้นมาก่อน ยกตัวอย่างเช่นถ้าหากผู้ใช้ทำการฝากเงิน 500 บาท ผมก็จะสามารถสร้าง Action จาก Action Creator และทำการ dispatch ออกไป

const depositAction = deposit(500)
store.dispatch(depositAction)

หรือเราก็สามารถรวบมาอยู่ใน statement เดียวได้ดังนี้

store.dispatch(deposit(500))

เมื่อ Action ถูก dispatch แล้ว Action ก็จะถูกส่งไปให้กับ Reducer ทุกตัว ถ้าหาก Reducer ตัวไหนสนใจใน Action นั้นก็สามารถประมวลผล ตามที่ code ได้เขียวไว้และ return states ใหม่ออกมาได้

เราสามารถดู states ใน Redux ได้ผ่าน store.getState() ซึ่งหลังจากการทำการฝากเงินไป Redux ของเราก็จะมี State เป็น

{
    balance: 500,
    userID: null
}
Note: ถ้าหากเราเรียก store.getState() ก่อนที่จะ dispatch Action เราก็จะได้ค่า Default value ที่เราใส่เอาไว้ใน parameter ตัวแรกของ Reducer ที่เราเขียนเอาไว้ ในกรณีนี้ก็คือ 0 กับ null

ถ้าหากเราลองเปลี่ยนแปลง states หลายๆ รอบตามนี้

store.dispatch(userLogin(5))
store.dispatch(deposit(100))
store.dispatch(withdraw(50))
store.dispatch(userLogout())
store.dispatch(withdraw(250))
store.dispatch(userLogin(8))

ผลลัพท์สุดท้ายก็จะได้เป็น

{
    balance: 300,
    userID: 8
}

เพียงเท่านี้เราก็สามารถจะเปลี่ยนแปลง States ใน Redux ได้แล้ว

The Hardest Part

สิ่งที่ยากในการใช้ Redux ไม่ใช่เราเข้าใจ concept และการทำงานของมัน (ซึ่งจริงๆ ก็เข้าใจยากแหละ) แต่คือการนำเอาไปปรับใช้ การออกแบบว่า Application ของเราจะต้องมี Action และ Reducer อะไรบ้างในการทำงานนี่แหละ เป็นสิ่งที่ผมว่ายากที่สุดของ Redux ส่วนหนึ่งก็เป็นเพราะ Process ของมันที่ไม่ค่อยตรงกับ libary ที่ใช้จัดการ states อื่นๆ สักเท่าไหร

Code ตัวอย่างที่ผมเขียนสามารถดูจากได้ที่ https://aka.cscms.me/basic-redux-demo

แท็ก

Sethanant Pipatpakorn

Innovation Engineer @ KLabs, KBTG CS20 SKN36