Chào các bạn, Hôm nay mình sẽ hướng dẫn các bạn tạo 1 ứng dụng CRUD đơn giản với React và Redux.

I. Giới thiệu chung về Redux

1. Hoàn cảnh ra đời

Năm 2013, Facebook cho ra mắt ReactJS khi cho rằng  AngularJS của Google rất chậm chạp và nặng nề. Tuy nhiên, ReactJS chỉ là một thư viện để render các Component lên giao diện chứ không có khả năng quản lý trạng thái của ứng dụng. Sau đó, Facebook có giới thiệu 1 thư viện giúp hỗ trợ React trong việc quản lý trạng thái mang tên Flux. Tuy nhiên, Flux chỉ được FB giới thiệu như 1 kiến trúc tổng quát ngoài ra họ không cung cấp cách implement chi tiết. Hơn nữa, việc áp dụng cấu trúc Flux lúc đó khá rắc rối và có thể nói khiến cho các Lập trình viên "tẩu hoả nhập ma". Khi đó, Dan Abra nghiên cứu về Flux của Facebook và ngôn ngữ ELM. Anh ta đã bị ảnh hưởng bởi kiến trúc của ELM, và nhận thấy sự phức tạp của Flux. Tháng 5/2015 Dan Abramov công bố một thư viện mới có tên Redux, được viết trên kiến trúc ELM và cách sử dụng đơn giản hơn rất nhiều so với Flux.

2. Khái quát chung cách sử dụng

Redux có thể nói  chính xác là 1 bản implement của Flux. Tuy nhiên được lược giản đi rất nhiều thứ như là chỉ có 1 store combine các reducers lại thay vì có nhiều store như Flux, sử dụng các stage-changing functions được gọi là reducers (thay vì Dispatcher),etc. Có thể tóm lược cách sử dụng của Redux bằng sơ đồ sau:

Khi người dùng thực hiện 1 Action trên giao diện (có thể là load lại trang, hay click vào 1 button submit,...) thì một đối tượng Action sẽ được tạo ra. Action ở đây được khai báo kiểu hành động và chứa một thông tin nhằm thay đổi state. Action này sẽ được dispatch theo ActionType và được tiếp nhận trực tiếp bởi Store. Như đã nói ở trên Store chứa rất nhiều các Reducer, và tại đây Reducer sẽ nhận biết các kiểu action đã được định nghĩa và trả về các state cho các React Component mà Action mong muốn. Điều đặc biệt của Redux là Reducer không bao giờ thay đổi state hiện tại của App mà nó sẽ copy và trả về một State mới.

Ngoài ra trong Redux chúng ta có thể add thêm các Middleware layers vào Reducers để xử lý các actions. Các lớp trung gian này sẽ thực hiện các công việc thêm cho các actions ví dụ: chứng thực người dùng, đảm bảo người dùng có đủ quyền để thực hiện action. Vì thế middleware có thể dừng action lại hoặc cho phép action thông qua và kể cả thay đổi action. Hiện nay các middleware phổ biến có thể kể đến: Redux Thunk, Redux Saga,...

II. Tạo app CRUD demo

Thật ra việc để hiểu được lý thuyết tương đối khó khăn đối với những người bắt đầu sử dụng Redux. Vì vậy, chúng ta cùng thực hành tạo 1 app CRUD cơ bản áp dụng Redux để hiểu rõ hơn về cơ chế hoạt động của Redux. Trong ứng dụng lần này, mình tạo một ứng dụng quản lý game yêu thích  sử dụng React, Redux kết hợp với backend sử dụng Node.js và MongoDB.

1. Chuẩn bị

Các bạn có thể tạo app sử dụng package `create-react-app`  bằng cách chạy lệnh:

create-react-app crud

để khởi tạo project.

Tiếp đến là cài đặt Redux cùng các thư viện liên quan:

npm install --save redux react-redux
npm install --save react-router-dom

