javascriptiosreact-nativevalidationnative-base

Reset isLoading property of NativeBase button when on-press handler fails


I have made a "one shot" button wrapper component which intercepts the onPress event, sets the isLoading property and then calls the original onPress handler. This sets the button to the disabled loading spinner state while a slow API call is made, provides nice UI feedback and possibly prevents the user from double-clicking.

The original onPress handler has form-field validation, and if this fails I want to cancel the process and let the user correct the input data. So I am returning false to indicate this, which my wrapper catches, however I find I cannot set the button's isLoading back to false - it doesn't change the button state nor remove the disabled, so it's stuck spinning forever.

My button wrapper is this:

import React, {useState} from "react"
import {StyleSheet} from "react-native";
import {Button as ButtonNB, IButtonProps, useTheme} from "native-base"

interface IMyButtonProps extends IButtonProps {
    oneShot?: boolean
}

const Button = (props: IMyButtonProps) => {
    const [isLoading, setIsLoading] = useState(false)
    const {colors} = useTheme()

    return <ButtonNB px="25"
                     style={styles.button}
                     isLoading={isLoading} spinnerPlacement="end" isLoadingText="Saving..." backgroundColor={colors['primary']['500']}
                     {...props}
                     onPress={event => {
                         if (props.oneShot) setIsLoading(true)
                         if (props.onPress) {
                             if(!props.onPress(event)) {
                                 console.debug('cancelled loader')
                                 setIsLoading(false) // <--- DOESN'T WORK
                             }
                         }
                     }}
    />
}
export default Button

Calling code simplified:

                            <Button
                                onPress={() => onSave()}
                                oneShot={true}
                                testID="prepare-submit-button"
                            >
                                {saveSubjectButtonText}
                            </Button>
    async function onSave(){
       // on validation failure, just stop and return false
       if(!validate()){
          return false
       }
       else {
         // do api stuff...
         // update local state...
         navigation.navigate('Home')
       }
    }

When validation fails, I do get the 'cancelled loader' log, but the setIsLoading(false) has no effect.

I am viewing in iOS, package versions:

"native-base": "~3.4",
"react": "17.0.2",
"react-dom": "~17.0.2",
"react-native": "0.67.4",

Their documentation: https://docs.nativebase.io/button#h3-loading

I've looked at their issues: https://github.com/GeekyAnts/NativeBase/issues?q=is%3Aissue+isLoading+button+is%3Aclosed


Solution

  • I tried an alternative approach which worked, and now I think I have realised the original problem. I think because the setIsLoading(true) in the onPress handler is re-rendering the button due to state change, the remainder of the function closure is the old state scope when the button wasn't spinning anyway.

    So this was nothing to do with NativeBase nor ReactNative but a React gotcha which I think has caught me out before. Each time the render function is called, a new scope becomes active, and even though the old scope may still be finishing running code threads in memory they aren't "attached" to the display/DOM any more. At least that's how I picture it.

    I changed it to use reference functions to be able to call the "setLoading" from the parent component. Thanks to this answer: Call child function from parent component in React Native

    My button component is now defined like this:

    import React, {forwardRef, useImperativeHandle, useState} from "react"
    
    const Button = (props: IMyButtonProps, ref) => {
        const [isLoading, setIsLoading] = useState(false)
    
        useImperativeHandle(ref, () => ({
            cancelLoading: () => { setIsLoading(false) },
        }))
    
    ...
    
    export default forwardRef(Button)
    
    

    and called like this from the parent view:

        const submitButtonRef = useRef()
    
        async function onSave(){
           // on validation failure, tell the button to cancel loading
           if(!validate()){
              submitButtonRef.current.cancelLoading()
              return
           }
    ...
    
        <Button
            onPress={() => onSave()}
            ref={submitButtonRef}
    

    And now if the validation fails, the spinner is stopped and you can click the button again.