Search code examples
javascriptreactjsasynchronousweb-applicationsreact-lifecycle

React.js, how to render users action instantaneously (before waiting for server propagation)


I'm building a shopping list web app. Items in the list can be toggled 'checked' or 'unchecked'.
My data flow is this: click on item checkbox --> send db update request --> re-render list data with new checkbox states.

If I handle the checkbox state entirely in react local component state, the user action updates are very fast.

Fast Demo: https://youtu.be/kPcTNCErPAo

However, if I wait for the server propagation, the update appears slow.

Slow Demo: https://youtu.be/Fy2XDUYuYKc

My question is: how do I make the checkbox action appear instantaneous (using local component state), while also updating checkbox state if another client changes the database data.

Here is my attempt (React component):

import React from 'react';
import ConfirmModal from '../ConfirmModal/ConfirmModal';
import {GrEdit} from 'react-icons/gr';
import {AiFillDelete, AiFillCheckCircle} from 'react-icons/ai';
import {MdRadioButtonUnchecked} from 'react-icons/md';
import './ListItem.scss';

class ListItem extends React.Component{
    constructor(props){
        super(props);

        this.state = {
            confirmOpen: false,
            checkPending: false,
            itemChecked: props.item.checked,
        };
    }

    static getDerivedStateFromProps(nextProps, prevState){
        if(nextProps.item.checked != prevState.itemChecked){
            return ({itemChecked: nextProps.item.checked})
        }
        return null;
    }

    render(){
        return (
            <div className={`listItemWrapper${this.state.itemChecked ? ' checked': ''} `}>
                {this.state.confirmOpen ? 
                    <ConfirmModal 
                        triggerClose={() => this.setState({confirmOpen: false})}
                        message={`Do you want to delete: ${this.props.item.content}?`}
                        confirm={() => {
                            this.clickDelete();
                            this.setState({confirmOpen: false});
                        }}
                    /> : null
                }

                <div className="listItem">
                    <div className='listContent'>
                        { this.state.itemChecked ?
                            <strike>{this.props.item.content}</strike>
                            : this.props.item.content
                        }
                        <div className={`editBtn`}>
                            <GrEdit onClick={() => {
                                let {content, category, _id} = this.props.item;
                                this.props.edit({content, category, _id});
                            }}
                            />
                        </div>
                    </div>
                </div>
                <div className={`listToolsWrapper`}>
                    <div className = "listTools">
                        <div onClick={() => this.setState({confirmOpen: true})} className={`tool`}><AiFillDelete className="listItemToolIcon deleteIcon"/></div>
                        <div onClick={() => !this.state.checkPending ? this.clickCheck() : null} className={`tool`}>
                            {this.state.itemChecked ? <AiFillCheckCircle className="listItemToolIcon checkIcon"/> : <MdRadioButtonUnchecked className="listItemToolIcon checkIcon"/>}
                        </div>
                    </div>
                    <div className = "listInfo">
                        <div className ="itemDate">
                            {this.props.item.date}
                            {this.props.item.edited ? <p className="edited">(edited)</p> : null}
                        </div>
                    </div>
                </div>
            </div>
        );
    }

    async clickCheck(){
        this.setState(prevState => ({checkPending: true, itemChecked: !prevState.itemChecked}));
        await fetch(`/api/list/check/${this.props.item._id}`,{method: 'POST'});
        this.setState({checkPending: false});
        //fetch updated list
        this.props.fetchNewList();
    }

    async clickDelete(){
        await fetch(`/api/list/${this.props.item._id}`,{method: 'DELETE'});
        //fetch updated list
        this.props.fetchNewList();
    }
}

export default ListItem;

I'm confused about how to properly use react lifecycle methods here. I'm using local state to mirror a component prop. I attempt to use getDerivedStateFromProps() to sync state and props, but that doesn't make the render faster.


Solution

  • This is what ended up working for me -- although it isn't the most elegant solution

    I changed getDerivedStateFromProps to this:

    static getDerivedStateFromProps(nextProps, prevState){
            // Ensure that prop has actually changed before overwriting local state
            if(nextProps.item.checked != prevState.itemChecked && 
                prevState.prevPropCheck != nextProps.item.checked){
                return {itemChecked: nextProps.item.checked}
            }
            return null;
        }
    

    The problem, I believe, was that getDerivedStateFromProps was overwriting the local state change every time I tried to setState() and re-render the component. The nextProps.item.checked != prevState.itemChecked always evaluated to true, because the nextProps.item.checked binding referenced the previous props (the props hadn't changed), but the prevState.itemChecked binding's value had flipped.

    Therefore, this function always overwrote the state with the prior prop state.

    So I needed to add prevState.prevPropCheck != nextProps.item.checked to check that the props did in fact change.

    I'm not sure if this is getDerivedStateFromProps() intended usage, but a prevProps parameter seemed to be what I needed!

    Please let me know if you see a more elegant solution

    Thanks to @vedran for the help!