rồi sau đó sử dụng lệnh npm start để chạy ứng dụng của mình.

a) Server

Về phần server, mình viết phần server sử dụng Nodejs và MongoDB. Tất cả được đặt trong cùng project trong thư mục ./backend. Các bạn có thể tham khảo source  code của mình tại đây. Server được sử dụng ở đây để liên kết database với app của chúng ta. Tất cả được mình đặt trong thư mục ./backend.

const express = require('express');
const mongoose = require('mongoose');
const bodyParser = require('body-parser');
var cors = require('cors');

const app = express();
app.use(cors());
app.use(bodyParser.json());
const dbUrl = 'mongodb://localhost/reduxcrud';

function validate(data) {
    let errors = {};
    if (data.title.trim() === '') errors.title = "Can't be empty";
    if (data.cover.trim() === '') errors.cover = "Can't be empty";
    const isValid = Object.keys(errors).length === 0;
    return { errors, isValid };
}

mongoose.connect(dbUrl, function (err, db) {
    console.log("connected")
    app.get('/api/games', (req, res) => {
        db.collection('games').find({}).toArray((err, games) => {
            res.json(games);
        });
    })

    app.post('/api/games', (req, res) => {
        const { errors, isValid } = validate(req.body);
        if (isValid) {
            const { title, cover } = req.body;
            db.collection('games').insert({ title, cover }, (err, result) => {
                if (err) {
                    res.status(500).json({ errors: { global: 'Something went wrong ' } })
                } else {
                    res.json({ game: result.ops[0] })
                }
            })
        } else {
            res.status(400).json({ errors });
        }
    })

    app.get('/api/games/:_id', (req, res) => {
        db.collection('games').findOne({ _id: new mongoose.Types.ObjectId(req.params._id) }, (err, game) => {
            res.json(game);
        })
    })

    app.put('/api/games/:_id', (req, res) => {
        const { errors, isValid } = validate(req.body);

        if (isValid) {
            const { title, cover } = req.body;
            db.collection('games').findOneAndUpdate(
                { _id: new mongoose.Types.ObjectId(req.params._id) },
                { $set: { title, cover } },
                { returnOriginal: false },
                (err, result) => {
                    if (err) { res.status(500).json({ errors: { global: err } }); return }
                    res.json({ game: result.value });
                }
            )

        } else {
            res.status(400).json({ errors })
        }
    })

    app.delete('/api/games/:_id', (req, res) => {
        console.log('id nè', req.params._id);
        db.collection('games').deleteOne({ _id: new mongoose.Types.ObjectId(req.params._id) }, (err, r) => {
            if (err) { res.status(500).json({ errors: { global: err }}); return }
            res.json({});
        })
    });

    app.use((req, res) => {
        res.status(404).json({
            errors: {
                global: "Please try again later"
            }
        })
    })
    app.listen(5000, () => console.log('Server is running on localhost:5000'));
});

Như các bạn thấy server của mình chạy trên localhost:5000. Trên server, mình đã tạo các đường link dùng cho việc lấy dữ liệu data, thêm mới, sửa, xoá. Sử dụng mongoose và MongoDB. hãy chắc chắn rằng trên máy của các bạn đã cài đầy đủ Node.js và MongoDB, đồng thời, tạo database reduxcrud và  collection games. Do chúng ta đi sâu vào phần Redux nên mình sẽ tạm thời không đi sâu giải thích về phần này.

b) Cấu trúc thư mục

src
--actions
  --action.js
--actionTypes
  --index.js
--components
  --GamCard.js
  --GameList.js
--containers
  --GameForm.js
  --GamePage.js
--reducers
  --games.js
  --rootReducer.js
--App.js
--index.js

Trong src/App.js :

import React from 'react';
import { Link, Route } from 'react-router-dom';
import GamesPage from './containers/GamesPage';
import GameForm from './containers/GameForm';

