DEV Community

Lionel Marco
Lionel Marco

Posted on

React Multi State Submit Button

A multi state submit button that inter actuate according API response.

When a form with data is sended, we have two alternatives, do in a blindness way or give the user a feedback of what happen.

We gather information in fields inside a form and of course whe need a submit button.

The benefits of a submit button instead a normal button is that by default their behaviour is tied to ENTER key in mobile or desktop.

In order to give information of the resulting operation a button with five states is implemented:

normal, loading, warning, error, success

Every status explain by self.

For dialog an icons will be use MATERIAL-UI library.

Dialog

Button

Button

Table of Contents

1) Tuning Icons

The MATERIAL-UI library have a large collection of useful icons.

Three icons are needed:

  • CheckCircleIcon for submit and save confirmation.
  • SyncIcon for processing.
  • SyncProblemIcon for error and warning.

In a normal case to use the icons only need to import them :

import CheckCircleIcon from '@material-ui/icons/CheckCircle';
import SyncIcon from '@mui/icons-material/Sync';
import SyncProblemIcon from '@mui/icons-material/SyncProblem';
Enter fullscreen mode Exit fullscreen mode

But in this case, we need to extract the path to make a fine tunnig, taking from here :

material-ui-icons

Or from the browser using the right click mouse to inspect the element and then copy the PATH from the SVG.

Loading Icon

We will take the path of SyncIcon and give a rotation animation :

//file: /src/controls/SButtonIcons.jsx
//extract

export function LoadingIcon(props) {
  return (
  <SvgIcon   viewBox="0 0 24 24" style={{ width: 24, height:24  }} > 
   <circle fill="#1976d2" cx="12" cy="12" r="10" />        
        <g transform="translate(2.2 2.2) scale(0.8)"   >
        <path        
         d= 'M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.46 8.97 4 10.43 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z' 
         fill="#FFFFFF" strokeWidth="0" >
         <animateTransform attributeName="transform" type="rotate" from="360 12 12" to="0 12 12" dur="1.5s" repeatCount="indefinite">
        </animateTransform>
        </path>
        </g>

  </SvgIcon>
);
}

Enter fullscreen mode Exit fullscreen mode

Error-Warning Icon

We will take the path of SyncProblemIcon and give a property with the desired fill color.

//file: /src/controls/SButtonIcons.jsx
//extract
export function WarningIcon(props) {
    return (
    <SvgIcon   viewBox="0 0 24 24" style={{ width: 24, height:24  }} > 
     <circle fill={props.color ? props.color :'orange' } cx="12" cy="12" r="10" />        
        <g transform="translate(2.2 2.2) scale(0.8)"   >
        <path    
         d= 'M3 12c0 2.21.91 4.2 2.36 5.64L3 20h6v-6l-2.24 2.24C5.68 15.15 5 13.66 5 12c0-2.61 1.67-4.83 4-5.65V4.26C5.55 5.15 3 8.27 3 12zm8 5h2v-2h-2v2zM21 4h-6v6l2.24-2.24C18.32 8.85 19 10.34 19 12c0 2.61-1.67 4.83-4 5.65v2.09c3.45-.89 6-4.01 6-7.74 0-2.21-.91-4.2-2.36-5.64L21 4zm-10 9h2V7h-2v6z'
         fill="#FFFFFF" strokeWidth="0" >      
        </path>
        </g>
    </SvgIcon>
  );
  }
Enter fullscreen mode Exit fullscreen mode

2) Multistate Button

The button have a status prop with five possible values: normal, loading, warning, error, success.

To use it, just add: <SButton status={status} />, the status is coming from the API response.

Internally the button has a view state, it can be: idle, wait, timeout

At the start the view is idle. When come a response from the API: warning, error, success, the view change to wait. The view handle how many seconds the warning, error or success icons are showed.

After 2 second of wait view, the button view change to timeout restoring the normal button, giving to the user another try. And the fair tale begin again.

//file: /src/controls/SButton.jsx

export default class SButton extends React.Component {
    constructor(props) {
      super(props);
      this.state = {view:'idle'};   
    }


   //Called immediately after updating occurs. Not called for the initial render.
   componentDidUpdate(prevProps, prevState, snapshot) {

      //console.log("SButton componentDidUpdate, props:",this.props.status);

      //Only listen to 'status' change,      
      if (prevProps.status === this.props.status) return; // avoid re call "componentDidUpdate" when view change

      // after firts attemp, change from 'timeout' to 'idle'
      if (this.props.status === 'loading' ) 
      {
        console.log("view: idle");   
        this.setState({view: 'idle'});
      }            

      // ['warning','error','success']
      if (this.props.status === 'warning' ||this.props.status === 'error' || this.props.status === 'success'){          

       this.setState({view: 'wait'});// console.log("view: wait");   
       this.timer = setTimeout(()=> {this.setState({view: 'timeout'})}, 2000);
      }
   }

   componentWillUnmount(){
     clearTimeout(this.timer); // console.log("Button componentWillUnmount");     
    }

