[Redux beginner] Rails + Redux + API

Ở bài trước mình đã hướng dẫn khởi tạo reactjs, react-redux trong rails project (Làm quen với Redux trong rails app.). Bài viết này mình sẽ hướng dẫn sử dụng redux tương tác với API, API demo sẽ là Reddit API .

Các package cần thiết

  1. superagent Thư viện hỗ trợ thực hiện các HTTP async request: npm install superagent .
  2. redux-thunk Middleware cho phép bạn viết các action creator trả về function, điều này giúp chúng ta dễ dàng dispatch các action theo ý thích. Các function ở trong nhận dispatchgetStore làm param. npm install --save redux-thunk

Implement api util request

Ta tạo thêm 1 thư mục utils , utils sẽ chứa các file js thực hiện các request đến API. Tạo util reddit, dưới đây là GET request, lấy về những bài viết trong 1 subreddit.

//app/javascript/packs/utils/reddit_api_util.js
const RedditAPIUtil = {
  fetchSubReddit: (subreddit) => {
    return request
      .get(`https://www.reddit.com/r/${subreddit}.json`)
      .then(response => {
        return response.body.data.children
      })
  }
}

Call api bằng redux actions

Tiếp đến, ta sẽ thực hiện gọi api bằng cách tạo ra redux actions.

// app/javascript/packs/actions/reddit_actions.js
import RedditAPIUtil from '../utils/reddit_api_util';

export const REQUEST_POSTS = 'REQUEST_POSTS'
export const RECEIVE_POSTS = 'RECEIVE_POSTS'
export const SELECT_SUBREDDIT = 'SELECT_SUBREDDIT'
export const INVALIDATE_SUBREDDIT = 'INVALIDATE_SUBREDDIT'

// function này sẽ là function được gọi trong component, 
export const fetchPostsIfNeeded = (subreddit) => (dispatch, getState) => {
  if (shouldFetchPosts(getState(), subreddit)) {
    return dispatch(fetchPosts(subreddit))
  }
}

const fetchPosts = (subreddit) => dispatch => {
  RedditAPIUtil.fetchSubReddit(subreddit)
    .then(response => {
      dispatch(requestPosts(response))
    })
}

const shouldFetchPosts = (state, subreddit) => {
  const { redditReducer } = state
  const posts = redditReducer.postsBySubreddit[subreddit]
  if (!posts) {
    return true
  } else if (posts.isFetching) {
    return false
  } else {
    return posts.didInvalidate
  }
}

Xử lý data từ actions bằng reducer

Tạo reducer cho reddit actions:

import { combineReducers } from 'redux'
import {
  SELECT_SUBREDDIT,
  INVALIDATE_SUBREDDIT,
  REQUEST_POSTS,
  RECEIVE_POSTS
} from '../actions/reddit_actions'

const postsBySubreddit = (state = {}, action) => {
  switch (action.type) {
    case INVALIDATE_SUBREDDIT:
    case RECEIVE_POSTS:
    case REQUEST_POSTS:
      return Object.assign({}, state, {
        [action.subreddit]: posts(state[action.subreddit], action)
      })
    default:
      return state
  }
}

const redditReducer = combineReducers({
  postsBySubreddit,...
})

export default redditReducer

Tạo root reducer

Mục đích để ta lưu tất cả các reducer vào 1 chỗ, tiện cho việc quản lý và sử dụng.

import { combineReducers } from 'redux';
import helloReducer from './hello_reducer.js';
import redditReducer from './reddit_reducers';

export default combineReducers({
  helloReducer,
  redditReducer
})

Cấu hình lại store để sử dụng được ajax request

// app/javascript/packs/configureStore.js
import {createStore, applyMiddleware, compose} from 'redux';
import thunk from 'redux-thunk';
import rootReducer from './reducers/root_reducer';

const configureStore = (preloadedState = {}) => {
  return compose(applyMiddleware(thunk))(createStore)(rootReducer);
}

export default configureStore

Như vậy về cơ bản phần gọi và xử lý dữ liệu đã hoàn tất, giờ chúng ta sẽ đổ data ra view bằng các component.

Tạo các component

  1. Pickers - subreddit select box
import React, { Component } from 'react'
import PropTypes from 'prop-types'

export default class Picker extends Component {
  render() {
    const { value, onChange, options } = this.props

    return (
      <span>
        <h1>{value}</h1>
        <select onChange={e => onChange(e.target.value)} value={value}>
          {options.map(option => (
            <option value={option} key={option}>
              {option}
            </option>
          ))}
        </select>
      </span>
    )
  }
}

