Tạo ứng dụng CRUD đơn giản với Redux (phần 2)

Chào các bạn, hôm nay mình trở lại và viết tiếp phần 2 cũng là phần cuối của "Tạo ứng dụng CRUD với Redux". Lần trước mình đã viết bài hướng dẫn các bạn tạo ứng dụng, setup phần backend và thực hiện chức năng Read (các bạn có thể xem bài viết trước tại đây). Trong blog lần này, mình sẽ hướng dẫn các bạn thực hiện các chức năng còn lại: Create, Update, Delete.

Trong bài viết không thể nói hết các phần râu ria của project, các bạn có thể tham khảo phần  code tại đây.

II. Tạo app CRUD demo

2. Chức năng Create

Đầu tiên cùng lướt qua một chút về phần xử lý chức năng Create ở trong backend/server.js:

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 };
}

...

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 });
        }
    })

...

Giải thích sơ qua, sau khi validate 2 trường titlecover được gửi lên từ req.body, nếu dữ liệu valid thì sẽ insert vào database. Khi insert thành công thì api sẽ trả về 1 object với key là game.

Trong src/containers/GameForm.js chúng ta có 1 form để nhập dữ liệu:

import React, { Component } from 'react'
import classnames from 'classnames';
import { connect } from 'react-redux';
import { saveGame } from '../actions/actions';
import { Redirect } from 'react-router-dom';

class GameForm extends Component {
    state = {
        _id:  null,
        title: '',
        cover: '',
        errors: {},
        loading: false,
        done: false
    }

    handleChange = (e) => {
        if (!!this.state.errors[e.target.name]) {
            let errors = Object.assign({}, this.state.errors);
            delete errors[e.target.name];
            this.setState({ [e.target.name]: e.target.value, errors });
        } else {
            this.setState({
                [e.target.name]: e.target.value
            })
        }

    }

    handleSubmit = (e) => {
        const { title, cover } = this.state;
        e.preventDefault();

        // validation
        let errors = {};
        if (title.trim() === '') errors.title = "Can't be empty";
        if (cover.trim() === '') errors.cover = "Can't be empty";
        this.setState({ errors });

        const isValid = Object.keys(errors).length === 0;

        if (isValid) {
            this.setState({ loading: true })
            this.props.saveGame({ title, cover }).then(
                () => { this.setState({ done: true }) },
                (err) => err.response.json().then(({ errors }) => this.setState({ errors, loading: false }))
            );
        }
    }

    renderForm = () => (
        <div>
            <form className={classnames('ui', 'form', { loading: this.state.loading })} onSubmit={this.handleSubmit}>
                <h1>Add New Game</h1>

                {!!this.state.errors.global && <div className="ui negative message"><p>{this.state.errors.global}</p></div>}

                <div className={classnames('field', { error: !!this.state.errors.title })}>
                    <label htmlFor="title">Title</label>
                    <input
                        id="title"
                        name="title"
                        value={this.state.title}
                        onChange={this.handleChange}
                    />
                    <span>{this.state.errors.title}</span>
                </div>
                <div className={classnames('field', { error: !!this.state.errors.cover })}>
                    <label htmlFor="cover">Cover URL</label>
                    <input
                        id="cover"
                        name="cover"
                        value={this.state.cover}
                        onChange={this.handleChange}
                    />
                    <span>{this.state.errors.cover}</span>
                </div>

                {this.state.cover.trim() !== '' &&
                    <div className="field">
                        <img src={this.state.cover} alt="cover" className="ui small bordered image" />
                    </div>
                }

                <div className="field">
                    <button className="ui primary button">Save</button>
                </div>
            </form>
        </div>
    )

    render = () => (
        <div>
            {this.state.done ? <Redirect to="/games" /> : this.renderForm()}
        </div>
    )
}

const mapStateToProps = (state, props) => ({
    game: null 
})

const mapDispatchToProps = dispatch => ({
    saveGame: (data) => dispatch(saveGame(data)),
});
 
export default connect(mapStateToProps, mapDispatchToProps)(GameForm);

Do GameForm không cần dùng để lấy các state có trong store hiện tại của Redux, nên ta lấy giá trị của game: null trong mapStateToProps. Khi người dùng submit form lên thông qua hàm handleSubmit(), thông qua (function được chuyển thành) props saveGame(), params được gửi lên là object chứa 2 state titlecover . Trong hàm saveGame():

import { GET_GAMES, CREATE_GAME } from '../actionTypes';

const handleResponse = (response) => {
    if (response.ok) {
        return response.json();
    } else {
        let error = new Error(response.statusText);
        error.response = response;
        throw error;
    }
}

...