   render() {      

      var icon;

      if (this.state.view==='timeout') 
      { //when timeout, set the normal color to light blue 

        icon = <CheckCircleIcon style={{ color: '#1976d2' }}/>
      }
      else //view==[idle or wait], or in first render
      {

      // first render  
      if ( !this.props.status || this.props.status==='normal') {icon = <CheckCircleIcon style={{ color: '#1976d2' }}/> }
      // after submit
      if (this.props.status==='loading' ) {icon = <LoadingIcon/>}
      if (this.props.status==='warning') {icon = <WarningIcon  /> }
      if (this.props.status==='error') {icon = <WarningIcon color={'red' }/> }
      if (this.props.status==='success') {icon = <CheckCircleIcon style={{ color: 'green' }}/> }
      }

      // To avoid re-click when status==='loading'
      // type={this.props.status==='normal'?"button":"submit"}

      return (
        <>
        <IconButton {...this.props}  type={this.props.status==='loading'?"button":"submit"}  > 
          {icon}
        </IconButton>       
        </>               
      );
  }
}

Enter fullscreen mode Exit fullscreen mode

3) Dialog with Submit Button

Putting the button at work. For demonstration purposes, the button has been placed in a dialog that exemplifies the creation of a user.

When the user click the button, an action is dispatched, this submit the data using Axios.The async interaction of the dialogue with the API will be made with Axios and managed by Redux thunk.

The dialog make the API request with Axios, if the API response is 'success' it close by self, if not error advices are showed.

handleSubmit

When the user finish to fill fields, click the button and then one action is dispatched.

//file: /src/client/ClientCreateDlg.jsx
//extract

  const handleSubmit = (e) => {
    console.log("handleSubmit, data:",data);   
    e.preventDefault();  // prevent a browser reload/refresh
    props.dispatch(actClientCreate(data)); 

  };

Enter fullscreen mode Exit fullscreen mode

Actions and Reducer

The main action is actClientsFormApi(data,url) implementing an async Axios request with Redux Thunk, that will be called when need to create, update or delete a client. In this case just will use actClientCreate(data)
Just for demonstration, all three point to the same API route, but in real scenario, every must to have their specific route.

//file: /src/client/ClientsActions.jsx
//extract

//-----------------------
// Form =>  Create, Update, Delete
//-----------------------
export function actClientCreate(d) {return actClientsFormApi(d,"clientsresponse")};
export function actClientUpdate(d) {return actClientsFormApi(d,"clientsresponse")};
export function actClientDelete(d) {return actClientsFormApi(d,"clientsresponse")};

//------------------------------------------------------
function actClientsFormApi(d,url) { 

  return dispatch => {
    dispatch(actClientFormSubmit());// for processing advice msg

    axios.post(process.env.REACT_APP_API_BASE_URL+url,d, {withCredentials: true})
    .then(response => { dispatch(actClientFormResponse(response.data));})
    .catch(error => { dispatch(actClientFormResponse({status:'error',msg:error.message}))})                    

  };
}

export const actClientFormInit = () => ({
  type: 'CLIENT_FORM_INIT'  
});
export const actClientFormSubmit = () => ({
  type: 'CLIENT_FORM_SUBMIT'  
});
export const actClientFormResponse = (response) => ({
  type: 'CLIENT_FORM_RESPONSE',
  payload : response
});


Enter fullscreen mode Exit fullscreen mode

The reducer is very simple.

//file: /src/client/ClientReducer.jsx

const initialState = {
  formStatus : 'normal',
  formMsg: null
};


export default function ClientsReducer(state = initialState,action)
 {
  switch (action.type) {

    case 'CLIENT_FORM_INIT':             
        return {
          ...state,
          formStatus: 'normal',
          formMsg: '',          
        };   
    case 'CLIENT_FORM_SUBMIT':   
        return {
          ...state,
          formStatus: 'loading',
          formMsg: '',          
        };   

    case 'CLIENT_FORM_RESPONSE':    

      return {
        ...state,
        formStatus: action.payload.status,
        formMsg: action.payload.msg,

      };   

    default:  
      return state;
  }
}


Enter fullscreen mode Exit fullscreen mode

The dialog

Working with dialogs, we have an isolated place to do things like create, update or delete items.
We will take the dialog from MATERIAL-UI:

import Dialog from '@material-ui/core/Dialog';

As said before, the dialog interactuate with the API dispatching one action.

props.dispatch(actClientCreate(data));

And then, feed the button status prop with the status coming from the API response.

<SButton status={status} />

The dialog is connected to redux store, listening the operation status from the API.

//file: /src/client/ClientCreateDlg.jsx
//extract
const mapStateToPropsForm = state => ({    
  status:state.clients.formStatus,
  msg:state.clients.formMsg,   
});

export default connect(mapStateToPropsForm)(ClientCreateDlg);

Enter fullscreen mode Exit fullscreen mode

Basically the dialog have a form with just three fields, every change over it update the data state hook.

//file: /src/client/ClientCreateDlg.jsx