Picker.propTypes = {
  options: PropTypes.arrayOf(PropTypes.string.isRequired).isRequired,
  value: PropTypes.string.isRequired,
  onChange: PropTypes.func.isRequired
}
  1. Posts - display a list of post
import React, { Component } from 'react'
import PropTypes from 'prop-types'

export default class Posts extends Component {
  render() {
    return (
      <ul>
        {this.props.posts.map((post, i) => <li key={i}>{post.title}</li>)}
      </ul>
    )
  }
}

Posts.propTypes = {
  posts: PropTypes.array.isRequired
}
  1. MainPage - component chính chứa Picker và Posts component
import React, { Component } from 'react'
import PropTypes from 'prop-types'
import { connect } from 'react-redux'
import { bindActionCreators } from 'redux'
import * as RedditActions from '../actions/reddit_actions'
import Picker from './Picker'
import Posts from './Posts'

class MainPage extends Component {
  constructor(props) {
    super(props)
    this.handleChange = this.handleChange.bind(this)
    this.handleRefreshClick = this.handleRefreshClick.bind(this)
  }

  componentDidMount() {
    const { dispatch, selectedSubreddit, actions: {fetchPostsIfNeeded} } = this.props
    fetchPostsIfNeeded(selectedSubreddit)
  }

  componentDidUpdate(prevProps) {
    if (this.props.selectedSubreddit !== prevProps.selectedSubreddit) {
      const { dispatch, selectedSubreddit, actions: {fetchPostsIfNeeded} } = this.props
      fetchPostsIfNeeded(selectedSubreddit)
    }
  }

  handleChange(nextSubreddit) {
    this.props.actions.selectSubreddit(nextSubreddit)
    this.props.actions.fetchPostsIfNeeded(nextSubreddit)
  }

  handleRefreshClick(e) {
    e.preventDefault()

    const { dispatch, selectedSubreddit,
      actions: {invalidateSubreddit, fetchPostsIfNeeded} } = this.props
    invalidateSubreddit(selectedSubreddit)
    fetchPostsIfNeeded(selectedSubreddit)
  }

  render() {
    const { selectedSubreddit, posts, isFetching, lastUpdated } = this.props
    return (
      <div>
        <Picker
          value={selectedSubreddit}
          onChange={this.handleChange}
          options={['reactjs', 'frontend']}
        />
        <p>
          {lastUpdated &&
            <span>
              Last updated at {new Date(lastUpdated).toLocaleTimeString()}.
              {' '}
            </span>}
          {!isFetching &&
            <a href="#" onClick={this.handleRefreshClick}>
              Refresh
            </a>}
        </p>
        {isFetching && posts.length === 0 && <h2>Loading...</h2>}
        {!isFetching && posts.length === 0 && <h2>Empty.</h2>}
        {posts.length > 0 &&
          <div style={{ opacity: isFetching ? 0.5 : 1 }}>
            <Posts posts={posts} />
          </div>}
      </div>
    )
  }
}

MainPage.propTypes = {
  selectedSubreddit: PropTypes.string.isRequired,
  posts: PropTypes.array.isRequired,
  isFetching: PropTypes.bool.isRequired,
  lastUpdated: PropTypes.number,
}

const mapStateToProps = (state) => {
  const { selectedSubreddit, postsBySubreddit } = state.redditReducer
  const {
    isFetching,
    lastUpdated,
    items: posts
  } = postsBySubreddit[selectedSubreddit] || {
    isFetching: true,
    items: []
  }

  return {
    selectedSubreddit,
    posts,
    isFetching,
    lastUpdated
  }
}

const mapDispatchToProps = dispatch => ({
  actions: bindActionCreators(RedditActions, dispatch)
})

export default connect(mapStateToProps, mapDispatchToProps)(MainPage)
  1. Root component
// app/javascripts/packs/components/Root.js
import React, { Component } from 'react'
import { Provider } from 'react-redux'
import configureStore from '../configureStore'
import MainPage from './MainPage'

const store = configureStore()

export default class Root extends Component {
  render() {
    return (
      <Provider store={store}>
        <MainPage />
      </Provider>
    )
  }
}
  1. Khai báo lại component với rails
// app/javascripts/packs/application.js
import React from 'react'
import { render } from 'react-dom'
import Root from './components/Root'

render(
  <Root />,
  document.getElementById('root')
)

Kết thúc

Như vậy chúng ta đã hoàn thiện 1 rails app cùng webpacker gem tương tác với api bên ngoài và giúp cho ta dễ dàng sử dụng các thư viện javascript trong rails. Bài viết dựa trên tutorial chính thức của redux.js.org : http://redux.js.org/docs/advanced/ExampleRedditAPI.html Github của tutorial: https://github.com/nguyenthetoan/RedditRails