export const saveGame = (data) => (
    async dispatch => {
        const game = await fetch('http://127.0.0.1:5000/api/games', {
            method: 'post',
            body: JSON.stringify(data),
            headers: {
                "Content-Type": "application/json"
            }
        }); 
        handleResponse(game);
        await dispatch({
            type: CREATE_GAME,
            game
        });
    }
)

Dữ liệu được gửi lên api thông qua method post, nếu không thành công sẽ throw error, còn không thì sẽ trả về response là chính game mới mà chúng ta submit lên. (object với key là game như mình đã nói về phần backend ở trên). Sau đó, nó dispatch một action với type là CREATE_GAME và params là game mà api trả về.

// src/reducers/games.js

import { ..., CREATE_GAME } from "../actionTypes";

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

Quay trở lại class GameForm, sau khi submit form xong, sẽ redirect về màn games. Lúc này, trong reducer games state sẽ được cập nhập, với case type = CREATE_GAME, state trả về sẽ là state cũ và thêm action.game mà chúng ta vừa dispatch trong function saveGame().

Create Game

3. Chức năng Update

Với chức năng Update, có 2 việc chúng ta phải làm:

  • Lấy dữ liệu của các bản ghi hiện có để hiển thị ra khi người dùng bấm vào 1 bản ghi.
  • Sau khi người dùng chỉnh sửa, validate dữ liệu và cập nhật lại bản ghi.
a) Lấy dữ liệu bản ghi

Để tối ưu code, chúng ta sử dụng lại container GameForm để hiển thị chi tiết của bản ghi. Trong src/App.js chúng ta define đường dẫn:

// src/App.js
...

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

...

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

export default App;

Rồi sau đó thêm button trỏ đến link trên trong component GameCard.js:

// src/components/GameCard.js
...
import {Link} from 'react-router-dom';

const GameCard = ({game, deleteGame}) => (

...

            <div className="ui two buttons">
                <Link to={`/game/${game._id}`} className="ui basic button green">Edit</Link>

                ...
            </div>
        </div>
    </div>
)

export default GameCard;

Việc tiếp theo là cập nhật lại GameForm trong trường hợp update:

...
import { saveGame, fetchGame } from '../actions/actions';

class GameForm extends Component {
	state = {
        _id: this.props.game ? this.props.game._id : null,
        title: this.props.game ? this.props.game.title : '',
        cover: this.props.game ? this.props.game.cover : '',
        errors: {},
        loading: false,
        done: false
    	}
	...
    componentWillReceiveProps = (nextProps) => {
        this.setState({
            _id: nextProps.game._id,
            title: nextProps.game.title,
            cover: nextProps.game.cover,
        })
    }

    componentDidMount = () => {
        if(this.props.match.params._id){
        this.props.fetchGame(this.props.match.params._id);
        }
    }

   ...
}

const mapStateToProps = (state, props) => (
    props.match.params._id ? { game: state.games.find(item => item._id = props.match.params._id) } : { game: null }
)

const mapDispatchToProps = dispatch => ({
    ...
    fetchGame: (id) => dispatch(fetchGame(id)),
});
 
export default connect(mapStateToProps, mapDispatchToProps)(GameForm);

src/actions/actions.js:

import { ..., GET_GAME } from '../actionTypes/index';

export const fetchGame = (id) => (
    async dispatch => {
        const response  = await fetch(`http://127.0.0.1:5000/api/games/${id}`);
        const game = await response.json();
        dispatch({
            type : GET_GAME,
            game
        });
    }
)

src/reducers/games.js:

import { ..., GET_GAME } from "../actionTypes";

export default function games(state = [], action = {}) {
    switch (action.type) {
        ...
        case GET_GAME:
            const index = state.findIndex(item => item._id === action.game._id);
            if (index > -1) {
                return state.map(item => {
                    if (item._id === action.game._id) return action.game;
                    return item;
                })
            } else {
                return [
                    ...state,
                    action.game
                ]
            }
        ...
    }
}

Logic ở đây có thể hiểu như sau: khi người dùng ấn vào một bản ghi, trong componentDidMount sẽ gọi props fetchGame truyền vào params là id được lấy trên đường dẫn mà chúng ta truyền vào bên GameCard.js. Trong hàm fetchGame() trong actions, lấy dữ liệu bản ghi qua api rồi dispatch hành động type = GET_GAME truyền vào bản ghi lấy được từ api. Reducer lắng nghe action, khi thấy action được thực hiện, thì sẽ return giá trị bản ghi mà action trả về. Trong GameForm,  chíng ta sẽ lọc global states games ở trong hàm mapStateToProps để trả về bản ghi trùng với cả tham số _id truyền vào URL.

Với việc sử dụng hàm componentWillReceiveProps, chúng ta sẽ render lại GameForm.js đối với các bản ghi khác nhau. Thành quả đạt được:

b) Cập nhật bản ghi

src/containers/GameForm.js:

...
import { ..., updateGame } from '../actions/actions';


