Babel Coder

[Day #3] จัดการ data flow ใน React.js อย่างมีประสิทธิภาพด้วย Redux

intermediate

 บทความนี้เป็นส่วนหนึ่งของชุดบทความ [ชุดบทความ] สอนสร้าง Isomorphic Application ด้วย React.js และ Redux ใน 5 วัน

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

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

บทความนี้ไม่เหมาะกับทุกคนครับ เพื่อนๆต้องผ่านการอ่านขั้นตอนปัญหาและวิธีการแก้ปัญหาที่อาจยาวกว่าบทความอื่น เพื่อนๆที่ต้องการรู้และเข้าใจทั้งหมดในไม่กี่บรรทัดอาจต้องพิจารณาบทความอื่นนะครับ ถึงอย่างไรผมก็ยังอยากให้เพื่อนๆลองอ่านดูก่อน ติดขัดอย่างไรถามได้ครับ

เพื่อให้การอ่านบทความนี้ลื่นไหลราวใส่จาระบี ผมแนะนำให้เพื่อนๆอ่านบทความต่อไปนี้ก่อน เพื่อเสริมสร้างความเข้าใจให้แข็งแกร่งมากขึ้น

สารบัญ

แบบปฏิบัติที่ดีในโลกของ React

ก่อนพูดถึง Redux เรามาทบทวน React กันซักนิดครับ มีสิ่งหนึ่งที่ผมอยากเน้นมากเป็นพิเศษ และถือว่าเป็นแบบปฏิบัติอันดีงามกันเลยทีเดียว ขีดเส้นใต้ตรงนี้ห้าเส้นไปเลยครับ ใครใช้ React แล้วไม่ปฏิบัติตามนี้ถือว่าบาป ได้ตามไปใช้กรรมยัน production แน่นอน!

ตามแนวคิดของ Presentational Component และ Container Component ที่ผมได้กล่าวอย่างยืดยาวไปแล้วนั้นแสดงให้เห็นถึงการแบ่งคอมโพแนนท์ตามภาระหน้าที่ Container Component นั้นฉลาดและรู้ว่าเมื่อมีเหตุการณ์อย่างหนึ่งอย่างใดเกิดขึ้น ควรจะตอบสนองต่อเหตุการณ์นั้นอย่างไร ส่วน Presentational Component นั้นเป็นเพียงคอมโพแนนท์ที่รักงานศิลปะ รักการแสดงผลอย่างเดียว

เพื่อให้คอมโพแนนท์ทั้งสองประเภทดูมีสายการบังคับบัญชาเฉกเช่นทหาร จึงเกิดคอนเซ็ปต์ที่ว่า action up, data down จริงๆผมไม่แน่ใจว่า React มีคำเรียกวิธีการนี้ที่เฉพาะกว่านี้หรือไม่ แต่คำนี้เกิดขึ้นเป็นคอนเซ็ปต์หลักของ Ember2 ที่ล้อมาจากวิธีการจัดการการไหลของข้อมูลด้วย React

วิธีการจัดการเหตุการณ์จะกำหนดไว้ใน Container Component เมื่อมีเหตุการณ์อะไรก็ตามเกิดขึ้น Presentational Component ที่ถือเป็น subcomponent หรือคอมโพแนนท์ลูกของ Container จะโยนเหตุการณ์นั้นขึ้นไปเรื่อยๆ จนกระทั่งถึง Container Component ที่อยู่บนสุด เพื่อให้คอมโพแนนท์ที่ชาญฉลาดตัวนี้จัดการกับเหตุกาณ์นั้น อย่าลืมนะครับมีเพียง Container Component เท่านั้นที่รู้ว่าจะจัดการเหตุการณ์อย่างไร

กลับมาดูที่วิกิของเรากัน components/Pages/Index.js เป็น Presentational Component มีหน้าที่แสดงผลอย่างเดียว ในตัวคอมโพแนนท์นี้มีลิงก์ที่มีข้อความว่า Reload Pages ให้ผู้ใช้งานกดเพื่อจะโหลดวิกิทั้งหมดจากเซิร์ฟเวอร์มาแสดงผลใหม่ แต่เนื่องจากคอมโพแนนท์นี้ไม่มีหน้าที่จัดการเหตุการณ์ เมื่อเรากดลิงก์แล้วมันจึงต้องโยนเหตุการณ์นี้ขึ้นไปให้ Container Component จัดการ เราเรียกวิธีการทำเช่นนี้ว่า action up

action up

หลังจากที่โหลดวิกิมาใหม่แล้ว Container Component ต้องส่งข้อมูลนี้กลับลงไปให้ Presentational Component เพื่อให้คอมโพแนนท์นี้แสดงผลข้อมูลออกมา ที่ต้องทำเช่นนี้เพราะ Presentational Component นั้นไม่รู้วิธีจัดการเหตุการณ์ จึงไม่สามารถไปคุ้ยข้อมูลวิกิเพื่อนำข้อมูลมาแสดงผลได้ด้วยตนเอง นี่หละครับที่เราเรียกกันว่า data down

ข้อสรุปจาก[Day #2] สอนการใช้งาน React.js และการเรียกใช้งาน RESTful API ด้วย Reactอยู่ในย่อหน้านี้ครับ นั่นคือการจัดการข้อมูลของ React ต้องเป็น one-way data flow หรือข้อมูลตลอดทั้งแอพพิลเคชั่นต้องวิ่งไปในทิศทางเดียว เมื่อข้อมูลตลอดทั้งแอพพลิเคชั่นวิ่งวนไปในทิศทางเดียวแล้ว เราจะพยากรณ์สิ่งที่จะเกิดขึ้นเมื่อเหตุการณ์หนึ่งๆเกิดขึ้นได้

one-way data flow

เพื่อนๆบางคนอาจเกิดข้อสงสัย ข้อมูลไหลไปทิศทางเดียวมันช่วยให้เราพยากรณ์สิ่งที่จะเกิดขึ้นในแอพพลิเคชันของเราได้อย่างไร? ลองจินตนาการตามผมนะครับ ถ้าเราอยู่ที่หน้า Index ของวิกิและกำลังจะกดปุ่ม Reload Pages เราจะรู้ทันทีว่าคอมโพแนนท์ /containers/Pages/Index เท่านั้นที่จะเป็นคนโหลดข้อมูลวิกิและเป็นผู้ถือครองข้อมูลทั้งหมดก่อนส่งไปให้คอมโพแนนท์อื่นๆ เมื่อข้อมูลไหลไปทางเดียวเราจะคาดเดาได้ทันทีว่าเมื่อเหตุการณ์นั้นๆเกิดขึ้น ข้อมูลของเราจะมาจากไหนและใครเป็นคนจัดการเหตุการณ์นั้น

เอาหละครับนี่คือข้อสรุปทั้งหมดจากบทความที่แล้ว เพื่อนๆคนไหนที่เข้าใจอย่างท่องแท้ คาดหวังเพียงแค่ประยุกต์ใช้ React กับงานที่ทำอยู่ ไม่ได้วางแผนจะทำอะไรใหญ่โตให้เวอร์วังอลังการ ผมขอแนะนำให้จบเพียงเท่านี้แล้วปิดบทความได้เลยครับ เพียงแค่คุณจัดการข้อมูลไปในทิศทางเดียวได้ตามที่กล่าวมานี้ ตายไปคุณได้ขึ่้นสวรรค์ชั้น7แล้วครับ หากอยากไปให้ถึงพรหมโลกแนะนำให้อ่านบทความนี้ต่อให้จบ แต่ถ้าอยากบรรลุนิพพานต้องอ่านบทความให้จบทั้งซีรีย์ครับ!

** หมายเหตุ: ในทางพุทธศาสนาสวรรค์มีแค่6ชั้นนะเออ

ทบทวน Hot Module Replacement กันอีกครั้ง

เพื่อนๆครับลองเปิดดูไฟล์ package.json กันอีกครั้งครับ ทุกคนเห็นเหมือนกันเนอะครับว่าเรารัน webpack-dev-server ด้วยคำสั่งนี้ webpack-dev-server --hot --inline เราบอก webpack-dev-server ว่าถ้า loader ของเราสนับสนุน HMR ก็ให้ webpack ช่วยโหลดหน้าเพจของเราแค่ส่วนที่เปลี่ยนแปลง แต่ถ้าไม่สนับสนุนก็ reload ทั้งเพจซะเลย (เพื่อนๆที่อ่านแล้วงง รบกวนอ่าน[Day #1] แนะนำ Webpack2 และการใช้งานร่วมกับ Reactอีกรอบนะครับ ^^)

ตอนนี้เราอาจไม่สังเกตครับ ทุกครั้งที่เราแก้โค๊ดวิกิ หน้าเพจเราจะกระพริบซึ่งก้คือ refresh หน้าเพจใหม่ทั้งหมดทุกครั้ง! ที่เป็นเช่นนี้เพราะเราใช้ babel เป็น loader สำหรับไฟล์ js และ jsx แต่ babel ไม่ได้นิยามไว้ครับว่าหากโค๊ด React ของเรามีการเปลี่ยนแปลง จะให้ทำ HMR อย่างไร นั่นละฮะเมื่อไม่ฉลาดพอจะ HMR มันจึงโหลดเพจใหม่หมดซะเลย

พฤติกรรมแบบนี้ไม่น่าหนักใจสำหรับแอพพลิเคชันขนาดเล็กอย่างวิกิของเรา แต่สำหรับแอพพลิเคชันขนาดใหญ่แล้วมันเป็นเรื่องน่าปวดหัวมากครับ ทุกครั้งที่ reload หน้าเพจใหม่มันคือหายนะแห่งการรอคอยหรือช้านั่นเอง แต่เหนือสิ่งอื่นใดทุกครั้งที่โหลดทั้งเพจ สถานะของแอพพลิเคชันเราจะหายไปทั้งหมด ถ้าเราทำงานกับฟอร์มอยู่ เรากรอกข้อมูลอะไรเอาไว้การรีโหลดเพจจะทำให้ข้อมูลหายหมด สำหรับ HMR นั้นข้อมูลยังคงอยู่เช่นเดิมไม่หายไปไหน (เฉพาะกรณีที่ loader ฉลาดพอที่จะจัดการเป็น)

ติดตั้ง react-hot-loader ซะ

เพื่อให้ HMR สำหรับ React ของเราสมบูรณ์ พวกเราจงลงมือติดตั้ง react-hot-loader กันเถิดด้วยคำสั่งนี้

npm i --save-dev [email protected]

สถานะปัจจุบันคุณ Dan Abramov แนะนำให้เรากลับมาใช้ react-hot-loader เวอร์ชัน3ครับ เพราะเฮียแกได้แก้ไขบัคและหลายสิ่งจาก react-hot-loader เวอร์ชัน2 รวมถึง react-transform-hmr ไว้ในเวอร์ชันนี้เลย

ProTips! เครื่องมือนี้เราใช้ใน development จึงยังรับได้ที่จะใช้เวอร์ชัน beta แต่สำหรับ plugin/library อื่นๆที่เราต้องใช้ใน production ด้วยแล้ว ผมไม่แนะนำด้วยประการทั้งปวง production ของเราไม่ใช่หนูทดลองสำหรับ alpha/beta software นะครับ!

จากนั้นไปที่ /ui/index.js แล้วแก้ไขโค๊ดของเราตามนี้ครับ

import React, { Component } from 'react'
import { render } from 'react-dom'
// เราต้องใช้ AppContainer จาก hor-loader
// เพื่อครอบคอมโพแนนท์บนสุดของแอพพลิเคชันเราชื่อ Root
// เพื่อให้ทุกๆสิ่งภายใต้คอมโพแนนท์ Root มีคุณสมบัติ HMR ได้
import { AppContainer } from 'react-hot-loader'
// เพื่อให้ hot loader ทำงานสมบูรณ์เราต้องมีเพียงหนึ่งคอมโพแนนท์
// ที่ห่อหุ้มภายใต้ AppContainer โดยคอมโพแนนท์นั้นเราตั้งชื่อว่า Root
import Root from './containers/Root'

const rootEl = document.getElementById('app')

render(
  <AppContainer>
    <Root />
  </AppContainer>,
  rootEl
)

if (module.hot) {
  // เมื่อไหร่ก็ตามที่โค๊ดภายใต้ Root รวมถึง subcomponent ภายใต้ Root
  // มีการเปลี่ยนแปลง ให้ทำ HMR ด้วย Root ตัวใหม่
  // ที่เราตั้งชื่อให้ว่า NextRootApp
  module.hot.accept('./containers/Root', () => {
    const NextRootApp = require('./containers/Root').default
    
    render(
      <AppContainer>
         <NextRootApp />
      </AppContainer>,
      rootEl
    );
  });
}

เราต้องการคอมโพแนนท์ Root ไว้เป็นคอมโพแนนท์ที่อยู่บนสุด เมื่อโค๊ดภายใต้ Root และเหล่าคอมโพแนนท์ที่เป็นลูกหลานของ Root มีการเปลี่ยนแปลง react-hot-loader จะจับความเปลี่ยนแปลงนั้นได้และทำ HMR ให้กับเรา สร้าง Root.js แล้วใส่โค๊ดตามนี้ครับ

import React, { Component } from 'react'
import routes from '../routes'

export default class App extends Component {
  render() {
    return (
      <div>
        {routes()}
      </div>
    )
  }
}

ก่อนที่เราจะไปดูผลสำเร็จมีอีกสามสิ่งที่ต้องทำ เปิดไฟล์ .babelrc และ webpack.config.js แล้วแก้ไขตามนี้ครับ

// .babelrc
{
  "presets": ["es2015", "stage-0", "react"],
  "plugins": ["react-hot-loader/babel"]
}

// webpack.config.js
module.exports = {
  devtool: 'eval',
  entry: [
    // patch hot-loader
    'react-hot-loader/patch',
    'webpack-dev-server/client?http://localhost:8080',
    // ยังจำได้ไหม webpack-der-server เราทำได้ทั้ง hot และ inline
    // แต่เราต้องการแค่ hot module replacement
    // เราไม่ต้องการ inline ที่จะแอบทะลึ่งไป reload เพจของเรา
    // เราจึงบอกว่าใช้ hot เท่านั้นนะ
    'webpack/hot/only-dev-server',
    './ui/theme/elements.scss',
    './ui/index.js'
  ],
  ....
  ....
  plugins: [
    new webpack.HotModuleReplacementPlugin()
  ],
  ....
  ....
  devServer: {
    hot: true,
    // เมินไปซะ ชาตินี้อย่าได้บังอาจมา reload เพจอีกเลย
    inline: false,
    historyApiFallback: true,
    proxy: {
      '/api/*': {
        target: 'http://127.0.0.1:5000'
      }
    }
  }
}

และสุดท้าย… ตอนนี้เราย้ายวิธีจัดการกับ hot และ inline ไปไว้ใน webpack.config.js แล้วฉะนั้นจึงไม่มีความจำเป็นใดๆต้องใส่ --hot และ --inline ใน package.json ดังนั้นนำมันออกซะจาก package.json ครับ

{
  ...
  "scripts": {
    ...
    ...
    "start-dev-ui": "webpack-dev-server"
  },
  ...
}

ถึงเวลาดูผลลัพธ์กันแล้ว เพื่อนๆลองรัน npm start ขึ้นมาใหม่ครับ แล้วลองแก้ไขโค๊ดใน js ไฟล์อะไรก็ได้ แล้วลองดูผลลัพธ์ครับ ตอนนี้ทุกคนน่าจะเห็นแล้วว่าหน้าจอของเราอัพเดทผลลัพธ์โดยไม่โหลดเพจใหม่ทั้งหมด!

ชะโงกหน้าไปดูที่ console เอ… แต่ทำไมดันเจอ warning ซะงั้น

hot-reload router warning

หลังจากดั้นด้นไปคุ้ยส่องใน Github ทำให้พบว่ามีท่านอื่นเจอปัญหานี้เช่นกัน

hot-reload router warning question

เฮีย Dan Abramov ของเราก็ได้เข้ามาตอบครับว่า พวกลื้อลองใช้ react-router 3.0.0-alpha.12 ดูนะ แต่ยังไงก็ warning อยู่ดีละพวก!

hot-reload router warning answer

ถึงเวลาเข้าสู่ Day3 กันแล้ว

นอกเรื่องไปยาวมาก เอาหละถึงเวลาเข้าฝั่ง วันนี้เราจะไปทำความรู้จักกับ Redux กันครับ แต่ก่อนอื่นผมขอให้ทุกคนตั้งกฎเหล็กหนึ่งข้อไว้ในใจว่าเป้าหมายของเราจะไม่ไปจากเธอ one-way data flow หรือ unidirectional

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

redux diagram1

ปัญหาคลาสสิกของแอพพลิเคชันขนาดใหญ่

ตอนนี้ทุกคนน่าจะเกิดคำถามครับ การไหลของข้อมูลในแอพพลิเคชันเราเป็นไปในทิศทางเดียวผ่านการทำ action up, data down แล้ว เราควรจบแค่นี้แล้วซิ มีเหตุผลอะไรที่เรายังต้องไปต่อ? ปัญหาต่อไปนี้จะเกิดขึ้นเมื่อแอพพลิเคชันของคุณใหญ่และซับซ้อนมากขึ้นครับ

  • โค๊ดที่ซ้ำซ้อน มีหลายคอมโพแนนท์ที่มีสถานะ (state) ร่วมกัน เช่นในหน้าสร้างวิกิกับหน้าแสดงวิกิอาจมี state เหมือนกัน ลองจินตนาการครับว่าเรากรอกฟอร์มเพื่อสร้างวิกิแล้ว เมื่อกดสร้างเราอาจนำข้อมูลจากฟอร์มนี้มาแสดงผลในหน้าแสดงวิกิได้เลย โดยไม่ต้องติดต่อขอข้อมูลจากเซิร์ฟเวอร์มาแสดง จำเป็นหรือที่เราจะเก็บสถานะและวิธีการจัดการกับสถานะไว้ในสองคอมโพแนนท์ ทั้งๆที่มันทำในสิ่งเดียวกัน?
  • การใช้สถานะร่วมกันระหว่างคอมโพแนนท์ แม้ว่าเราจะมีคอมโพแนนท์บนสุดเป็น Container Component ที่ไว้คอยจัดการกับสารพัด action แต่ในความเป็นจริงแล้วระบบขนาดใหญ่ไม่ได้มีหนึ่ง Container Component ต่อหนึ่งเพจครับ ความหมายคืออะไร? หมายความว่าในหนึ่งเพจเราอาจมีสอง Container Components และทั้งสองคอมโพแนนท์นี้อาจแชร์สถานะบางอย่างร่วมกันอยู่ เช่นทั้งสองคอมโพแนนท์นี้อาจต้องการเข้าถึงข้อมูลผู้ใช้งานระบบปัจจุบัน (current user) เมื่อมีความต้องการร่วมกัน เราจะเก็บสถานะนี้ไว้ที่คอมโพแนนท์ตัวที่หนึ่งหรือสองดี?
  • การเขียนโค๊ดคือการเมือง ขอนำเสนอมุมมองด้านการทำงานเป็นทีมบ้างครับ ถ้านายเอทำงานกับคอมโพแนนท์เอที่มีสถานะหรือข้อมูลจำเพาะอยู่แค่คอมโพแนนท์เอ เช่นกันนายบีก็ทำงานกับคอมโพแนนท์บี เนื่องจากคอมโพแนนท์ทั้งสองดันต้องใช้ state บางอย่างร่วมกัน งานงอกหละซิครับ แต่ละคนย่อมนิยามสถานะแตกต่างกัน จะดีกว่าไหมถ้าเราย้าย state ของแอพพลิเคชันออกมาอยู่ตรงกลาง?
  • อื่นๆอีกมากมายสุดพรรณนา

นั่นละฮะท่านผู้อ่าน ปัญหาความซับซ้อนของแอพพลิเคชั่นที่ผูกสถานะไว้กับตัวคอมโพแนนท์เองเลย เวลาผู้ใช้งานเข้าถึงเพจสิ่งที่แสดงออกมาทางหน้าจอไม่ใช่เพียงแค่สถานะของคอมโพแนนท์นะครับ แต่มันรวมถึงสถานะที่เกิดจากการทำงานร่วมกันทั้งระบบ (application state) ดังนั้นแล้วเราจึงแยก state ของแต่ละคอมโพแนนท์ออกมาไว้ตรงกลายให้เป็น application state เก็บเอาไว้ในโกดังแห่งหนึ่ง โกดังสำหรับเก็บสถานะของแอพพลิเคชันนี่หละครับที่เราเรียกว่า store หยิบปากกาขีดเส้นใต้สองเส้นครับ

รู้จัก store โกดังเก็บ state

กลับมาดูที่วิกิของเรากัน ขอให้เพื่อนๆเปิดไปที่ไฟล์ containers/Pages/Show.js ที่เป็น Container Component ไว้ดึงข้อมูลหน้าวิกิจากเซิร์ฟเวอร์พร้อมทั้งเก็บข้อมูลนั้นไว้กับตัว

...
...
state = {
  page: {
    title: '',
    content: ''
  }
}
...
...

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

เนื่องจากในหนึ่งแอพพลิเคชันเรามีสถานะได้หลายตัว เช่นสถานะของวิกิ สถานะของผู้ใช้งานระบบ หรือสถานะของแหล่งอ้างอิง ถ้าเรานำสถานะทุกๆตัวไปยัดอยู่ในไฟล์เดียวเพื่อให้เป็นสถานะโดยรวมของแอพพลิเคชันคงดูไม่ดีนัก เราควรแยกแต่ละสถานะออกจากกันไปคนละไฟล์ ภายใต้โฟลเดอร์ร่วมกัน เช่นผมอาจตั้งชื่อโฟลเดอร์ว่า stores และมีไฟล์ชื่อ page.js อยู่ข้างในเพื่อเก็บสถานะของวิกิเพจดังนี้

// stores/page.js
state = {
  page: {
    title: '',
    content: ''
  }
}

export function getState() {
  // โค๊ดสำหรับเข้าถึง state ของ page
}

export function setState(newState) {
  // โค๊ดสำหรับตั้งค่า state ใหม่
}

เอาหละ เมื่อเราเข้าหน้า Show ของวิกิที่จะไปโหลดข้อมูลวิกิมาแสดง มันจะไม่เก็บ title และ content ไว้ในตัว containers/Pages/Show.js อีกต่อไป แต่เราจะเรียกฟังก์ชัน setState ของ stores/page.js แล้วส่งสถานะใหม่ของวิกิซึ่งก็คือ title และ content ที่ได้มาจากเซริฟเวอร์เข้าไป ถึงตอนนี้สถานะของ page จะไม่ใช่ของคอมโพแนนท์อีกแล้ว แต่เป็นของทั้งระบบ ความหมายคือไม่ใช่เพียงแค่คอมโพแนนท์ Show.js เท่านั้นที่จะเข้าถึงสถานะนี้ได้ แต่คอมโพแนนท์อื่นๆย่อมเข้าถึงได้โดยตรงเช่นกัน

จบกระบวนการแล้วลองเช็คดูกันว่าสิ่งที่เราทำนั้นผิดข้อตกลงไหม การมี getState และ setState แบบนี้ยังเป็น one-way data flow อยู่ไหม?

Store Misconcept1

เห็นชัดเลยครับว่าถ้าเรามีคอมโพแนนท์หลายตัวเข้าถึงสถานะของแอพพลิเคชันแบบนี้ ข้อมูลของเราไม่ได้ไหลไปทางเดียวแล้ว? หืมยังไงอะ ดูจากในรูปก็ยังไหลไปทางเดียวนะ? ComponentA เรียก setState วิ่งเข้าไปตั้งค่า state ใหม่ใน store จากนั้นจึงเรียก getState เพื่อดึงค่ากลับอีกที? ก็ดูวนไปทางเดียวหนิ?

ใช่ครับ นั่นคือทิศทางเดียวสำหรับหนึ่งคอมโพแนนท์ แต่ตอนนี้เรามีสองคอมโพแนนท์แล้วครับ แต่ละคอมโพแนนท์มีอิสระที่จะ setState ใหม่ได้เสมอ นั่นละฮะถ้าคอมโพแนนท์ A เรียก setState คำถามคือคอมโพแนนท์ B จะทราบไหมครับว่าตอนนี้สถานะของแอพพลิเคชันเปลี่ยนไปแล้ว?

Store Misconcept2

พอเราเริ่มมีหลาย store มากขึ้น ปัญหาก็จะเกิดตามขึ้นมา เพราะคอมโพแนนท์แต่ละตัวก็สามารถเข้าถึงค่า state จากแต่ละ store ได้ ดูสภาพเส้นที่โยงใยไปซิครับ มันไม่ใช่ one-way data flow แล้ว

วิธีการนี้เกิดปัญหาแน่ในระบบขนาดใหญ่เพราะเราคาดเดาไม่ได้เลยว่าคอมโพแนนท์ไหนแอบไป setState บ้าง การไหลเวียนข้อมูลที่ดีต้องไปในทิศทางเดียวและต้องดีพอให้โปรแกรมเมอร์ที่ทำงานตัวเป็นเกรียว หัวเป็นน็อต ก้นเป็นสว่านแบบเราๆคาดเดาได้ว่าถ้าเกิดเหตุการณ์นี้ผลลัพธ์จะเป็นเช่นไร

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

และแล้วตัวละครตัวแรกของเราก็เผยโฉมมาแล้วใน Redux…

redux diagram2

เพื่อให้คอมโพแนนท์ทั้งระบบรับรู้ว่าแอพพลิเคชันของเรามีสถานะเปลี่ยนไปแล้ว เราจึงต้อง subscribe หรือก็คือให้ทุกๆคอมโพแนนท์ติดตั้งต่อมความเผือกเรื่องชาวบ้านไว้กับตัวเสมอ เมื่อใดก็ตามที่ state ใน store เปลี่ยน ต่อมเผือกของแต่ละคอมโพแนนท์จะเริ่มทำงานด้วยการส่องเข้าไปดูหรือเผือกเฉพาะสถานะที่คอมโพแนนท์นั้นสนใจ นั่นละฮะถ้าเป็นเราก็คงไม่เผือกเรื่องชาวบ้านไปทุกเรื่องใช่ไหมละ เราเผือกแค่สิ่งที่เราอยากรู้ โดยเฉพาะเรื่องชาวบ้าน ตัวอย่างเช่น คอมโพแนนท์ show.js ของวิกิหลังจากรับรู้ว่าสถานะมีการเปลี่ยนแปลง มันจะสนใจเฉพาะสถานะ page เท่านั้น อย่างอื่นเช่นสถานะของผู้ใช้ระบบปัจจุบันมันจะเมินเสีย

ตัวละครตัวที่สองของเรานี่ละครับคือ view เป็นตัวละครที่ต่อมเผือกเบ่งบาน ต้อง subscribe ดูความเปลี่ยนแปลงของ state ใน store ด้วยความอยากรู้อยากเห็นหรือเผือกนั่นเอง

redux diagram3

อย่าให้ store รับภาระแต่ฝ่ายเดียว

ปัจจัยหลักที่ห้ามไม่ให้คอมโพแนนท์เรียก setState จาก store นั่นเป็นเพราะเราต้องการแยกการทำงานให้ชัดเจนขึ้น กล่าวคือคอมโพแนนท์มีหน้าที่เดียวคือทำให้ได้ได้มาซึ่งข้อมูลเพื่อการแสดงผล เราจึง subscribe สถานะของแอพพลิเคชันเพื่อให้ได้มาซึ่งข้อมูล แต่การ setState นั้นไม่ได้เกี่ยวข้องกับการได้มาซึ่งข้อมูลเพื่อไปแสดงผล เราจึงไม่ควรรับรู้ว่า store นั้นต้อง setState อย่างไร ถ้าเราฝืนมีให้คอมโพแนนท์ของเรารับรู้ถึงการตั้งค่าสถานะตรงเข้าไปใน store จะผิดหลักการที่ว่า single responsibility principle เพราะเราไม่ได้ทำงานเดียวคือรับข้อมูลไปแสดงผลแล้ว แต่เรายังต้องกังวลว่าจะเปลี่ยนแปลงข้อมูลอย่างไรด้วย

ในระบบที่มีคอมโพแนนท์หลายตัว ย่อมมีโอกาสทำการเปลี่ยนสถานะของแอพพลิเคชันเหมือนๆกันในหลายๆคอมโพแนนท์ คอมโพแนนท์ SignInWithFacebook ย่อมสามารถเปลี่ยนสถานะของผู้ใช้งานระบบให้ isLoggedIn หรือเข้าสู่ระบบแล้วเป็น true เช่นเดียวกันคอมโพแนนท์ SignInWithGithub ก็สามารถเปลี่ยน isLoggedIn เป็น true ได้เหมือนกัน เราจะเห็นว่าทั้งสองคอมโพแนนท์นี้มีเหตุการณ์ที่ทำให้เกิดการเปลี่ยนสถานะของระบบเหมือนกัน เราเรียกเหตุการณ์หรือการกระทำที่ทำให้สถานะของระบบเปลี่ยนว่า action

action เดียวกันสามารถเกิดขึ้นจากคอมโพแนนท์ได้หลายตัว เราจึงควรแยก action ออกไปจากคอมโพแนนท์ เพื่อให้คอมโพแนนท์รับรู้เพียงแค่วิธีการเพื่อให้ได้มาซึ่งการแสดงผล เมื่อไหร่ก็ตามที่ผู้ใช้งานระบบคลิกปุ่มหรือทำอะไรซักอย่างบนหน้าเว็บเพื่อให้เกิดเหตุการณ์ขึ้นมา คอมโพแนนท์จะไม่เปลี่ยนสถานะของระบบเอง แต่คอมโพแนนท์จะส่งค่าสถานะนี้ออกไปโดยหวังว่าจะมีใครซักคนช่วยจัดการให้ อย่าลืมนะครับคอมโพแนนท์จะไม่รู้วิธีการเปลี่ยนสถานะต้องอาศัยคนอื่นทำให้

บางคนอาจงง เอ… ไหนว่าคอมโพแนนท์ควรรู้แค่การแสดงผลไง แต่นี้เหมือนคอมโพแนนท์รู้ด้วยนะว่า action คืออะไร เมื่อเกิด action แล้วต้องทำยังไง?

ไม่จริงครับขอแย้ง การคลิกปุ่ม การกด enter หรือเหตุการณ์อื่นๆบนคอมโพแนนท์นั้นล้วนสัมพันธ์กับการแสดงผล การที่คอมโพแนนท์รับรู้ action จึงไม่ผิด ส่วนกรณีที่คอมโพแนนท์รู้ว่าจะต้องโยน action ไปให้คนภายนอกจัดการนั้นก็เหมือนกรณีของ Container Component กับ Presentational Component ครับ Presentational Component ไม่รู้วิธีจัดการ action จึงต้องทำ action up คือโยน action ขึ้นไปให้ Container Component จัดการ เฮียแกแค่เรียก callback function ที่ส่งเขามาเฉยๆ เฮียแกไม่รู้หรอกครับว่า Container Component เป็นใคร

ตัวละครตัวที่สามเปิดเผยหน้าตาแล้วครับ กราบสวัสดีน้อง action

redux diagram4

เพื่อนๆคิดว่าถ้าเราจะเปลี่ยนสถานะของแอพพลิเคชัน เรารู้เพียงแค่ว่ามีคนคลิกปุ่มบนคอมโพแนนท์พอไหม? ไม่พอหรอกครับเราต้องรู้มากกว่านั้น เช่นต้องรู้ว่าปุ่มที่เขาคลิกคือปุ่มอะไร ในกรณีที่เป็นปุ่ม submit ฟอร์มเพื่อที่จะสร้างวิกิ เราก็ต้องส่งข้อมูลภายในฟอร์มไปด้วยเพื่อจะได้สร้างวิกิจากข้อมูลชุดนี้ได้ ด้วยเหตุนี้คอมโพแนนท์ของเราเพียงแค่โยน action ออกไปว่าเห้ยมีคนคลิกปุ่มหวะ แบบนี้ไม่พอ ต้องส่งข้อมูลเพิ่มเติมไปให้ผู้ที่จะทำการเปลี่ยนสถานะแอพพลิเคชันรู้ด้วยว่าปุ่มที่คลิกคืออะไร ข้อมูลในฟอร์มคืออะไร เป็นต้น ด้วยเหตุนี้คอมโพแนนท์ไม่ได้โยนแค่ชนิดของ action ออกไปให้คนอื่นจัดการ แต่มันต้องโยนก้อนอ็อบเจ็กต์ที่มีข้อมูลอธิบายเพิ่มเติมเกี่ยวกับ action นั้น เช่น

{
  // ชนิดของ action ที่เกิดขึ้น
  type: 'FORM_SUBMISSION',
  // ข้อมูลเพิ่มเติมที่ส่งไป
  data: {
    title: 'Wiki Title',
    content: 'Wiki Content'
  }
}

ในทางปฏิบัติแล้วเรามันนิยามฟังก์ชันขึ้่นมา เพื่อทำหน้าที่สร้างอ็อบเจ็กต์ที่เก็บชนิดของ action และข้อมูลเพิ่มเติม ดังนี้

const createWiki = (formData) => {
  type: 'FORM_SUBMISSION',
  data: formData
}

ฟังก์ชันผู้สร้างประเภทนี้แหละครับเราเรียกว่า action creator ตัวละครตัวที่สี่ของเรา

redux diagram5

ทบทวนกระบวนการส่งผ่านข้อมูลจากตัวละครทั้งสี่

เอาหละตอนนี้ตัวละครเปิดเผยมาแล้วสี่ตัว เรามาทบทวนก่อนที่จะลืมกันซะหน่อย เริ่มจากผู้ใช้งานระบบคลิกปุ่มอะไรซักอย่างบนคอมโพแนนท์ของเรา…

  • คอมโพแนนท์รับรู้ว่ามีการคลิกปุ่มเกิดขึ้น
  • คอมโพแนนท์เรียก action creator เพื่อสร้างอ็อบเจ็กต์ที่เป็นตัวแทนของ action ที่เกิดขึ้น
  • action creator ส่งค่ากลับมาเป็นก้อนอ็อบเจ็กต์ action
  • action ตัวนี้นี่แหละครับที่คอมโพแนนท์ของเราจะส่งไปให้ใครซักคน
  • ใครซักคนคนนั้นจะแกะดู action แล้วทำการเปลี่ยนแปลง state ใน store ตามชนิดของ action ที่เกิดขึ้น เช่นถ้า action นั้นเป็น LOGOUT ใครคนนั้นก็จะเปลี่ยน state ของ isLoggedIn ให้เป็น false
  • store เก็บสถานะใหม่ของระบบที่เปลี่ยนแปลงโดยใครคนนั้น
  • คอมโพแนนท์ทุกตัวในระบบ (จริงๆแล้วไม่ทุกตัวนะแต่ตอนนี้ขอให้เข้าใจไปก่อนว่าทุกตัว) ต่อมเผือกทำงาน รับรู้ถึงการเปลี่ยนแปลงของ state ใน store
  • คอมโพแนนท์จะหยิบเฉพาะ state ที่เกี่ยวข้องกับการแสดงผลขึ้นมาใช้งาน
  • คอมโพแนนท์จะ rerender หรือเปลี่ยนแปลงการแสดงผลใหม่ให้สอดคล้องกับ state ใหม่ที่ได้รับ

HMR พลเมืองชั้นหนึ่งของการพัฒนาด้วย Redux

ย้อนกลับไปดู store กันนิดนึงครับ เราบอกว่าคอมโพแนนท์จะส่ง action ไปให้ใครซักคนจัดการด้วยการเปลี่ยน state ของแอพพลิเคชัน ทำไมเราต้องทำอะไรให้มันซับซ้อนด้วย เอาอย่างนี้ดีกว่าเราก็ให้ store เป็นใครคนนั้นซะเลยซิ ไหนๆ store ก็เก็บ state เองอยู่แล้ว จะแปลกอย่างไรถ้า store จะรับ action มาแล้วเปลี่ยน state ตาม action นั้น

// stores/page.js
state = {
  page: {
    title: '',
    content: ''
  }
}

// ฟังก์ชันนี้จะรับอ็อบเจ็กต์ของ action เข้ามา ยังจำหน้าตามันได้ไหมครับ
// หน้าตาประมาณนี้ไง { type: 'CREATE_PAGE_SUCCESS', data: ... }
export default (action) => {
  // ตรวจสอบว่า action เป็นชนิดไหนแล้วจึงเปลี่ยนแปลงสถานะของ page ตาม action นั้น
  switch(action.type) {
    case 'CREATE_PAGE_SUCCESS':
      // ส่งค่ากลับเป็นสถานะใหม่ของ page
  }
}

ชีวิตดี๊ดีทุกอย่างดูสวยงามครับ แต่วิธีการแบบนี้มีปัญหาอยู่อย่างหนึ่ง…

ตอนนี้ store ของเราทำสองหน้าที่ครับ อย่างแรกคือเก็บสถานะของแอพพลิเคชัน อย่างหลังคือเปลี่ยนสถานะของแอพพลิเคชันตามแต่ action ที่ระบุเข้ามา สมมติเราแก้ไขโค๊ดสักที่ใน store (ในฟังก์ชันบรรทัดที่ 11-17) เป็นผลให้ react-hot-loader ทำงาน นั่นคือมันจะทำ HMR โดยมันจะนำโค๊ดของ store ใหม่ทั้งไฟล์ไปแทนทีในหน้าเว็บของเรา

เริ่มมองเห็นปัญหาไหมครับ ถ้า store ตัวนี้ทำงานกับฟอร์ม เราเคยกรอกฟอร์มอะไรเอาไว้ เพียงแค่คุณแก้ไข store ข้อมูลในฟอร์มที่คุณเคยกรอกก็จะสิ้นชีพลงอย่างสงบ ที่เป็นเช่นนี้เพราะว่าหนึ่งไฟล์ของ store ถือเป็นหนึ่งโมดูล ในโมดูลนี้มี state ตั้งต้นเป็นค่าว่าง (ดูบรรทัด 2-7) เมื่อทำ HMR ไอ้ state ที่มีแต่ค่าว่างนี้ก็จะโดนโยนไปแสดงผลบนหน้าจอทันที

เพื่อไม่ให้เหตุการณ์เช่นนี้เกิดขึ้น เราจึงควรแยกฟังก์ชันที่ไว้จัดการงานเปลี่ยนสถานะออกไปให้พ้นจาก store ซะ เมื่อเราแก้โค๊ดของฟังก์ชันนี้ react-hot-loader จะได้โหลดเพียงแค่ไฟล์ของฟังก์ชันนั้นไปอัพเดทบนหน้าเพจ จะได้ไม่ต้อง HMR state ใน store ที่อยู่คนละไฟล์อีกต่อไป ไอ้ฟังก์ชันที่นิยามวิธีการเปลี่ยนสถานะของแอพพลิเคชันตามแต่ action ที่ส่งเข้ามานี่ละครับที่เราเรียกว่า reducer ตัวละครตัวที่ห้าของเรา

redux diagram6

เรามาลองแทนที่คำว่า ใครคนนั้น ในกระบวนการทำงานที่ได้กล่าวแล้วในหัวข้อก่อนหน้านี้ จะได้ว่าเมื่อมีผู้ใช้งานระบบกดปุ่มอะไรซักอย่างบนคอมโพแนนท์ลำดับการทำงานต่อไปนี้จะเกิดขึ้น

  • คอมโพแนนท์รับรู้ว่ามีการคลิกปุ่มเกิดขึ้น
  • คอมโพแนนท์เรียก action creator เพื่อสร้างอ็อบเจ็กต์ที่เป็นตัวแทนของ action ที่เกิดขึ้น
  • action creator ส่งค่ากลับมาเป็นก้อนอ็อบเจ็กต์ action
  • action ตัวนี้นี่แหละครับที่คอมโพแนนท์ของเราจะส่งไปให้ reducer จัดการ
  • reducer จะแกะดู action แล้วทำการเปลี่ยนแปลง state ใน store ตามชนิดของ action ที่เกิดขึ้น เช่นถ้า action นั้นเป็น LOGOUT reducer ก็จะเปลี่ยน state ของ isLoggedIn ให้เป็น false
  • store เก็บสถานะใหม่ของระบบที่เปลี่ยนแปลงโดย reducer
  • คอมโพแนนท์ทุกตัวในระบบ (จริงๆแล้วไม่ทุกตัวนะแต่ตอนนี้ขอให้เข้าใจไปก่อนว่าทุกตัว) ต่อมเผือกทำงาน รับรู้ถึงการเปลี่ยนแปลงของ state ใน store
  • คอมโพแนนท์จะหยิบเฉพาะ state ที่เกี่ยวข้องกับการแสดงผลขึ้นมาใช้งาน
  • คอมโพแนนท์จะ rerender หรือเปลี่ยนแปลงการแสดงผลใหม่ให้สอดคล้องกับ state ใหม่ที่ได้รับ

ทฤษฎีเยอะพอแล้ว ลงมือปฏิบัติกัน!

ลงมือติดตั้ง redux และ react-redux ด้วยคำสั่งนี้

npm i --save react-redux redux

จากนั้นสร้างโฟลเดอร์ต่อไปนี้ขึ้นมาภายใต้โฟลเดอร์ ui ได้แก่ actions, reducers และ store

redux directory structure

เราบอกว่าคอมโพแนนท์รับรู้ว่ามี action อะไรเกิดขึ้น แต่มันจะไม่จัดการด้วยตนเอง คอมโพแนนท์มีหน้าที่ส่งก้อนอ็อบเจ็กต์ที่เป็นตัวแทนของ action ไปให้ reducer จัดการ เอาหละเราลองลงมือสร้าง action ผ่าน action creator กัน เพื่อนๆสร้างไฟล์ชื่อ page.js ใต้โฟลเดอร์ actions ครับ ดังนี้

// actions/page.js

// ฟังก์ชันนี้มีหน้าที่สร้างอ็อบเจ็กต์ที่เป็นตัวแทนของ action
// มันจึงเป็น action creator
// เมื่อเรากดปุ่ม reload pages หรือเมื่อหน้า Index ของวิกิแสดงผล
// คอมโพแนนท์ containers/Pages/Index.js จะเรียก action creator ตัวนี้
// เพื่อทำการสร้าง action ที่มีชนิดเป็น LOAD_PAGES_SUCCESS
// พร้อมกับก้อนข้อมูลของ wiki pages
export const loadPages = () => ({
  type: 'RECEIVE_PAGES',
  pages: [
    {
      "id": 1,
      "title": "test page#1",
      "content": "TEST PAGE CONTENT"
    }, {
      "id": 2,
      "title": "test page#2",
      "content": "TEST PAGE CONTENT"
    }
  ]
})

เรามีวิธีสร้างก้อน action แล้ว ทีนี้เราก็ต้องไปบอกคอมโพแนนท์ของเราว่าเมื่อมีการกดปุ่ม reload pages ให้คอมโพแนนท์ของเราเรียกฟังก์ชันนี้เพื่อนสร้างก้อนอ็อบเจ็กต์ของ action

// containers/Pages/Index.js
...
...
import { loadPages } from '../../actions/page'

class PagesContainer extends Component {
  state = {
    pages: []
  }

  onReloadPages = () => {
    // เมื่อไหร่ก็ตามที่โหลดเพจหรือกดปุ่ม reload page ให้สร้างอ็อบเจ็กต์ action
    // ผ่าน action creator ชื่อ loadPages
    loadPages()
    // คอมเมนต์ออกไปก่อน เราจะกลับมาคุยเรื่องการโหลดข้อมูลผ่าน AJAX กันอีกที
    // fetch(PAGES_ENDPOINT)
    //   .then((response) => response.json())
    //   .then((pages) => this.setState({ pages }))
  }

  componentDidMount() {
    this.onReloadPages()
  }

  render() {
    return (
      <Pages
        pages={this.props.pages}
        onReloadPages={this.onReloadPages} />
    )
  }
}

คำถามต่อมาคือเราสร้างก้อนอ็อบเจ็กต์ของ action ได้แล้ว เราจะส่งไปให้ reducer ยังไงดี? คำตอบก็คือใน Redux store จะมีเมธอดชื่อ dispatch ให้เราเรียกใช้ เพียงแค่เราส่งอ็อบเจ็กต์ action นี้ผ่านเข้าไปใน dispatch เหล่า reducer ทั้งหลายก็จะรับรู้ทันที พวกเธอจะขวักไขว่จัดการ action เพื่อเปลี่ยน state ตามที่เราคาดหวังไว้

ตามแผนภาพที่ผมให้เพื่อนๆดู ยังจำกันได้ไหมครับว่าคอมโพแนนท์ของเราต้อง subscribe store เพื่อเผือกในการเปลี่ยนแปลงสถานะของแอพพลิเคชัน พูดง่ายๆก็คือคอมโพแนนท์ของเราต้องรับรู้เมื่อ state เปลี่ยน พร้อมทั้งต้องส่ง action ไปให้ reducer ทำงานผ่าน dispatch

ตรงนี้ต้องขอขยายความเพิ่มนิดนึงครับ คอมโพแนนท์ที่จะรับรู้เรื่องการเปลี่ยนสถานะของแอพพลิเคชันและการ dispatch action ไปให้ reducer นั้น จะต้องเป็นคอมโพแนนท์ประเภท Container Component นะครับ ยังจำกันได้ไหม Presentational Component ชอบศิลปะและรักการแสดงผลอย่างเดียว

ในแพคเกจของ react-redux มี connect ที่เราสามารถเรียกใช้เพื่อประกาศก้องให้โลกรู้ว่าเราต้องการเชื่อมต่อหรือ connect คอมโพแนนท์ของเราเข้ากับ Redux store สิ่งที่ได้ตามมาคือคอมโพแนนท์ของเราก็จะรับรู้ทุกการเปลี่ยนแปลงของ state ที่อยู่ใน store และรู้จัก dispatch ที่เป็นฟังก์ชันของ store ไปโดยปริยาย

อัพเดท containers/Pages/Index.js กันอีกครั้งเพื่อเรียกใช้ connect

import React, { Component, PropTypes } from 'react'
// import connect เข้ามาก่อนครับ
import { connect } from 'react-redux'
import fetch from 'isomorphic-fetch'
import { PAGES_ENDPOINT } from '../../constants/endpoints'
import { loadPages } from '../../actions/page'
import { Pages } from '../../components'

class PagesContainer extends Component {
  // เอาโค๊ดตรงนี้ออกไปได้เลย
  // ตอนนี้ state ของเราบรรจุเข้า store แทนแล้ว
  // state = {
  //  pages: []
  // }
  
  static propTypes = {
    pages: PropTypes.array.isRequired,
    onLoadPages: PropTypes.func.isRequired
  }

  // เมื่อ state อัพเดท ฟังก์ชัน mapStateToProps ด้านล่างจะทำงาน
  // สิ่งที่ return ออกมาจากฟังก์ชันนี้จะกลายเป็น props ของคอมโพแนนท์
  shouldComponentUpdate(nextProps) {
    // ดังนั้นเราจึงตรวจสอบ pages ผ่าน props แทน state
    // อย่าสับสนนะครับ state ที่พูดถึงตรงนี้คือ this.state หรือสถานะของคอมโพแนนท์
    // ไม่ใช่สถานะของแอพพลิเคชันนะ
    return this.props.pages !== nextProps.pages;
  }

  onReloadPages = () => {
    // loadPages ตัวนี้เป็น props ที่ได้มาจากค่าที่ mapDispatchToProps ส่งออกมา
    // เมื่อเราเรียกฟังก์ชันนี้ มันจะ dispatch ก้อนอ็อบเจ็กต์ของ action ไปให้ reducer
    // ดู mapDispatchToProps ด้านล่างประกอบ
    this.props.onLoadPages()
    // fetch(PAGES_ENDPOINT)
    //   .then((response) => response.json())
    //   .then((pages) => this.setState({ pages }))
  }

  componentDidMount() {
    this.onReloadPages()
  }

  render() {
    return (
      <Pages
        pages={this.props.pages}
        onReloadPages={this.onReloadPages} />
    )
  }
}

// state ในที่นี้หมายถึงสถานะของแอพพลิเคชันที่เก็บอยู่ใน store
const mapStateToProps = (state) => ({
  // เมื่อ state ใน store มีการเปลี่ยนแปลง
  // เราไม่สนใจทุก state
  // เราสนใจแค่ state ของ pages
  // โดยทำการติดตั้ง pages ให้เป็น props
  // เราใช้ชื่อ key ของ object เป็นอะไร
  // key ตัวนั้นจะเป็นชื่อที่เรียกได้จาก props ของคอมโพแนนท์
  pages: state.pages
})

// ส่ง dispatch ของ store เข้าไปให้เรียกใช้
// อยาก dispatch อะไรไปให้ reducer ก็สอยเอาตามปรารถนาเลยครับ
const mapDispatchToProps = (dispatch) => ({
  onLoadPages() {
    // เมื่อเรียก this.props.onLoadPages
    // loadPages ที่เป็น action creator จะโดนปลุกขึ้นมาทำงาน
    // จากนั้นจะ return ก้อนอ็อบเจ็กต์ของ action
    // ส่งเข้าไปใน dispatch
    // store.dispatch จะไปปลุก reducer ให้มาจัดการกับ action ที่เกี่ยวข้อง
    dispatch(loadPages())
  }
})

// วิธีใช้ connect สังเกตนะครับส่งสองฟังก์ชันคือ
// mapStateToProps และ mapDispatchToProps เข้าไปใน connect
// จะได้ฟังก์ชันใหม่ return กลับมา
// แล้วเราก็ส่ง PagesContainer ที่เป้นคอมโพแนนท์ที่ต้องการเชื่อมต่อกับ store
// เข้าไปในฟังก์ชันใหม่นี้อีกที
// มันคือ Higher-order function นั่นเอง
export default connect(
  mapStateToProps,
  mapDispatchToProps
)(PagesContainer)

สังเกตตรง mapDispatchToProps นะครับ เรานิยาม onLoadPages ไว้ว่าคือการส่ง loadPages เข้าไปใน dispatch ถ้าเราต้องการทำเพียงแค่นี้เราไม่ต้องถึงกับสร้าง mapDispatchToProps ขึ้นมาก็ได้ แค่จับคู่ onLoadPages เข้ากับ loadPages ดังนี้

export default connect(
  mapStateToProps,
  { onLoadPages: loadPages }
)(PagesContainer)

เจาะลึก reducer

เราพูดถึง reducer กันมาหลายบรรทัดแล้ว ถึงเวลาต้องเปิดเผยความลับแห่งจักรวาลซะที เริ่มจากสร้างไฟล์ pages.js ภายใต้โฟลเดอร์ reducers ดังนี้ครับ

const initialState = []

// reducer นั้นเป็นฟังก์ชันที่รับพารามิเตอร์สองตัว
// คือสถานะก่อนหน้า (previous state) และอ็อบเจ็กต์ action
// ตัวอย่างเช่นถ้าเราจะเพิ่มหน้าวิกิใหม่ สถานะก่อนหน้าอาจเป็นหน้าวิกิทั้งหมด
// เมื่อ reducer ทำงานเสร็จจะเพิ่มวิกิใหม่มี่เราพึ่งสร้าง เข้าไปในสถานะก่อนหน้าซึ่งก็คือวิกิทั้งหมดที่มีอยู่ก่อน
// ในกรณีที่เราไม่มีสถานะก่อนหน้า เราบอก reducer ว่าให้ใช้ค่า initialState
// ซึ่งก็คืออาร์เรย์ว่างเปล่าเป็นสถานะตั้งต้น
// สำหรับ [] ใน pages reducer นี้หมายความว่า
// เริ่มต้นนั้นเราไม่มีหน้าวิกิอยู่ในระบบเลย
export default (state = initialState, action) => {
  switch(action.type) {
    // เมื่อไหร่ก็ตามที่ action มีชนิดเป็น RECEIVE_PAGES
    // ให้แกะดูข้อมูล pages จากก้อนอ็อบเจ็กต์ action
    // pages นี้คือหน้าวิกิทั้งหมด
    // เราคืนค้ากลับออกไปจาก reducer เป็นวิกิทั้งหมดที่ได้จากอ็อบเจ็กต์ action
    case 'RECEIVE_PAGES':
      return action.pages
    // ในกรณีที่ไม่มี action ตรงกลับที่ระบุให้คืนค่ากลับออกจาก reducer เป็น state ตัวเดิม
    default:
      return state
  }
}

สิ่งที่เรา return กลับออกมาจาก reducer นี้จะกลายเป็น state ตัวถัดไปครับ ในกรณีของ RECEIVE_PAGES เมื่อเราโหลดวิกิทั้งหมดมาสำเร็จ หลังจากเรียก reducer แล้ว store จะเก็บค่า state ที่ return ออกมานี้ไว้ เมื่อมี action เกิดขึ้นอีกครั้งไอ้ state ตัวที่เก็บไว้อยู่ใน store นี่หละครับ จะกลายเป็น previous state ต่อไป

หัวใจหลักของ reducer คือกิจกรรมภายในตัว reducer ห้ามไปแก้ไข previous state ครับ เพราะฉะนั้น reducer แบบด้านล่างจึงผิด

export default (state = initialState, action) => {
  switch(action.type) {
    // เมื่อ action คือการเพิ่มเพจใหม่ให้ทำส่วนนี้
    case 'ADD_PAGE':
      // ยัดเพจใหม่เข้าไปในวิกิทั้งหมดที่มีอยู่แต่เดิม
      // นี่คือการเปลี่ยนแปลง previous state
      // ไม่ควรทำ
      state.push(action.page)
      // จากนั้นก็โยน previous state ที่ผ่านการรุมยำเรียบร้อยแล้วออกไป
      return state
    default:
      return state
  }
}

ต้องบอกเลยครับการทำเช่นนี้เพื่อนๆจะได้แก้กรรมยัน production เลยฮะ มีสองสามเหตุผลที่ต้องคงความสดและซิงของ reducer เอาไว้ หนึ่งในนั้นที่อยากพูดถึงก็คือการใช้งานคู่กับ shouldComponentUpdate

ยังจำได้ไหมฮะ shouldComponentUpdate นั้นเราใช้เพื่อป้องกันไม่ให้คอมโพแนนท์ของเรา rerender ใหม่หลายๆรอบ เช่น

shouldComponentUpdate(nextProps) {
  return this.props.pages !== nextProps.pages
}

ตัวอย่างข้างบนเป้นการตรวจสอบครับ ถ้า pages ก่อนหน้าและ pages ปัจจุบันตรงกัน แสดงว่าข้อมูลของเราอัพเดทแล้ว ไม่มีความจำเป็นใดๆต้อง rerender คอมโพแนนท์ใหม่อีกครั้ง ถ้าใครยังงงตรงจุดนี้ ผมแนะนำให้กลับไปอ่าน [Day #2] สอนการใช้งาน React.js และการเรียกใช้งาน RESTful API ด้วย React อีกครั้งในหัวข้อ รู้จักกับ React Reconciliation

ก่อนจะพูดถึงการทำงานของ reducer และ shouldComponentUpdate อยากทบทวนเพื่อนๆเรื่องนี้ก่อนครับ

// arr ไม่ได้เก็บค่า [1, 2, 3, 4] นะครับ
// แต่สิ่งที่มันเก็บคือ memory address
const arr = [1, 2, 3]

const add4 = (arr) => {
  // ฉะนั้นแล้วการแก้ไข array ด้วยการเพิ่มของเข้าไปใหม่
  // จึงไม่ได้เป็นการเปลี่ยน memory address
  arr.push(4)
  return arr
}

const newArr = add4(arr)

console.log(newArr) // [1, 2, 3, 4]
// ผลของการเปรียบเทียบจึงเท่ากัน เพราะ memory address ไม่เปลี่ยน
// เปลี่ยนแต่ค่าข้างใน
console.log(arr === newArr) // true

เห็นอะไรไหมเอ่ย… ถ้าเราแก้ไขอาร์เรย์โดยตรงจากใน reducer นั่นหมายความว่า return this.props.pages !== nextProps.pages ใน shouldComponentUpdate จะคืนค่ากลับเป็น true เสมอ ถ้าเพื่อนๆคนไหนอ่านแล้วยังงงแนะนำให้อ่าน 7 เรื่องพื้นฐานชวนสับสนใน JavaScript สำหรับผู้เริ่มต้น ในหัวข้อ 3. Equality Operators ครับ

สงสัยกันไหมครับทำไมถึงชื่อ reducer? มันมาจากพฤติกรรมของมันครับ ใน JavaScript เรามี Array#reduce เพื่อใช้ลดไอเทมในอาร์เรย์ให้เหลือเป็นค่าตัวเดียวออกมา เช่น

[1, 2, 3, 4].reduce((sum, item) => sum + item, 0) // 10

reducer ใน Redux ก็เช่นกันมีไว้ reduce สถานะของแอพพลิเคชันมันจึงได้ชื่อว่าเป็น reducer

ความจริงมีเพียงหนึ่งเดียว!

ตอนนี้เรามี reducer ตัวเดียวสำหรับเปลี่ยนสถานะของ pages ถ้าในอนาคตเรามี reducers เพิ่มขึ้นอีกหลายๆตัว เช่นอาจมี reducer สำหรับ users นั่นหมายความว่าตอนนี้สถานะของแอพพลิเคชันเรามีมากกว่าหนึ่งตัว มีทั้งสถานะของ pages และ users เราจะเก็บสถานะทั้งสองนี้แบบไหนดี?

ทางออกที่หนึ่ง ในเมื่อเรามีสองสถานะ เราก็เห็บแต่ละสถานะแยกออกจากกันในแต่ละ store ซิ พูดง่ายๆก็คือให้มี store ไว้เก็บสถานะของ pages และมีอีก store ไว้เก็บสถานะของ users ในมุมมองของคอมโพแนนท์นั้นให้เลือกสนใจเฉพาะ store ที่เราต้องการ state จากมัน เช่นคอมโพแนนท์ Index.js ที่ต้องการแสดงวิกิทั้งหมดก็ควรสนใจแค่ store ของ pages โดยเมินเฉยต่อ store ของ users

อีกวิธีหนึ่ง เราเก็บทุกๆสถานะไว้ใน store เพียงแค่ตัวเดียวไปเลย นั่นหมายความว่าทุกๆคอมโพแนนท์ที่เกี่ยวข้องจะ subscribe ไปที่ store เพียงตัวเดียวแล้วเลือกหยิบเฉพาะ state ที่คอมโพแนนท์นั้นสนใจไปใช้งาน

Redux เลือกวิธีเก็บ state แบบหลังครับ นั่นคือเก็บทุก state ไว้ใต้ store เดียว สาเหตุที่ Redux เลือกวิธีนี้เป็นเพราะมันง่ายต่อการจัดการครับ ผมจะยกตัวอย่างนึงให้ฟัง บางที state แต่ละตัวไม่ได้แยกขาดออกจากกันครับเช่น ถ้าเราอยู่หน้าแสดงวิกิที่จะแสดงเฉพาะวิกิที่เราสร้าง คอมโพแนนท์ของเราจะผูกติดอยู่กับ state ของ pages และ users ดังนั้นถ้าเราต้องการ debug โปรแกรมแล้วละก็เราคงต้องการเห็น state ทั้งหมดในครั้งเดียวใช่ไหมครับ? ถ้าเราเก็บทุก state ไว้ใน store เดียว เราแค่เรียก store.getState() state ทั้งหมดก็จะออกมา เราสามารถดูความสัมพันธ์ของทั้งสอง state ได้ด้วยคำสั่งเดียว โดยไม่ต้องเรียก getState จากทีละ store นี่ละครับความจริงมีหนึ่งเดียวตามคอนเซ็ปต์ของ Redux ที่ว่า Single source of truth

วิธีการที่จะทำให้ได้มาซึ่ง store เดียวนั้นคือการรวมทุกๆ reducer เข้าด้วยกัน ยังจำได้ไหมเอ่ย reducer เป็นเพียงตัวจัดการเปลี่ยน state มันจะคืนค่ากลับออกมาเป็น state ใหม่ซึ่ง state ที่ได้มันก็คือก้อนอ็อบเจ็กต์ดีๆนี่เอง ทีนี้ถ้าเรามีหลาย reducer ไว้จัดการหลายๆ state เราก็ต้องรวม reducer เข้าด้วยกัน เพราะแต่ละ reducer จะคืนค่าเป็นอ็อบเจ็กต์เราจึงต้องรวมอ็อบเจ็กต์ทั้งหมดให้เป็นก้อนเดียว เพื่อจัดเก็บไว้เป็นสิ่งเดียวใน store

// state ที่ได้จาก pages reducer
{
  pages: [
    {
      id: 1,
      title: 'title#1',
      content: 'content#1'
    },
    {
      id: 2,
      title: 'title#2',
      content: 'content#2'
    }
  ]
}

// state ที่ได้จาก users reducer
{
  users: [
    {
      id: 1,
      email: '[email protected]',
      name: 'babel coder'
    }
  ]
}

// เพื่อที่จะให้ state ที่แยกจากกันรวมเป็นหนึ่งเดียว
// เราจึงต้องรวบรวม state ที่ได้จากแต่ละ reducer เข้าด้วยกัน
// ผลลัพธ์สุดท้ายเป็นดังนี้
{
  pages: [
    {
      id: 1,
      title: 'title#1',
      content: 'content#1'
    },
    {
      id: 2,
      title: 'title#2',
      content: 'content#2'
    }
  ],
  users: [
    {
      id: 1,
      email: '[email protected]',
      name: 'babel coder'
    }
  ]
}
// คอมโพแนนท์ที่ subscribe store จะเลือกเอาเฉพาะ state ที่ตนเองสนใจไปใช้งาน

สร้างไฟล์ index.js ขึ้นมาภายใต้โฟลเดอร์ reducers ครับ เราจะทำการรวม state จากแต่ละ reducer กันแล้ว

import { combineReducers } from 'redux'
import pages from './pages'

// ใช้ combineReducers เพื่อรวม reducer แต่ละตัวเข้าเป็นหนึ่ง
export default combineReducers({
  // ES2015 มีค่าเท่ากับ pages: pages
  // pages ตัวแรกที่เป็น key ของอ็อบเจ็กต์บอกว่า
  // เราจะใช้คำว่า pages เป็นคำในการเข้าถึง
  pages
})

ไหนละ store? พูดถึงมันกันมาตั้งเยอะแต่นี้มีแต่ภาพล่องหน สร้างไฟล์ชื่อ configureStore.js ไว้ใต้โฟลเดอร์ store ครับแล้วเพิ่มโค๊ดตามนี้

import { createStore } from 'redux'
import rootReducer from '../reducers'

export default () => {
  // วิธีการสร้าง store คือการเรียก createStore
  // โดยผ่าน reducer ตัวบนสุดหรือตัวที่เรารวม reducer ทุกตัวเข้าด้วยกัน
  // เราจะได้ store กลับออกมาเป็นผลลัพธ์
  const store = createStore(rootReducer)
  
  if (module.hot) {
    // เมื่อใดที่โค๊ดใน reducer เปลี่ยนแปลงเราแค่ HMR มัน
    // จำได้ไหมครับในตอนต้นที่ผมบอกว่าเราแยก state ไปไว้ใน store
    // แล้วแยกวิะีการเปลี่ยน state ไปไว้ใน reducer
    // เพราะต้องการให้ทุกครั้งที่แก้โค๊ด reducer แล้ว webpack จะ HMR เฉพาะ reducer
    // โดย state ปัจจุบันยังคงอยู่
    System.import('../reducers').then(nextRootReducer =>
      store.replaceReducer(nextRootReducer.default)
    )
  }

  return store
}

เราห่อหุ้มคอมโพแนนท์ของเราด้วย connect เพื่อใช้ความสามารถของ Redux ในการเข้าถึง state ใน store เบื้องหลังการทำงานที่จะทำให้การเรียกใช้ connect สำเร็จคือการห่อหุ้มคอมโพแนนท์ไว้ภายใต้ Provider เปิดไฟล์ Root.js แล้วแก้ไขตามนี้ครับ

import React, { Component } from 'react'
import { Provider } from 'react-redux'
import configureStore from '../store/configureStore'
import routes from '../routes'

export default class App extends Component {
  render() {
    return (
      // เนื่องจากมีหลายคอมโพแนนท์ที่เรียก connect ได้
      // เราจึงครอบ Provider ไว้รอบ routes
      // เพราะเรารู้ว่าที่ตรงนี้คือคอมโพแนนท์บนสุดแล้ว
      // เมื่อคอมโพแนนท์ต่างๆภายใต้นี้เข้าถึง connect
      // จะอ้างอิงถึง store ได้ทันที
      <Provider store={configureStore()} key='provider'>
        {routes()}
      </Provider>
    )
  }
}

เอาหละฮะจบไปแล้วครึ่งนึงของบทความนี้ เพื่อนๆลองรัน npm start แล้วเข้าไปยลโฉมหน้าความพยายามของพวกเราผ่าน Redux ได้เลย!

สรุปครึ่งแรกของบทความจาก Actions, Reducers สู่ Store

ทุกคนครับ ตอนนี้ขอให้ทุกคนจำภาพนี้ให้ขึ้นใจก่อน ผมจะเริ่มสรุปตั้งแต่ต้นตามภาพนี้ครับ

redux diagram6

เมื่อผู้ใช้งานระบบกดปุ่ม reload pages บนหน้ารวมวิกิ สิ่งต่างๆต่อไปนี้จะเกิดขึ้น…

  1. คอมโพแนนท์เรียก action creator เพื่อสร้างก้อนอ็อบเจ็กต์ของ action
export const loadPages = () => ({
  type: 'RECEIVE_PAGES',
  pages: [
    {
      "id": 1,
      "title": "test page#1",
      "content": "TEST PAGE CONTENT"
    }, {
      "id": 2,
      "title": "test page#2",
      "content": "TEST PAGE CONTENT"
    }
  ]
})
  1. คอมโพแนนท์ส่ง action ไปให้ reducer ผ่าน dispatch
const mapDispatchToProps = (dispatch) => ({
  onLoadPages() {
    dispatch(loadPages())
  }
})
  1. action ลอยไปตามสายลมจนถึง reducer ตัวบนสุดที่เราเรียกว่า reducer
export default combineReducers({
  pages
})
  1. root reducer รู้ว่าตัวเองมีลูกเป็น reducer อีกหนึ่งตัวชื่อ pages จึงส่ง action ลงไปให้ reducer ตัวลูก
  2. pages reducer มีวิธีการจัดการกับ action ชื่อ RECEIVE_PAGES จึงเริ่มทำงาน
export default (state = initialState, action) => {
  switch(action.type) {
    case 'RECEIVE_PAGES':
      return action.pages
    default:
      return state
  }
}
  1. เมื่อ pages reducer ทำงานเสร็จ root reducer จะนำส่ง state ใหม่นี้ไปให้ store
  2. คอมโพแนนท์ subscribe store ผ่าน connect จึงรับรู้ถึงการเปลี่ยนแปลงของ state
export default connect(
  mapStateToProps,
  { onLoadPages: loadPages }
)(PagesContainer)
  1. คอมโพแนนท์เลือกเฉพาะ state ที่ตนเองสนใจเพื่อนำไปแสดงผลต่อไป
const mapStateToProps = (state) => ({
  pages: state.pages
})

เอาหละครับจบแล้วครึ่งแรก ใครจะพักแล้วเก็บไว้อ่านต่อวันหลังก็ได้นะครับ แต่ใครที่ยังไหว เราไปลุยกันต่อกับครึ่งหลังของบทความกันครับ!

Hello Middleware

ตอนนี้ใน actions/page.js ของเราใส่ก้อน json ของวิกิที่เราตั้งขึ้นมาเองมั่วๆ ดังนี้

{
  pages: [
    {
      "id": 1,
      "title": "test page#1",
      "content": "TEST PAGE CONTENT"
    }, {
      "id": 2,
      "title": "test page#2",
      "content": "TEST PAGE CONTENT"
    }
  ]
}

แต่ในการใช้งานจริงเราต้องการดึงข้อมูลจากเซิร์ฟเวอร์มาแสดงผลต่างหาก ฉะนั้นแล้วเรามาเปลี่ยนแปลงโค๊ดกันนิดหน่อยใน actions/page.js เพื่อสนับสนุนให้โหลดข้อมูลมาจากเซิร์ฟเวอร์

import fetch from 'isomorphic-fetch'
import { PAGES_ENDPOINT } from '../constants/endpoints'

// เมื่อได้รับข้อมูล pages จากเซิร์ฟเวอร์แล้ว
// ให้ส่งเข้า reducer ไปเลย
const receivePages = (pages) => ({
  type: 'RECEIVE_PAGES',
  pages
})

export const loadPages = () => (
  // ดึงข้อมูลจากเซิร์ฟเวอร์
  // ฟังก์ชันนี้ return promise ออกไปนะครับ
  // สังเกตดีๆสิ่งที่คืนออกไปไม่ใช่ก้อนอ็อบเจ็กต์ของ action ละนะ
  fetch(PAGES_ENDPOINT)
    .then((response) => response.json())
    // เมื่อได้รับข้อมูลแล้ว จึงส่งไปให้ receivePages ซึ่งเป็น action creator
    // ให้ช่วยสร้าง type และห่อหุ้มข้อมูลเป็นก้่อนอ็อบเจ็กต์ของ action
    .then((pages) => receivePages(pages))
)

จากนั้นก็ไปลบโค๊ดที่เราไม่ต้องการใช้แล้วใน containers/Pages/Index.js โค๊ดที่สมบูรณ์เป็นดังนี้

import React, { Component, PropTypes } from 'react'
import { connect } from 'react-redux'
import { loadPages } from '../../actions/page'
import { Pages } from '../../components'

class PagesContainer extends Component {
  static propTypes = {
    pages: PropTypes.array.isRequired,
    onLoadPages: PropTypes.func.isRequired
  }

  shouldComponentUpdate(nextProps) {
    return this.props.pages !== nextProps.pages;
  }

  onReloadPages = () => {
    this.props.onLoadPages()
  }

  componentDidMount() {
    this.onReloadPages()
  }

  render() {
    return (
      <Pages
        pages={this.props.pages}
        onReloadPages={this.onReloadPages} />
    )
  }
}

const mapStateToProps = (state) => ({
  pages: state.pages
})

export default connect(
  mapStateToProps,
  { onLoadPages: loadPages }
)(PagesContainer)

ท้าวความกันนิดนึงครับ เวลาเราเรียกให้ reducer ทำงานเราจะเรียกผ่าน dispatch ใช่ไหม dispatch เนี่ยจะรับก้อนอ็อบเจ็กต์ของ action เข้าไป แต่ตอนนี้สิ่งที่เราทำอยู่คือเรียก dispatch(loadPages()) เมื่อมีคนคลิกปุ่ม reload pages แต่เอ… loadPages ของเรามันคืนค่ากลับมาเป็น promise นะครับ แต่ dispatch ของเราปรารถนาให้ส่งก้อน action ต่างหากละ?

เพื่อให้การทำงานถูกต้อง เราจึงต้องไปแอบแก้ไข dispatch ให้สามารถทำงานร่วมกับ promise ได้ เปิดไฟล์ configureStore.js แล้วเพิ่มเติมตามนี้เลยครับ

import { createStore } from 'redux'
import rootReducer from '../reducers'

// ก่อนจะมาอ่านตรงนี้ อ่านคอมเม้นข้างล่างตรง store.dispatch ก่อนนะครับ
// เรารับ store เข้ามาเพื่อเข้าถึง dispatch
const promise = (store) => {
  // next ในที่นี้คือ dispatch ตัวดั้งเดิม
  const next = store.dispatch

  // เนื่องจากเราจะสร้าง dispatch ตัวใหม่
  // เราจึงต้องทำตัวให้เหมือน dispatch
  // dispatch นั้นรับ action เข้ามา เราจึงต้องรับ action เช่นกัน
  return (action) => {
    // ตรวจสอบซักหน่อย ถ้า action มี then เป็นฟังก์ชันแสดงว่ามันเป็น promise
    if(typeof action.then === 'function')
      // เมื่อเป็น promise เราจึงให้มันทำงานให้เสร็จก่อน
      // จากนั้นจึงค่อยเรียก dispatch ตัวดังเดิมทำงานต่อไป
      return action.then(next)
    return next(action)
  }
}

export default () => {
  const store = createStore(rootReducer)
  // เปลี่ยนแปลงการทำงานของ dispatch นิดนึงแล้วกัน
  // เราบอกว่าให้ dispatch นั้นมีค่าเป็นสิ่งที่คืนกลับมาจากการเรียก promise(store)
  store.dispatch = promise(store)

  if (module.hot) {
    module.hot.accept('../reducers', () => {
      const nextRootReducer = require('../reducers')
      store.replaceReducer(nextRootReducer)
    })
  }

  return store
}

จะเห็นว่าเรื่องราวชั่งซับซ้อนครับ เราต้องเข้าใจเรื่อง promise พอสมควร เพื่อนๆคนไหนยังสับสนแนะนำให้อ่านเรื่อง รู้ลึกการทำงานแบบ Asynchronous กับ Event Loop และ กำจัด Callback Hell ด้วย Promise และ Async/Await

ลำดับการทำงานของ dispatch หลังเราปู้ยี้ปู้ยำมันเป็นดังนี้ ตรวจสอบก่อนว่าเป็น promise ไหม ถ้าเป็นจัดการแก้ปัญหาชีวิตซะก่อนแล้วค่อยเรียก dispatch ตัวเดิมมาทำงาน แต่ถ้าไม่เป็น promise ก็แค่เรียก dispatch ตัวปกติมาทำงานได้เลย

ทำไมเราต้อง resolve promise ก่อน? นั่นเป็นเพราะถ้าเราไม่ทำ promise ก่อนแล้ว จะไม่มีสิ่งใดตอบกลับมาจากเซิร์ฟเวอร์นั่นเอง เมื่อเซิร์ฟเวอร์โยนข้อมูลกลับมาแล้ว จะสังเกตเห็นว่าเราเรียก action creator ชื่อ receivePages ต่ออีกทีซึ่งตัวนี้จะสร้างอ็อบเจ็กต์ของ action ขึ้นมา และนี่หละครับคือจุดที่ dispatch ตัวดั้งเดิมจะเข้ามามีบทบาท บทบาทของมันก็คือรับ action ตัวนี้เข้าไปส่งมอบให้ reducer นั่นเอง

จะเห็นว่าสิ่งที่เราทำนั้นเป็นคาบเกี่ยวระหว่างการ dispatch action ไปให้ reducer สิ่งที่เราทำเพื่อแทรกกลางระหว่างสองเรานี้เรียกว่า Middleware ตัวละครตัวสุดท้ายของเราที่จะมีหรือไม่มีก็ได้

redux diagram7

จัดการ middleware อย่างชาญฉลาดด้วยการใช้ของชาวบ้าน!

นอกจากเราจะเป็น Google Programmer ที่พัฒนาโปรแกรมด้วยแบบแผน Stackoverflow-driven Development แล้ว เรายังประกอบโค๊ดเป็นจิ๊กซอว์ด้วยการไม่เขียนเองแต่ใช้ของที่ชาวบ้านทำไว้แล้ว จับมายำๆผสมในโปรเจคเราเอง

จากที่ผ่านมาเราดึงข้อมูลจากเซิร์ฟเวอร์มาแสดงผล เมื่อข้อมูลมาถึงก็จับไปแสดงบนหน้าจอ แต่ถ้าข้อมูลยังไม่มาก็ไม่มีอะไรเกิดขึ้น จะดีกว่าไหมถ้าเราจะขึ้นข้อความว่า กำลังโหลดอยู่นะจ๊ะ ถ้าข้อมูลยังไม่มา และขึ้นข้อความว่า ซวยแหล่วๆ เมื่อเซิร์ฟเวอร์ส่งข้อผิดพลาดกลับมา

เพื่อให้ทำได้ตามที่คาดหวัง เราจึงต้องแยกประเภทของ action เสียใหม่เป็นสามประเภท คือ LOAD_PAGES_REQUEST ที่จะเรียกเมื่อเริ่มส่งคำร้องไปหาเซิร์ฟเวอร์, LOAD_PAGES_SUCCESS เมื่อเซิร์ฟเวอร์ตอบข้อมูลกลับมา และสุดท้ายคือ LOAD_PAGES_FAILURE หากเซิร์ฟเวอร์ตอบกลับมาด้วยข้อผิดพลาด

เรามาแก้ actions/page.js ให้เป็นไปตามนี้กันครับ

import fetch from 'isomorphic-fetch'
import { PAGES_ENDPOINT } from '../constants/endpoints'

export const loadPages = () => {
  // เมื่อเรียก loadPages จะคืนค่ากลับเป็นฟังก์ชันที่รับ dispatch เข้ามา
  return (dispatch) => {
    // ก่อนอื่นเมื่อเรียก loadPages ก็ให้สร้าง action เพื่อบอกว่ากำลังโหลดนะ
    dispatch({
      type: 'LOAD_PAGES_REQUEST'
    })

    fetch(PAGES_ENDPOINT)
      .then((response) => response.json())
      .then(
        // เมื่อโหลดเสร็จแล้วก็สร้าง action เพื่อบอกว่าสำเร็จแล้ว
        // พร้อมส่ง pages วิ่งเข้าไปใน reducer
        (pages) => dispatch({
          type: 'LOAD_PAGES_SUCCESS',
          pages
        }),
        // หากเกิดข้อผิดพลาด ใช้ action ตัวนี้
        (error) => dispatch({
          type: 'LOAD_PAGES_FAILURE'
        })
      )
  }
}

ฟังก์ชัน loadPages ของเราตอนนี้เป็น Higher-order Function ไปซะแล้ว ตอนนี้มันคืนค่ากลับออกมาเป็นฟังก์ชันอีกตัวที่รับ dispatch เข้าไปในตัวมันเพื่อให้เราสามารถ dispatch ทุกสรรพสิ่งที่เราปรารถนาภายใต้ฟังก์ชันนั้น แต่… ในคอมโพแนนท์ของเราเวลาเรียกเราก็เรียกแค่ loadPages() นี่นา แต่มันส่งค่ากลับเป็นฟังก์ชันอีกทอดแบบนี้ต้องไปแก้อะไรในคอมโพแนนท์ไหม?

เราไม่ต้องจัดการอะไรเพิ่มเติมในคอมโพแนนท์ครับ เราอาศัยความสามารถในการแทรกกลางระหว่างเราของ Middleware ที่จะทำตัวเป็นตัวเผือกแทรกระหว่าง action และ reducer สิ่งที่เราต้องทำมีแค่สร้าง middleware ขึ้นมาตัวหนึ่ง โดย middleware ตัวนี้จะแอบยัด dispatch เข้าไปให้ ไปดูกันเลย เปิดไฟล์ configureStore.js แล้วแก้ไขตามนี้

import { createStore, applyMiddleware } from 'redux'
import rootReducer from '../reducers'

// Middleware ตัวนี้ละฮะที่เขามาแทรกกลางระหว่างสองเรา
// สังเกตการสร้าง middleware ใน Redux ให้ดีนะครับ
// มันช่างเป็นฟังก์ชันซ้อนฟังก์ชันซะจริง
// จำรูปแบบไว้ง่ายๆว่า store => next => action
const thunk = (store) => (next) => (action) =>
  // ถ้า action เป็น function เราก็ยัดเยียด dispatch เข้าไปเลย
  // ในที่นี้เราส่งทั้ง dispatch และ getState เข้าไป
  // ในส่วนของ action เราต้องการใช้แค่ dispatch เราก็เลยไม่อ้างถึง getState
  typeof action === 'function' ?
    action(store.dispatch, store.getState) :
    // แต่ถ้าใน action creator เราไม่ได้ประกาศว่า (dispatch) => {...}
    // ก็คือไม่ได้ประกาศคืนค่ากลับเป็นฟังก์ชัน
    // เราก็เรียกมันทำงานซะเลย โดยไม่ส่ง dispatch กับ getState ไปให้
    next(action)

export default () => {
  // ประกาศ middlewares เป็นอาร์เรย์ซะก่อน
  // ที่ทำเช่นนี้เพราะในอนาคตเราอาจได้ใช้ middleware ตัวอื่นอีก
  // เราสามารถเพิ่มทีหลังเข้าไปในอาร์เรย์นี้ได้
  const middlewares = [thunk]
  const store = createStore(
    rootReducer,
    // จะใช้ middleware อะไรก็ยัดเยียดเข้าไปใน applyMiddleware ซะเลย
    applyMiddleware(...middlewares)
  )

  if (module.hot) {
    module.hot.accept('../reducers', () => {
      System.import('../reducers').then(nextRootReducer =>
        store.replaceReducer(nextRootReducer.default)
      )
    })
  }

  return store
}

สุดท้ายเมื่อเราแก้ชื่อ action ก็อย่าลืมไปอัพเดทกันใน reducers/pages.js นะครับ

const initialState = []

export default (state = initialState, action) => {
  switch(action.type) {
    case 'LOAD_PAGES_SUCCESS': << ไปมองที่ไหนเล่า อยู่ตรงนี้!
      return action.pages
    default:
      return state
  }
}

แบบฝึกหัด: ให้สร้างคอมโพแนนท์ขึ้นมาหนึ่งตัวตั้งชื่อว่า FlashMessage โดยให้คอมโพแนนท์นี้อยู่ใต้ Navbar ทุกครั้งที่หน้า Index ของวิกิเริ่มโหลดหรือผู้ใช้งานกดปุ่ม reload pages ให้แสดงข้อความว่า Loading ในคอมโพแนนท์ FlashMessage ในทางกลับกันให้เพิ่มข้อผิดพลาดเข้าไปในคอมโพแนนท์เมื่อเซิร์ฟเวอร์ตอบกลับมาด้วยข้อผิดพลาด เมื่อใดที่การโหลดวิกิสำเร็จให้ยกเลิกการแสดงคอมโพแนนท์นั้น Hint: เพิ่ม action ที่เกี่ยวข้องลงใน actions/page.js รวมถึงเพิ่มสถานะบางอย่างที่บ่งบอกถึงว่ากำลังโหลดหรือมีข้อผิดพลาดเกิดขึ้น

เอาหละได้เวลาแสดงความขี้เกียจกันแล้ว เราจะไม่มานั่งเขียน thunk กันเองรวมถึงไม่มานั่งจัดการกับ AJAX อีกต่อไปเพราะเรานั้่นขี้เกียจ! ในที่นี้ผมจะใช้ middleware ตัวนึงเข้ามาช่วยในการติดต่อเพื่อรับข้อมูลจากเซิร์ฟเวอร์นั่นคือ redux-api-middleware ติดตั้งตามนี้เลยครับ

npm i --save redux-api-middleware redux-thunk

เมื่อทำการติดตั้งเสร็จแล้ว เราก็ต้องรายงานให้ Redux นั้นรับรู้ว่าเราจะใช้ middleware ตัวนี้นะ

// configureStore.js
import { createStore, applyMiddleware } from 'redux'
import thunk from 'redux-thunk'
import { apiMiddleware } from 'redux-api-middleware'
import rootReducer from '../reducers'

export default () => {
  // ใช้ middleware ตัวที่พึ่งติดตั้งไป
  const middlewares = [thunk, apiMiddleware]
  const store = createStore(
    rootReducer,
    applyMiddleware(...middlewares)
  )

  ...
  ...

  return store
}

กฏเกณฑ์การใช้งานนั้นแสนเรียบง่าย เพียงแค่เราบอกว่าจะมี action อะไรสำหรับการร้องขอข้อมูล การได้รับข้อมูลแล้ว และการจัดการข้อผิดพลาดจากเซิร์ฟเวอร์ เราเขียนบอก action สามตัวนี้เข้าไปใน action creator ที่เหลือ redux-api-middleware จะจัดการให้เอง

// actions/page.js
import { CALL_API } from 'redux-api-middleware'
import { PAGES_ENDPOINT } from '../constants/endpoints'

export const loadPages = () => ({
  // ต้องมี Symbol ตัวนี้เพื่อบอกให้ redux-api-middleware รับทราบ
  // ว่าสิ่งที่อยู่ในนี้มันควรเป็นผู้จัดการ
  // หากปราศจาก Symbol ตัวนี้
  // redux-api-middleware จะเมินเฉยไม่สนใจ
  [CALL_API]: {
    endpoint: PAGES_ENDPOINT,
    method: 'GET',
    types: ['LOAD_PAGES_REQUEST', 'LOAD_PAGES_SUCCESS', 'LOAD_PAGES_FAILURE']
  }
})

redux-api-middleware นั้นจะคืนค่าจากเซิร์ฟเวอร์กลับมาในรูปของ payload หน้าตาของก้อนอ็อบเจ็กต์ที่ redux-api-middle ปั้นแต่งนั้นมีลักษณะแบบนี้

{
  type: 'LOAD_PAGES_SUCCESS',
  // ถ้าเซิร์ฟเวอร์คืนค่ากลับเป็น { pages: {....} }
  // ผลลัพธ์ที่ได้จะเป็น { payload: { pages: [...] } }
  // แต่เซิร์ฟเวอร์ของเราส่งกลับเป็นอาร์เรย์เลย จึงไม่มี pages ซ้อนอีกชั้น
  payload: [
    { id: 1, title: 'Title#1', content: 'Content#1' }
  ]
}

และเจ้าก้อนอ็อบเจ็กต์ตัวนี้หละฮะ ที่จะล่องลอยไปตามสายลมเข้าสู่ reducer ฉะนั้นแล้วเราต้องไปแก้ reducers/pages.js ตามนี้

const initialState = []

export default (state = initialState, action) => {
  switch(action.type) {
    case 'LOAD_PAGES_SUCCESS':
      // จากเดิมเป็น action.pages
      // แต่ตอนนี้ก้อนอ็อบเจ็กต์ที่เข้ามาอยู่ในชื่อ payload แล้ว
      return action.payload
    default:
      return state
  }
}

สิ่งสุดท้ายที่เราจะพูดถึงในหัวข้อนี้นั่นคือ logger รู้สึกไหมครับเราอยากรู้อยากเห็นว่าตอนนี้แอพพลิเคชันของเราทำ action อะไรไปแล้วบ้าง ก่อนทำ action สถานะของแอพพลิเคชันเราเป็นอย่างไร และหลังทำ action แล้วสถานะของแอพพลิเคชันเราเปลี่ยนไปอย่างไร ด้วยความสามารถของ redux-logger เวทย์มนต์นี้จึงเกิดขึ้น ติดตั้ง redux-logger ด้วยคำสั่งนี้

npm i --save-dev redux-logger

เข้าไปแก้ไข configuteStore.js อีกครั้งเพื่อเรียกใช้ redux-logger

import { createStore, applyMiddleware } from 'redux'
import { apiMiddleware } from 'redux-api-middleware'
import createLogger from 'redux-logger'
import rootReducer from '../reducers'

export default () => {
  const middlewares = [apiMiddleware]
  
  // คงไม่มีใครอยากให้ logger ไปพ่นๆข้อความใน production ใช่ไหม
  // เราจึงตรวจสอบก่อน ถ้าเป็น production แล้วริวจะไม่ยุ่ง
  if(process.env.NODE_ENV !== 'production')
    // แต่ถ้าไม่ใช่ production เจนคงสัมผัสได้...
    middlewares.push(createLogger())

  const store = createStore(
    rootReducer,
    applyMiddleware(...middlewares)
  )

  if (module.hot) {
    module.hot.accept('../reducers', () => {
      System.import('../reducers').then(nextRootReducer =>
        store.replaceReducer(nextRootReducer.default)
      )
    })
  }

  return store
}

npm start อีกครั้งแล้วดูผลลัพธ์ที่เกิดขึ้นได้เลยครับ…

สรุปตัวละครทั้งหมดของ Redux

redux diagram7

รูปเดิมเอามาหากินอีกครั้ง ดูประกอบแล้วทำความเข้าใจตามครับ เมื่อมีเหตุการณ์หนึ่งๆเกิดขึ้นบนคอมโพแนนท์ สิ่งเหล่านี้จะเกิดขึ้นเป็นลำดับ

  • คอมโพแนนท์รับรู้ว่ามี event เกิดขึ้น
  • คอมโพแนนท์ไม่รู้วิธีจัดการสถานะของแอพพลิเคชันตาม event ที่เกิดขึ้น
  • คอมโพแนนท์จึงเรียก action creator เพื่อสร้างก้อนอ็อบเจ็กต์ของ action
  • เราได้ action ที่ประกอบด้วย type และข้อมูลมาแล้ว
  • คอมโพแนนท์เรียก dispatch เพื่อโยน action นั้นให้ล่องลอยไปหวังว่าจะมีใครซักคนคอยรับไปจัดการ
  • ถ้ามี middleware มันจะเข้าไปปู้ยี่ปู้ยำก้อน action นั้นก่อนส่งต่อให้ reducer
  • root reducer จมูกไว เจอกลิ่นของ action จึงรับเข้ามาอุปการะ
  • root reducer ส่ง action เป็นทอดๆไปให้ reducer อื่นๆที่เป็นลูกหลานเหลนโหลน
  • reducer แต่ละตัวถ้าเขียนจับเอาไว้ให้ตอบสนองต่อ action นั้น มันจะรับ action นั้นเข้าไปทำงาน
  • reducer reduce state ก่อนหน้าให้เป็น state ใหม่ ตามประเภทของ action ที่ส่งเข้ามา
  • state ใหม่ที่ได้จะไปเก็บเป็นสถานะใหม่ของแอพพลิเคชันใน store
  • คอมโพแนนท์เองนั้นมีต่อมเผือกที่ผูกพันธ์อยู่กับ store เมื่อ state ใน store อัพเดท คอมโพแนนท์จะรับช่วงต่อ
  • คอมโพแนนท์เลือกเฟ้นเฉพาะ state ที่ตนเองสนใจจากสถานะทั้งหมดที่ได้รับมา
  • คอมโพแนนท์ rerender ใหม่ด้วยสถานะใหม่ที่ได้รับ

ปรับเปลี่ยนหน้า Show ด้วย Redux

เราแก้ไขหน้า Index กันแล้ว ต่อไปเป็นตาของหน้า Show บ้างครับ ลงมือแก้ไขตามนี้เลยครับ

// actions/page.js
import { CALL_API } from 'redux-api-middleware'
import { PAGES_ENDPOINT } from '../constants/endpoints'

export const loadPages = () => ({
  [CALL_API]: {
    endpoint: PAGES_ENDPOINT,
    method: 'GET',
    types: ['LOAD_PAGES_REQUEST', 'LOAD_PAGES_SUCCESS', 'LOAD_PAGES_FAILURE']
  }
})

// สำหรับโหลดวิกิแค่เพจเดียว
export const loadPage = (id) => ({
  [CALL_API]: {
    endpoint: `${PAGES_ENDPOINT}/${id}`,
    method: 'GET',
    types: ['LOAD_PAGE_REQUEST', 'LOAD_PAGE_SUCCESS', 'LOAD_PAGE_FAILURE']
  }
})

// reducers/pages.js
const initialState = []

export default (state = initialState, action) => {
  switch(action.type) {
    case 'LOAD_PAGES_SUCCESS':
      return action.payload
    // เมื่อโหลดวิกิมาเพจเดียวก็ให้สถานะของแอพพลิเคชันเรามีเพจเดียว
    case 'LOAD_PAGE_SUCCESS':
      return [action.payload]
    default:
      return state
  }
}

// เวลาเข้าหน้า Show เราจะเอาวิกิเพจไหนมาแสดงให้ค้นหาด้วย ID
export const getPageById = (state, id) => (
  state.pages.find((page) => page.id === +id) || { title: '', content: '' }
)

// containers/Show.js
import React, { Component, PropTypes } from 'react'
import { connect } from 'react-redux'
import { loadPage } from '../../actions/page'
import { getPageById } from '../../reducers/pages'
import { ShowPage } from '../../components'

class ShowPageContainer extends Component {
  static propTypes = {
    page: PropTypes.object.isRequired,
    onLoadPage: PropTypes.func.isRequired
  }
  
  shouldComponentUpdate(nextProps) {
    return this.props.page !== nextProps.page;
  }

  componentDidMount() {
    const { onLoadPage, params: { id } } = this.props
	// โหลดเพจตาม ID ที่ปรากฎบน URL
    onLoadPage(id)
  }

  render() {
    const { id, title, content } = this.props.page

    return (
      <ShowPage
        id={id}
        title={title}
        content={content} />
    )
  }
}

const mapStateToProps = (state, ownProps) => ({
  // เลือกเพจด้วย ID
  page: getPageById(state, ownProps.params.id)
})

export default connect(
  mapStateToProps,
  { onLoadPage: loadPage }
)(ShowPageContainer)

เอาหละครับเพื่อนๆคงเกิดคำถามแน่ว่าทำไมตอนเราได้วิกิมาหนึ่งหน้า เราตั้งค่าให้ pages เป็นอาร์เรย์ที่ประกอบด้วยวิกิหน้าเดียวแล้ว แต่สุดท้ายต้องมาคุ้ยหาหน้าวิกิผ่าน ID อีก? นั่นเป็นเพราะการเข้าถึงวิกิโดยการเรียก state.pages[0] มันทำให้การกลับมาอ่านโค๊ดอีกครั้งลำบาก เราจะไม่เข้าใจเหตุผลเลยว่าทำไมต้องเข้าถึงตำแหน่งที่ 0 แต่ถ้าเราอ้างผ่าน ID มันเป็นการอธิบายโค๊ดในตนเองแล้วว่าเรากำลังทำอะไรอยู่

วิธีการนี้ยังไม่ดีพอครับ เพราะเราไม่สามารถ cache วิกิเพจได้เลย ทุกครั้งที่เราเปลี่ยนไปหน้า Show วิกิทั้งหมดที่เคยโหลดมาก็จะหายเหลือเพียงวิกิใหม่ตัวเดียว ทางแก้ก็คือจัดโครงสร้างใหม่ให้กับ state แบบนี้

{
  result: {
    pages: [1, 2, 3, 4]
  },
  entitles: {
    pages: [
      {
        id: 1,
        title: 'Title#1',
        content: 'Content#1
      },
      ...
      ...
      ...
    ]
  }
}

เมื่อเวลาเราได้วิกิหน้าใหม่มา เราแค่เพิ่ม ID เข้าไปใน result.pages พร้อมทั้งเพิ่มอ็อบเจ็กต์ตัวเต็มไว้ใน entities.pages เวลาเราจะเข้าถึงหน้า Show เราแค่เรียก entities.pages[ID] แค่นี้ก็ได้ผลลัพธ์กลับมาแล้ว ทั้งนี้เรายังคงแคชผลลัพธ์เก่าไว้ได้ด้วย นี่เป็นวิธีการที่ใช้ใน normalizr ซึ่งเราจะไม่กล่าวถึงกันในที่นี้ เชื่อว่าหลายคนคงภาวนาให้รีบจบบทความจะแย่แล้ว ใช่ไหมหละครับ ^^

ก่อนที่เราจะเดินหน้าไปกันต่อ มาทำโค๊ดให้ดูดีขึ้นกันก่อนครับ

Refactor กันซะหน่อย

อย่างแรกเลยที่เราจะทำคือย้ายค่าคงที่ไปไว้ข้างนอก บรรดา LOAD_PAGES_REQUEST, LOAD_PAGES_SUCCESS หรืออื่นๆพวกนี้เราควรแยกไปประกาศไว้อีกไฟล์ ทำไมหนะหรอครับ? เพราะว่าเวลาเราทำงานกันเป็นทีม ต่างคนต่างทำจะเกิดปัญหาชื่อ action ชนกันได้ ถ้าเราย้ายการประกาศชื่อ action ไปรวมกันไว้ที่ไฟล์หนึ่ง เวลาใครอยากสร้าง action ใหม่จะได้รู้ว่ามี action อะไรใช้ไปแล้วบ้าง

สร้างไฟล์ใหม่ชื่อ actionTypes.js ภายใต้โฟลเดอร์ constants

export const LOAD_PAGES_REQUEST = 'LOAD_PAGES_REQUEST'
export const LOAD_PAGES_SUCCESS = 'LOAD_PAGES_SUCCESS'
export const LOAD_PAGES_FAILURE = 'LOAD_PAGES_FAILURE'

export const LOAD_PAGE_REQUEST = 'LOAD_PAGE_REQUEST'
export const LOAD_PAGE_SUCCESS = 'LOAD_PAGE_SUCCESS'
export const LOAD_PAGE_FAILURE = 'LOAD_PAGE_FAILURE'

ProTips! สังเกตไหมครับทำไมเราประกาศตัวแปรด้วยชื่อที่เหมือนกันกับค่าของมันแล้ว เราต้องพิมพ์ซ้ำสองครั้ง? วิธีการนี้ขัดหลักการ DRY (Don’t Repeat Yourself) จะดีกว่าไหมถ้าเราพิมพ์แค่นี้ แต่ได้ผลลัพธ์เช่นเดียวกัน

{
  LOAD_PAGES_REQUEST: null,
  LOAD_PAGES_SUCCESS: null,
  ...
}

ความปรารถนาของคุณจะเป็นจริงครับถ้าใช้ keyMirror

เอาหละต่อไปก็แก้ไฟล์อื่นที่มีชื่อ action อยู่ด้วย

// reducers/pages.js
import {
  LOAD_PAGES_SUCCESS,
  LOAD_PAGE_SUCCESS
} from '../constants/actionTypes'

const initialState = []

export default (state = initialState, action) => {
  switch(action.type) {
    case LOAD_PAGES_SUCCESS:
      return action.payload
    case LOAD_PAGE_SUCCESS:
      return [action.payload]
    default:
      return state
  }
}

export const getPageById = (state, id) => (
  state.pages.find((page) => page.id === +id) || { title: '', content: '' }
)

// actions/page.js
import { CALL_API } from 'redux-api-middleware'
import { PAGES_ENDPOINT } from '../constants/endpoints'
import {
  LOAD_PAGES_REQUEST,
  LOAD_PAGES_SUCCESS,
  LOAD_PAGES_FAILURE,

  LOAD_PAGE_REQUEST,
  LOAD_PAGE_SUCCESS,
  LOAD_PAGE_FAILURE
} from '../constants/actionTypes'

export const loadPages = () => ({
  [CALL_API]: {
    endpoint: PAGES_ENDPOINT,
    method: 'GET',
    types: [LOAD_PAGES_REQUEST, LOAD_PAGES_SUCCESS, LOAD_PAGES_FAILURE]
  }
})

export const loadPage = (id) => ({
  [CALL_API]: {
    endpoint: `${PAGES_ENDPOINT}/${id}`,
    method: 'GET',
    types: [LOAD_PAGE_REQUEST, LOAD_PAGE_SUCCESS, LOAD_PAGE_FAILURE]
  }
})

เนื่องจากตอนนี้ mapStateToProps ของเราในแต่ละคอมโพแนนท์ช่างสั้นเหลือเกิน เราเอาไปรวมไว้ใน connect เลยครับ

// containers/Index.js
export default connect(
  (state) => ({ pages: state.pages }),
  { onLoadPages: loadPages }
)(PagesContainer)

// containers/Show.js
export default connect(
  (state, ownProps) => ({ page: getPageById(state, ownProps.params.id) }),
  { onLoadPage: loadPage }
)(ShowPageContainer)

ถึงเวลาสร้างวิกิแล้ว

หัวข้อนี้จะเป็นหัวข้อสุดท้ายสำหรับบทความนี้แล้ว เอ้าพวกเราดีใจกันหน่อย! เนื่องจากการสร้างวิกินั้นเราต้องทำงานกับฟอร์ม และเพื่อให้ชีวิตเราง่ายขึ้นผมแนะนำให้ใช้ redux-form เป็นตัวช่วยสร้างฟอร์มครับ ติดตั้งกันเลยรออะไรเล่า

npm i --save redux-form

ตอนนี้เราต้องสร้าง action ใหม่สำหรับการสร้างวิกิแล้ว เพิ่มสาม action ต่อไปนี้ลงใน actionTypes.js

export const CREATE_PAGE_REQUEST = 'CREATE_PAGE_REQUEST'
export const CREATE_PAGE_SUCCESS = 'CREATE_PAGE_SUCCESS'
export const CREATE_PAGE_FAILURE = 'CREATE_PAGE_FAILURE'

จากนั้นไปเพิ่ม action creator ตัวใหม่เพื่อใช้ในการสร้างวิกิที่ actions/page.js

import { CALL_API } from 'redux-api-middleware'
import { PAGES_ENDPOINT } from '../constants/endpoints'
import {
  ...
  ...

  CREATE_PAGE_REQUEST,
  CREATE_PAGE_SUCCESS,
  CREATE_PAGE_FAILURE
} from '../constants/actionTypes'

...
...

export const createPage = (values) => ({
  [CALL_API]: {
    endpoint: PAGES_ENDPOINT,
    headers: {
      'Accept': 'application/json',
      'Content-Type': 'application/json'
    },
    method: 'POST',
    body: JSON.stringify(values),
    types: [CREATE_PAGE_REQUEST, CREATE_PAGE_SUCCESS, CREATE_PAGE_FAILURE]
  }
})

ตอนนี้ถึงเวลาสร้าง route แล้ว เราจะให้ทุกครั้งที่เข้า path /pages/new เป็นการไปสู่หน้าสร้างวิกิใหม่ แก้ไข routes.js ดังนี้

import React from 'react'
import { Router, Route, IndexRoute, browserHistory } from 'react-router'
import {
  Pages,
  ShowPage,
  NewPage
} from './containers'
import {
  App,
  Home
} from './components'

export default () => {
  return (
    <Router history={browserHistory}>
      <Route path='/'
             component={App}>
        <IndexRoute component={Home} />
        <route path='pages'>
          <IndexRoute component={Pages} />
          {/* เพิ่ม route ใหม่ชื่อ new *}
          <route path='new'
                 component={NewPage} />
          <route path=':id'
                 component={ShowPage} />
        </route>
      </Route>
    </Router>
  )
}

จากนั้นจึงสร้าง container component ขึ้นมาชื่อ containers/Pages/New.js

import React, { Component } from 'react'
import Form from './Form'

export default class NewPageContainer extends Component {
  render() {
    return (
      // เรารู้ว่าหน้า New และหน้า Edit วิกิมีฟอร์มที่เหมือนกัน
      // เราจึงแยกส่วนจัดการฟอร์มออกเป็นอีกคอมโพแนนท์ชื่อ Form
      <Form />
    )
  }
}

เมื่อเราแยกส่วนจัดการฟอร์มออกแล้ว เราก็สร้างมันขึ้นมาครับ ตั้งชื่อไฟล์ว่า containers/Pages/Form.js

import React, { Component, PropTypes } from 'react'
import { reduxForm } from 'redux-form'
import { createPage } from '../../actions/page'
import { PageForm } from '../../components'

// เราต้องการให้ฟอร์มของเรามี 2 fields
const FIELDS = ['title', 'content']

class PageFormContainer extends Component {
  static propTypes = {
    fields: PropTypes.object.isRequired,
    handleSubmit: PropTypes.func.isRequired
  }

  render() {
    const { fields, handleSubmit } = this.props

    return (
      <PageForm
        fields={fields}
        handleSubmit={handleSubmit} />
    )
  }
}

// ใช้ reduxForm เพื่อสร้างฟอร์ม
export default reduxForm({
    // โดยระบุว่าฟอรฺมขชองเรานั้นชื่อ page
    form: 'page',
    // มีฟิลด์อะไรบ้างที่ต้องการ
    fields: FIELDS,
    // จะให้ตรวจสอบฟิลด์ไหม?
    validate: (values, props) =>
      // ตัวอย่างนี้จะทำการตรวจสอบฟิลด์ทั้งหมด
      // ถ้าฟิลด์ไหนไม่ได้พิมพ์ค่า จะให้มี error ว่า Required
      FIELDS.reduce((errors, field) =>
        values[field] ? errors : { ...errors, [field]: 'Required' }, {})
  },
  // เราสามารถใส่ mapStateToProps เข้าไปใน reduxForm ได้
  (state) => ({}),
  // เช่นเดียวกัน mapDispatchToProps ใส่ได้เหมือนกัน
  (dispatch) => ({
    // onSubmit จะจับคู่กับ handleSubmit ของ reduxForm
    // เมื่อใดก็ตามที่ฟอร์ม submit onSubmit ตัวนี้หละครับจะทำงาน
    onSubmit: (values) =>
      // เมื่อ onSubmit ทำงานเราต้องการให้มันไปเรียก createPage
      // เพื่อสร้างก้อนอ็อบเจ็กต์ action ที่สัมพันธ์กับการสร้างวิกิออกมา
      dispatch(createPage(values))
  })
)(PageFormContainer)

เนื่องจากตัว reduxForm นั้นจะสร้าง state ใหม่อัดเข้าไปใน store สถานะตัวนี้หละครับที่ reduxForm จะใช้จัดการกับฟอร์มที่เรากรอกข้อมูล ไม่ว่าจะเป็นการตรวจสอบว่าข้อมูล valid ไหม มีการบันทึกว่ากำลัง submit ฟอร์มอยู่หรือไม่ เป็นต้น ดังนั้นเราจึงต้องเข้าไปตั้งค่าใน root reducer ของเรา เพื่อให้เก็บสถานะของฟอร์มจาก reduxForm ด้วย

// reducers/index.js
import { combineReducers } from 'redux'
import { reducer as formReducer } from 'redux-form'
import pages from './pages'

export default combineReducers({
  form: formReducer,
  pages
})

ต่อไปเราจะสร้าง presentational component ที่เป็นตัวแทนของฟอร์มชื่อ components/Pages/Form.js ดังนี้

import React, { PropTypes } from 'react'

// ถ้ามี error เกิดขึ้นในแต่ละฟิลด์ให้แสดงข้อความนั้นออกมา
// วิธีการตรวจสอบว่ามี error หรือไม่คือ
// 1. เช๋คก่อนว่าฟิลด์นั้น touched หรือยัง
// 2. มี error เกิดที่ฟิลด์นั้นหรือไม่
// ถ้าผ่านทั้งสองข้อก็แสดง div ที่มีคลาสเป้น error พร้อมข้อความแสดงข้อผิดพลาด
const errorMessageElement = (field) => (
  field['touched'] &&
  field['error'] &&
  <div className='error'>{field['error']}</div>
)
const PageForm = ({
  fields,
  handleSubmit
}) => {
  const { title, content } = fields

  return (
    <form
      // เรัยก handleSubmit ที่ส่งเข้ามาเมื่อ submit ฟอร์ม
      onSubmit={handleSubmit}
      className='form'>
      <fieldset>
        <label>Title</label>
        <input type='text' placeholder='Enter Title' {...title} />
        {errorMessageElement(title)}
      </fieldset>
      <fieldset>
        <label>Content</label>
        <textarea
          rows='5'
          placeholder='Enter Content' {...content}>
        </textarea>
        {errorMessageElement(content)}
      </fieldset>
      <button
        type='submit'
        className='button'>
        Submit
      </button>
    </form>
  )
}

PageForm.propTypes = {
  fields: PropTypes.shape({
    title: PropTypes.object.isRequired,
    content: PropTypes.object.isRequired
  }).isRequired,
  handleSubmit: PropTypes.func.isRequired
}

export default PageForm

เอาหละ เราเพิ่มทั้ง presentational component และ container component กันแล้ว ต่อไปเราก็เพิ่มการเข้าถึงให้กับมัน ดังนี้

// components/index.js
export PageForm from './Pages/Form'

// containers/index.js
export NewPage from './Pages/New'

สุดท้ายเพิ่มสไตล์ให้ฟอร์มซะหน่อย จะได้ดูมีสีสันขึ้นครับ

// theme/_variables.scss
...
...
$red1-color: #da4453;

// theme/elements.scss
...
...
// Form
.fo// Form
.form {
  fieldset {
    margin: 0;
    padding: 0.35rem 0 0.75rem;
    border: 0;
  }

  input[type='text'],
  textarea,
  label {
    display: block;
    margin: 0.25rem 0;
    width: 100%;
  }

  .error {
    color: $red1-color;
  }
}

ถึงเวลาทดสอบกันแล้ว ไปที่ http://127.0.0.1:8080/pages/new เพื่อนๆควรจะพบฟอร์มหน้าตาแบบนี้

new page

ลองเล่นดูครับ ทดสอบด้วยการไม่กรอกอะไรเลยแล้วกด Submit จากนั้นลองกรอกข้อมูลให้ครบดูแล้ว Submit ฟอร์มครับ ตอนนี้เราจะสามารถสร้างวิกิหน้าใหม่ได้แล้ว แต่… เดี๋ยวก่อน เมื่อสร้างหน้าใหม่แล้วมันก็ควร redirect ไปที่หน้าแสดงวิกิ?

ท่านผู้ชมครับ เราจะติดตั้งไลบรารี่เพิ่มอีกตัวเพื่อทำให้เราสามารถเปลี่ยนสถานะของ router ได้ ความจริงแล้วเราสามารถใช้ withRouter จาก react-router เพื่อทำสิ่งเดียวกันนี้ได้ แต่การใช้แพคเกจนี้นั้นง่ายกว่า เอาหละลงมือติดตั้งกันเถอะ

npm i --save react-router-redux

เพื่อนๆที่อ่านมาถึงตรงนี้แล้ว ผมว่าเก่งกันทุกคนแล้วครับ ตรงนี้จะขอไปอย่างรวดเร็ว ก็อปปี้แปะอย่างรวดเร็วไปกับผมนะครับ

// routes.js

import { syncHistoryWithStore } from 'react-router-redux'

// รับ store เข้ามา
export default (store, history) => {
  return (
    // เพื่อใช้เพิ่มความสามารถให้ history
    <Router history={syncHistoryWithStore(history, store)}>
      
    </Router>
  )
}

// Root.js
import { browserHistory } from 'react-router'

export default class App extends Component {
  render() {
    const store = configureStore(browserHistory)
    return (
      <Provider store={store} key='provider'>
        // ส่ง store และ history เข้าไปใน routes
        {routes(store, browserHistory)}
      </Provider>
    )
  }
}

// reducers/index.js
import { routerReducer } from 'react-router-redux'

export default combineReducers({
  form: formReducer,
  // จับ routing เป็นสถานะหนึ่งของแอพพลิเคชัน
  routing: routerReducer,
  pages
})

// configureStore.js
import { routerMiddleware } from 'react-router-redux'

export default (history) => {
  // ทำให้สามารถใช้ push ใน action เพื่อเปลี่ยนเส้นทางไป URL อื่นได้
  const middlewares = [thunk, apiMiddleware, routerMiddleware(history)]

  ...
  ...
}

เมื่อตั้งค่าทุกอย่างเสร็จสิ้น เราก็จะได้ routing เป็น state หนึ่งของระบบเราแล้วครับ

routing state

ต่อไปเราก็ต้องทำให้ทุกครั้งที่สร้างวิกิสำเร็จให้วิ่งไปที่หน้าแสดงวิกิ

// actions/page.js
import { push } from 'react-router-redux'

export const createPage = (values) => (
  // ใช้ redux-thunk ช่วยให้เราสามารถเข้าถึง dispatch ได้
  (dispatch) =>
    dispatch({
      [CALL_API]: {
        endpoint: PAGES_ENDPOINT,
        headers: {
          'Accept': 'application/json',
          'Content-Type': 'application/json'
        },
        method: 'POST',
        body: JSON.stringify(values),
        types: [
          CREATE_PAGE_REQUEST,
          // นี่เป้นวิธีการ custom ของ redux-api-middleware
          {
            type: CREATE_PAGE_SUCCESS,
            payload: (_action, _state, res) => {
              return res.json().then((page) => {
                // เมื่อโหลดเพจสำเร็จให้วิ่งไปที่ /pages/:id
                dispatch(push(`/pages/${page.id}`))
                return page
              })
            }
          },
          CREATE_PAGE_FAILURE
        ]
      }
    }
  )
)

เมื่อทุกอย่างเสร็จเรียบร้อย เพื่อนๆลองทดสอบสร้างวิกิใหม่ดูครับ ตอนนี้จะพบว่าหลังสร้างเสร็จหน้าเว็บเราจะวิ่งไปที่ /pages/:id ตามที่เราต้องการแล้ว เหนื่อยเนอะจะทำอะไรให้ได้ซักอย่างนึง redux-api-middleware ตัวนี้ไม่ได้สนับสนุนให้เราสามารถเรียก action อื่นได้เมื่อโหลดเพจสำเร็จ จึงต้องมานั่งทำอะไรยุ่งยากแบบนี้ เพื่อนๆสามารถเลือกใช้ middleware ตัวอื่นที่รองรับความสามารถที่เพื่อนๆต้องการได้ครับ

สุดท้ายแล้ว เราคงไม่อยากพิมพ์ http://127.0.0.1:8080/pages/new เพื่อเข้าถึงหน้าสร้างวิกิใช่ไหมครับ ดังนั้นแล้วเรามาสร้างลิงก์กันเถอะ เพื่อให้คลิกแล้ววิ่งไปที่หน้านั้นเลย

// components/Pages/Index.js
<div>
    <button
      className='button'
      onClick={() => onReloadPages()}>
      Reload Pages
    </button>
    {/* ตรงนี้ */}
    <Link to={{ pathname: '/pages/new' }}>Create New Page</Link>
    <hr />
    ...
    ...

เย้ ในที่สุดก็จบลงได้ซะที เล่นเอาคนเขียนเหนื่อยเหมือนกันนะครับเนี่ย แต่ก่อนที่เราจะจากกัน เพื่อนๆควรทำแบบฝึกหัดต่อไปนี้ด้วยนะครับ

แบบฝึกหัด ตอนนี้เรามีหน้า New สำหรับสร้างวิกิใหม่แล้ว แต่เรายังไม่มีหน้า Edit สำหรับแก้ไขวิกิ ให้เพื่อนๆลองเพิ่มคอมโพแนนท์สำหรับจัดการ route /pages/:id/edit ครับ โดยต้องนำ containers/Pages/Form.js กลับมาใช้ใหม่อีกครั้ง ใน Form.js ของเดิมเราจะเรียก createPage เพื่อสร้างเพจใหม่ ให้เพื่อนๆลองแก้เป็นเรียก createPage เมื่อ Form นี้อยู่หน้า /pages/new และเรียก updatePage เมื่อ Form นี้อยู่ในหน้า /pages/:id/edit สุดท้ายให้เปลี่ยนข้อความของปุ่มเป็น Save สำหรับหน้า Edit และ Create สำหรับหน้า New แทนของเดิมที่เป็น Submit

ลากเลือดกันเลยทีเดียว สำหรับโค๊ดของวันนี้ทั้งหมดดูได้จาก Github ครับ ในบทความถัดไปเราจะพูดถึงวิธีการทำ Isomorphic Application ด้วย React และ Redux กันครับ ฝากติดตามด้วยนะครับ 😃

เอกสารอ้างอิง

Lin Clark (2015). A cartoon intro to redux. Retrieved June, 4, 2016, from https://code-cartoons.com/a-cartoon-intro-to-redux-3afb775501a6#.lu5ps2t45

Dan Abramov (2016). Building React Applications with Idiomatic Redux. Retrieved June, 4, 2016, from https://egghead.io/courses/building-react-applications-with-idiomatic-redux

Redux. Retrieved June, 4, 2016, from http://redux.js.org/


แสดงความคิดเห็นของคุณ


Thanarack Chaisri6 เดือนที่ผ่านมา

บอกเลยบทความนี้ผม ทำตามประมาณ อาทิตย์หนึ่ง 555


Sirichakorn Suwapinyopas8 เดือนที่ผ่านมา

หลังจากสร้างหน้า /pages/new เสร็จแล้ว ขึ้น warning

  • Warning: Failed prop type: Invalid prop fields of type array supplied to PageFormContainer, expected object.
  • Warning: Failed prop type: Invalid prop fields of type array supplied to PageForm, expected object.
  • Warning: React.createElement: type should not be null, undefined, boolean, or number. It should be a string (for DOM elements) or a ReactClass (for composite components). Check the render method of AppContainer. แล้วก้อ error
  • Uncaught Error: Element type is invalid: expected a string (for built-in components) or a class/function (for composite components) but got: object. Check the render method of AppContainer. ผมทำผิดพลาดอะไรหรือป่าวครับ