0

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.

Cory Crowley
  • 304
  • 1
  • 12

2 Answers2

1

The effect you are trying to accomplish is called Optimistic UI which means that when some async action happens you are faking the effect of that action succeeding.

Eg. Facebook Like functionality 1. When you click Like on Facebook post the count is automatically increased by one (you) and then in the background ajax request is sent to API. 2. When you get response from your ajax request you will know if it succeeded or failed. If it succeeded then you can choose to do nothing but if it failed you will probably want to reduce the likes count you previously added and show some error to the user (or not).

In your case, you could do the same thing but if you want to have a real-time updates you will need to create some solution like long polling or web sockets.co

Also, getDerivedStateFromProps you provided looks good.

vedran
  • 1,106
  • 2
  • 13
  • 18
  • That's exactly what I'm trying to accomplish. Great analogy with Facebook likes. The functionality desired is almost identical. Long polling / web sockets are out of the scope of this project however. My solution based on getDerivedStateFromProps() makes sense to me, but after debugging, it appears that the setState() call in the clickCheck() handler is causing an update, but the local check state is being overwritten by **getDerivedStateFromProps()**. I'm not sure why this is happening. – Cory Crowley May 21 '20 at 05:23
  • I believe part of the problem may be this: ```if(nextProps.item.checked != prevState.itemChecked)``` This check seems to always yield true, overwriting the local state with the previous version of the check box state (the one from props). – Cory Crowley May 21 '20 at 05:28
  • 1
    @CoryCrowley I think it's because your props are never updating so your props.item.checked if initially was false will stay false even when you update checked in your state to true. So in your getDerivedStateFromProps when the component is rerendered it will always yield true because it's not the same and will overwrite your state with previous props that are out of date. – vedran May 21 '20 at 05:30
  • I think you're right. My props aren't changing when I make the setState call (in attempt to achieve Optimistic UI). Hmm. Do you know if there is a way to check if the props did in fact change from the last render. I only want to overwrite the local state value **if** new props are propagated to this component after the server response. – Cory Crowley May 21 '20 at 05:36
  • 1
    @CoryCrowley, To be honest, the best solution would be to refactor your structure a bit which would make things more correct in my honest opinion. You could create a container component that could be called eg. "List" and there you can handle all async calls and states and avoid using the getDerivedStateToProps. In that container component after you update the checked state in the component state, then you can call fetchNewList and use the response to directly update state in the same component that will automatically correctly update children (your ListItem com. w/o state or async actions – vedran May 21 '20 at 05:43
  • I actually have a container **List** component haha. The container component maps over the list returned from fetchNewList(), and renders each **ListItem** component. I thought about lifting the check state to that component, but then **ListItem** would need to track both prop.checkState & local.checkState for each item. Ideally, the only checkState exposed to **ListItem** would be the prop value, but I guess I don't know how to lift state out of this component properly. I imagine I would run into the same problem of syncing prop state with callback local state, no? – Cory Crowley May 21 '20 at 05:48
  • I know but you would need to lift the state from ListItem component and container should manage that and you shouldn't have the same issue as mentioned in your original question. I can provide a code example as a solution in my original answer if you need it. ;) – vedran May 21 '20 at 07:09
0

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!

Cory Crowley
  • 304
  • 1
  • 12