class GameForm extends Component {
    ...

    handleSubmit = (e) => {
        const { title, cover, _id } = this.state;
        e.preventDefault();

        // validation
        let errors = {};
        if (title.trim() === '') errors.title = "Can't be empty";
        if (cover.trim() === '') errors.cover = "Can't be empty";
        this.setState({ errors });

        const isValid = Object.keys(errors).length === 0;

        if (isValid) {
            this.setState({ loading: true })
            if(_id){
                this.props.updateGame({_id, title, cover}).then(
                    () => { this.setState({ done: true }) },
                    (err) => err.response.json().then(({ errors }) => this.setState({ errors, loading: false }))
                );
            }else{
                this.props.saveGame({ title, cover }).then(
                    () => { this.setState({ done: true }) },
                    (err) => err.response.json().then(({ errors }) => this.setState({ errors, loading: false }))
                );
            }
        }

    }

    ...
const mapDispatchToProps = dispatch => ({
    ...
    updateGame: (data)=> dispatch(updateGame(data))
});
 
export default connect(mapStateToProps, mapDispatchToProps)(GameForm);

Với trường hợp tồn tại this.state._id , chúng ta sử dụng props updateGame():

import { ..., UPDATE_GAME } from '../actionTypes/index';

export const updateGame = (data) => (
    async dispatch => {
        const game = await fetch(`http://127.0.0.1:5000/api/games/${data._id}`, {
            method: 'put',
            body: JSON.stringify(data),
            headers: {
                "Content-Type": "application/json"
            }
        });
        handleResponse(game);
        await dispatch({
            type: UPDATE_GAME,
            game
        });
    }
)

updateState() với params truyền vào là 1 object chứa id, title và cover mới được gửi tới api/games/id thông qua method PUT. Sau khi cập nhật xong, nó sẽ thực hiện dispatch 1 hành động với type UPDATE_GAME và tham số là game đã được cập nhật. Sau khi action được thực hiện, reducer lập tức xử lý state với type của action tương ứng:

import { ...,  UPDATE_GAME } from "../actionTypes";

export default function games(state = [], action = {}) {
    switch (action.type) {
        ...
        case UPDATE_GAME: 
            return state.map(item => {
                if(item._id === action.game._id) return action.game;
                return item;
            })
        ...
    }
}

Như vậy item trong state hiện tại được thay thế bằng game đã được cập nhật mà api trả về có id tương ứng.

Update Game

4. Chức năng Delete

Chức năng delete thì tương đối đơn giản, mục đích của chúng ta là xoá dữ liệu trên database rồi cập nhật lại global state.

src/actions/actions.js:

import { ..., DELETE_GAME} from '../actionTypes';

export const deleteGame = (id) => (
    async dispatch => {
        const game = await fetch(`http://127.0.0.1:5000/api/games/${id}`, {
            method: 'delete',
            headers: {
                "Content-Type": "application/json"
            }
        });
        handleResponse(game);
        await dispatch({
            type : DELETE_GAME,
            id
        });
    }
)

Logic của action deleteGame khá rõ ràng, khi sẽ sử dụng method delete, xoá đi dữ liệu của bản ghi có id được truyền thẳng vào header api.  Sau đó dispatch 1 hành động với type DELETE_GAME và params là id của bản ghi đã xoá.

Ở trong container GamePage, chúng ta chuyển action deleteGame thành 1 props và truyền vào tới component GameList > GameCard:

src/containers/GamePage.js:

...
import { ..., deleteGame } from '../actions/actions';

class GamesPage extends React.Component {
    ...
    render= () => (
            <div>
                ...
                <GamesList games={this.props.games} deleteGame={this.props.deleteGame} />
            </div>
    )
}

...

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

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

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;

Khi người dùng click vào nút Delete, ngay lập tức action được thực thi và reducer games sẽ lập tức xử lý state theo type DELETE_GAME.

import { ..., DELETE_GAME } from "../actionTypes";

export default function games(state = [], action = {}) {
    switch (action.type) {
        ...
        case DELETE_GAME:
            return state.filter(item => item._id !== action.id)
        ...
    }
}

Như vậy, state sẽ được filter và return tất cả bản ghi có id không trùng với bản ghi đã bị xoá.

Delete Game

III. Lời kết

Trong bài viết mình còn sử dụng một số các thư viện có sẵn nhưng mình chỉ đi sâu vào Redux, Redux và Redux để giúp các bạn hiểu được nội dung chính mà mình muốn đề cập. Qua 2 bài viết, hy vọng đã giúp các bạn hiểu rõ hơn về khái niệm và cơ chế hoạt động của Redux - một trong những state management được sử dụng nhiều nhất nhưng khá khó đối với những người mới. Mong các bạn sẽ thực hành thật nhiều để master Redux nhé! Xin chào!.