const App = () => (
  <div className='ui container'>
    <div className='ui three item menu'>
      <Link className='item' activeOnlyWhenExact to='/'>Home</Link>
      <Link className='item' activeOnlyWhenExact to={'/games'}>Games</Link>
      <Link className='item' activeOnlyWhenExact to='/games/new'>Add New Game</Link>
    </div>

    <Route exact path={'/games'} component={GamesPage} />
    <Route path={'/games/new'} component={GameForm} />
    <Route path={'/game/:_id'} component={GameForm} />
  </div>
);

export default App;

mình dùng để tại các routers trong app.

c) Tạọ Store

src/index.js:

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import * as serviceWorker from './serviceWorker';
import { createStore, applyMiddleware } from 'redux';
import { Provider } from 'react-redux';
import rootReducer from './reducers/rootReducer';
import thunk from 'redux-thunk';
import { BrowserRouter } from 'react-router-dom';

const store = createStore(
    rootReducer,
    applyMiddleware(thunk)
);

ReactDOM.render(
    <BrowserRouter>
        <Provider store={store}>
            <App />
        </Provider>
    </BrowserRouter>, document.getElementById('root'));
    
serviceWorker.unregister();

Đầu tiên chúng ta sẽ tạo store để chứa các State được sử dụng trong Project. Chúng ta sử dụng hàm createStore() để tạo store. Trong store có chứa rootReducer và được apply middleware redux-thunk dùng cho việc xử lý synchronous actions với API . Hãy nhìn qua 1 chút rootReducer trong .src/reducers/rootReducer.js:

import { combineReducers } from 'redux';
import games from './games';

export default combineReducers({
    games
});

Như vậy, rootReducers là tập hợp của các reducers rồi được tổng hợp bởi hàm combineReducers(), Store chứa các reducers này sau đó gắn chúng với props store thẻ Provider của thư viện react-redux. Việc này giúp chúng ta có thể gọi các state xuyên suốt, ở mọi các component trong project thay vì phải truyền đi truyền lại giữa các component với nhau.

d) Tạo các actionTypes

// src/actionTypes/index.js

export const GET_GAMES = 'GET_GAMES';
export const CREATE_GAME = 'CREATE_GAME';
export const GET_GAME = 'GET_GAME';
export const UPDATE_GAME = 'UPDATE_GAME';
export const DELETE_GAME = "DELETE_GAME";

Việc khai báo các action types là cực kì quan trọng, nó giúp chúng ta phân loại được kiểu hành động và đồng thời giúp Reducers nhận biết được các action này để trả về được các state hợp lý đúng với nguyện vọng. Ngoài 4 kiểu type CRUD ra, chúng ra có thêm kiểu type GET_GAME để trả về giá trị của 1 bản ghi, dùng để dễ dàng update và xoá.

2. Chức năng Read

src/containers/GamePage.js:

import React from 'react';
import { connect } from 'react-redux';
import GamesList from '../components/GamesList';
import { fetchGames, deleteGame } from '../actions/actions';

class GamesPage extends React.Component {
    componentWillMount = () => {
        this.props.fetchGames();
    }

    render= () => (
            <div>
                <h1>Game List</h1>
                <GamesList games={this.props.games} deleteGame={this.props.deleteGame} />
            </div>
    )
}

const mapStateToProps = (state) => ({
    games: state.games
});

const mapDispatchToProps = dispatch => ({
    fetchGames: () => dispatch(fetchGames()),
    deleteGame: (id) => dispatch(deleteGame(id))
});

export default connect(mapStateToProps, mapDispatchToProps)(GamesPage);

src/components/GameList.js:

import React from 'react';
import GameCard from './GameCard';

const GamesList = ({games, deleteGame}) => (
    <div>
        {games.length === 0 ?
        <p>There are no games yet in your collection</p>
        :
        <div className="ui four cards">
            {games.map((game) => <GameCard game={game} deleteGame={deleteGame}/>)}
        </div>
        }
    </div>
)

export default GamesList;

src/components/GameCard.js:

import React from 'react';
import {Link} from 'react-router-dom';

const GameCard = ({game, deleteGame}) => (
    <div className="ui card">
        <div className="image">
            <img src={game.cover} alt={'Game Cover'} />
        </div>
        <div className="content">
            <div className="header" >{game.title}</div>
        </div>
        <div className="extra content">
            <div className="ui two buttons">
                <Link to={`/game/${game._id}`} className="ui basic button green">Edit</Link>
                <Link className="ui basic button red" onClick={()=> deleteGame(game._id)}>Delete</Link>
            </div>
        </div>
    </div>
)

export default GameCard;

Trong container GamePage, chúng ta sử dụng hàm connect() từ thư việc react-redux để liên kết component với store và các action . Cụ thể, thông qua function mapStateToProps(),· Chúng ta đã có thể lấy được dữ liệu của global state trong store của redux và biến chúng thành props games của riêng mình. Tuy nhiên hãy nhìn một chút trong reducer của game:

// src/reducers/games.js
...
export default function games(state = [], action = {}) {
	switch (action.type) {
		default: return state;
	}
}
....

nếu chỉ gọi đến hàm mapStateToProps(), vì chưa có action nào được thực hiện nên hiện tại state từ redux trả về đang là [] - array rỗng và không có dữ liệu, Vậy nên kết quả hiển thị sẽ là There are no games yet in your collection.

Tuy nhiên, trong container GamePage,  cụ thể:


...
import { fetchGames, deleteGame } from '../actions/actions';
...
	componentWillMount = () => {
        this.props.fetchGames();
    }

...

const mapDispatchToProps = dispatch => ({
    fetchGames: () => dispatch(fetchGames()),
    deleteGame: (id) => dispatch(deleteGame(id))
});

export default connect(mapStateToProps, mapDispatchToProps)(GamesPage);

Ta có thể thấy trước khi dữ liệu được render ra view, trong hàm componentWillMount của GamePage đã gọi prop fetchGames. vậy props này từ đâu mà có? Như các bạn thấy,  trong hàm  connect mình  đã truyền tham số thứ 2 là 1 hàm mapDispatchToProps.  hàm mapDispatchToProps này có tác dụng là dispatch action và chuyển hàm chứa action đó thành props của app.

// src/action/actions.js

import { GET_GAMES } from '../actionTypes/index';

export const fetchGames = () => (
    async dispatch => {
        const response  = await fetch('http://127.0.0.1:5000/api/games');
        const games = await response.json();
        dispatch({
            type: GET_GAMES,
            games
        });
    }
)

trong hàm fetchGame được khai báo trong src/action/actions.js, ta có thể thấy sau khi fetch dữ liệu từ API trả về giá trị  games, nó dispatch 1 hành động với type là GET_GAMES và truyền dữ liệu đi chính là giá trị games vừa được trả về từ API.

trong src/reducers/games.js:

import { GET_GAMES } from "../actionTypes";

export default function games(state = [], action = {}) {
	switch (action.type) {
        case GET_GAMES:
            return action.games;
		default: return state;
    }
}

Ta có thể thấy reducer games ngoài params state với giá trị mặc định là [],  param thứ 2 là action. Trong trường hợp type action là GET_GAMES, thì nó sẽ trả về games của object action. Mà ở trên action fetchGames chúng ta đã dispatch 1 hành động với kiểu type GET_GAMES và trả về giá trị games từ API. vậy Reducer game sẽ trả về cho chúng ta chính dữ liệu game  đó từ action. Trong database hiện tại có:

Vậy kết quả hiển thị sẽ là:

Ở bài viết này, mình đã giới thiệu cho các bạn khái quát qua về Redux, cũng như là được chức năng Read trong CRUD để hiển thị kết quả ra màn hình. Ở phần sau mình sẽ tiếp tục hướng dẫn các bạn làm tiếp các phần Create, Update, Delete. Hy vọng bài viết hữu ích với các bạn! Xin chào!