function ClientCreateDlg(props){

  const initial = {  name:'',phone:'', mail:'',}; 
  const [data, setData] = useState(initial);  

  //Mount - Unmount  
  useEffect(() => {
    props.dispatch(actClientFormInit());  //componentMount    

   return () => {
    props.dispatch(actClientFormInit());  //componentWillUnmount      
   };
 }, []);

   //componentDidUpdate  status listener  
   // When success, auto close after some time
  useEffect(() => {
    console.log("status:", props.status);
    var timer;
    if( props.status==='success') 
    {
       timer = setTimeout(() => { props.clbkClose()}, 1000);
    }
    return () => clearTimeout(timer);
     }, [props.status]); 


  const handleClose = () => {   //console.log("handleClose");     
      props.clbkClose();
  };

  const handleChange = (e) => {
    const {name,value} = e.target;
    setData(prevState => ({...prevState,[name]: value}));  
  };

  const handleSubmit = (e) => {
    console.log("handleSubmit:");
    console.log("  data:",data);   
    e.preventDefault();  // prevent a browser reload/refresh
    props.dispatch(actClientCreate(data)); 

  };

    const { status, msg } = props; // server API responses   

    var advice = null;         
    if (status === "loading") advice = "Procesing...";    
    if (status === "error") advice =  "Error: " + msg;   
    if (status === "warning") advice =  "Warning: " + msg;      
    if (status === "success") advice = "Data was saved."; 


    return (
        <Dialog onClose={handleClose}  open={true}>
        <div style={{minWidth:'300px', maxWidth:'400px',minHeight:'200px', padding:"2px",display: "flex" ,flexDirection: "column"}}>
        <DialogTitle ><ViewHeadlineIcon  />Create new Client:</DialogTitle>          
        <form onSubmit={handleSubmit} >

        <div style={{minWidth:'50%',boxSizing:'border-box',padding:"2px",display: "flex" ,flexDirection: "column",flexGrow:'1'}}>         
          <TextField name="name"   size="small" placeholder="Name"  onChange={handleChange} />  
          <TextField name="phone"  size="small" placeholder="Phone" onChange={handleChange} />  
          <TextField name="mail"   size="small" placeholder="Mail"  onChange={handleChange} />     
        </div>        

        <div style={{           
           display: "flex" ,                    
           flexDirection: "row",      
           alignItems: "center",
           justifyContent: "space-around" }}> 

        <SButton status={status} />    
        <IconButton  onClick={handleClose}  > <CancelIcon/></IconButton>   
        </div>    

        <Typography  variant='caption' style={{fontWeight:'600',textIndent:'6px'}} noWrap>
          {advice}
        </Typography>                       

        </form>
        </div>
        </Dialog>  

)

};

const mapStateToPropsForm = state => ({    
  status:state.clients.formStatus,
  msg:state.clients.formMsg,   
});

export default connect(mapStateToPropsForm)(ClientCreateDlg);
Enter fullscreen mode Exit fullscreen mode

Opening the dialog

The dialog is open from a main control ClientsMain. A client add button, trigger the dialog opening. Then use conditional rendering to show or hide it.

//file: /src/client/ClientsMain.jsx
class ClientMain extends React.Component {

  constructor(props) {
    super(props);
    this.state = {dlgIsOpen:false}      
  };   

  // Handle Dlg Open
  openClientCreateDlg = () =>{
    this.setState({dlgIsOpen:true});
  }

  // Dlg Close Callback
  clbkDlgClose = () => {
    console.log("clbkDlgClose");
    this.setState({dlgIsOpen:false});
  };
   //----------------------------------

  render() {   
    //console.log("Client Main render");     
    var renderDlg = null;
    if (this.state.dlgIsOpen){
      renderDlg =  <ClientCreateDlg clbkClose={this.clbkDlgClose} />                                              
    }

    return (    
        <>        
        <IconButton onClick={this.openClientCreateDlg} color="primary"> <AddCircleIcon/>Add new Client</IconButton>           
       {renderDlg}
        </>    
    );
}
}

export default ClientMain;

Enter fullscreen mode Exit fullscreen mode

4) Flask response dummy route

In order to simulate the results of the API endpoint, a route with random responses is implemented.


@app.route('/clientsresponse', methods=['POST','GET'])
def clientrandomresponse():

  # Real world
  #  json_content = request.get_json(silent=False)
  #  response = doSomething(json_content)
  #  return response

  # Simulation

  responses = [{ 'status': 'success'},
               { 'status': 'error', 'msg': 'Json required'},
               { 'status': 'error', 'msg': 'Missing field '},
               { 'status': 'warning', 'msg': 'Data validation fail'}]

  time.sleep(1) # Just to show Processing Spinner
  return responses[time.localtime().tm_sec%4]  

Enter fullscreen mode Exit fullscreen mode

Conclusion:

Working it that way we achieve a better interaction of the user with the App.
It is important to note that this button can also be applied in inline editing forms.

Get the full code from https://github.com/tomsawyercode/react-multistate-submit-button

Discussion (0)