1

The app I'm working on is a multi-game app (a bird game, mole game, and minesweeper). The start screen is the menu. It gives me a cycle warning on this screen. When I tap to start the mole game, it plays naturally, but the moment I press the back button to go back to the menu, I receive errors: "Can't perform a React state update on an unmounted component" error follow by continuous "TYPE ERORR: null is not an object".

Screenshots:

Cycle warning

Null is not an object

Node.js errors

Mole Game's App.js

import {
    View,
    StyleSheet,
    Image,
    SafeAreaView,
    Text,
    TouchableWithoutFeedback
} from 'react-native';
import Images from './assets/Images';
import Constants from './Constants';
import Mole from './Mole';
import GameOver from './GameOver';
import Clear from './Clear';
import Pause from './Pause';

const DEFAULT_TIME = 20;
const DEFAULT_STATE = {
    level: 1,
    score: 0,
    time: DEFAULT_TIME,
    cleared: false,
    paused: false,
    gameover: false,
    health: 100
}

export default class MoleGame extends Component {
    constructor(props) {
        super(props);
        this.moles = [];
        this.state = DEFAULT_STATE;
        this.molesPopping = 0;
        this.interval = null;
        this.timeInterval = null;

    }

    componentDidMount = () => {
        this.setupTicks(DEFAULT_STATE, this.pause);
    }


    setupTicks = () => {
        let speed = 750 - (this.state.level * 50);
        if (speed < 350) {
            speed = 350;
        }
        this.interval = setInterval(this.popRandomMole, speed);
        this.timeInterval = setInterval(this.timerTick, 1000);
    }

    reset = () => {
        this.molesPopping = 0;
        this.setState(DEFAULT_STATE, this.setupTicks)
    }

    pause = () => {
        if (this.interval) clearInterval(this.interval);
        if (this.timeInterval) clearInterval(this.timeInterval);
        this.setState({
            paused: true
        });
    }

    resume = () => {
        this.molesPopping = 0;
        this.setState({
            paused: false
        }, this.setupTicks);
    }

    nextLevel = () => {
        this.molesPopping = 0;

        this.setState({
            level: this.state.level + 1,
            cleared: false,
            gameover: false,
            time: DEFAULT_TIME
        }, this.setupTicks)
    }

    timerTick = () => {
        if (this.state.time === 0) {
            clearInterval(this.interval);
            clearInterval(this.timeInterval);
            this.setState({
                cleared: true
            })
        } else {
            this.setState({
                time: this.state.time - 1
            })
        }
    }

    gameOver = () => {
        clearInterval(this.interval);
        clearInterval(this.timerInterval);
        this.setState({
            gameover: true
        })

    }

    onDamage = () => {
        if (this.state.cleared || this.state.gameOver || this.state.paused) {
            return;
        }
        let targetHealth = this.state.health - 10 < 0 ? 0 : this.state.health - 20;

        this.setState({
            health: targetHealth
        });

        if (targetHealth <= 0) {
            this.gameOver();
        }
    }

    onHeal = () => {
        let targetHealth = this.state.health + 10 > 100 ? 100 : this.state.health + 10
        this.setState({
            health: targetHealth
        })
    }

    onScore = () => {
        this.setState({
            score: this.state.score + 1
        })
    }

    randomBetween = (min, max) => {
        return Math.floor(Math.random() * (max - min + 1) + min);
    }

    onFinishPopping = (index) => {
        this.molesPopping -= 1;
    }

    popRandomMole = () => {
        if (this.moles.length != 12) {
            return;
        }
        let randomIndex = this.randomBetween(0, 11);
        if (!this.moles[randomIndex].isPopping && this.molesPopping < 3) {
            this.molesPopping += 1;
            this.moles[randomIndex].pop();
        }
    }


    render() {
        let healthBarWidth = (Constants.MAX_WIDTH - Constants.XR * 100 - Constants.XR * 60 - Constants.XR * 6) * this.state.health / 100;
        return (
            <View style={styles.container}>
                <Image style={styles.backgroundImage} resizeMode="stretch" source={Images.background} />
                <View style={styles.topPanel}>
                    <SafeAreaView>
                        <View style={styles.statsContainer}>
                            <View style={styles.stats}>
                                <View style={styles.levelContainer}>
                                    <Text style={styles.levelTitle}>Level</Text>
                                    <Text style={styles.levelNumber}>{this.state.level}</Text>
                                </View>
                            </View>
                            <View style={styles.stats}>
                                <View style={styles.timeBar}>
                                    <Text style={styles.timeNumber}>{this.state.time}</Text>
                                </View>
                                <Image style={styles.timeIcon} resizeMode="stretch" source={Images.timeIcon} />
                            </View>
                            <View style={styles.stats}>
                                <View style={styles.scoreBar}>
                                    <Text style={styles.scoreNumber}>{this.state.score}</Text>
                                </View>
                                <Image style={styles.scoreIcon} resizeMode="stretch" source={Images.scoreIcon} />
                            </View>
                            <View style={styles.stats}>
                                <TouchableWithoutFeedback onPress={this.pause}>
                                    <View style={styles.pauseButton}>
                                        <Image style={styles.pauseButtonIcon} resizeMode="stretch" source={Images.pauseIcon} />
                                    </View>
                                </TouchableWithoutFeedback>
                            </View>
                        </View>

                        <View style={styles.healthBarContainer}>
                            <View style={styles.healthBar}>
                                <View style={[styles.healthBarInner, { width: healthBarWidth }]} />
                            </View>
                            <Image style={styles.healthIcon} resizeMode="stretch" source={Images.healthIcon} />
                        </View>
                    </SafeAreaView>
                </View>
                <View style={styles.playArea}>
                    {Array.apply(null, Array(4)).map((el, rowIdx) => {
                        return (
                            <View style={styles.playRow} key={rowIdx}>
                                {Array.apply(null, Array(3)).map((el, colIdx) => {
                                    let moleIdx = (rowIdx * 3) + colIdx;

                                    return (
                                        <View style={styles.playCell} key={colIdx}>
                                            <Mole
                                                index={moleIdx}
                                                onDamage={this.onDamage}
                                                onHeal={this.onHeal}
                                                onFinishPopping={this.onFinishPopping}
                                                onScore={this.onScore}
                                                ref={(ref) => { this.moles[moleIdx] = ref }}
                                            />
                                        </View>
                                    )
                                })}
                            </View>
                        )
                    })}
                </View>
                {this.state.cleared && <Clear onReset={this.reset} onNextLevel={this.nextLevel} level={this.state.level} score={this.state.score} />}
                {this.state.gameover && <GameOver onReset={this.reset} level={this.state.level} score={this.state.score} />}
                {this.state.paused && <Pause onReset={this.reset} onResume={this.resume} />}
            </View>
        )
    }
};

const styles = StyleSheet.create({
    container: {
        flex: 1,
        flexDirection: 'column'
    },
    backgroundImage: {
        width: Constants.MAX_WIDTH,
        height: Constants.MAX_HEIGHT,
        position: 'absolute'
    },
    topPanel: {
        position: 'absolute',
        top: 0,
        left: 0,
        right: 0,
        height: Constants.YR * 250,
        flexDirection: 'column'
    },
    statsContainer: {
        width: Constants.MAX_WIDTH,
        height: Constants.YR * 120,
        flexDirection: 'row'
    },
    stats: {
        flex: 1,
        justifyContent: 'center',
        alignItems: 'center'
    },
    pauseButton: {
        width: Constants.YR * 50,
        height: Constants.YR * 50,
        backgroundColor: 'black',
        borderColor: 'white',
        borderWidth: 3,
        borderRadius: 10,
        flexDirection: 'column',
        alignItems: 'center',
        justifyContent: 'center'
    },
    pauseButtonIcon: {
        width: Constants.YR * 25,
        height: Constants.YR * 25,
    },
    levelContainer: {
        width: Constants.YR * 80,
        height: Constants.YR * 80,
        backgroundColor: '#ff1a1a',
        borderColor: 'white',
        borderWidth: 3,
        borderRadius: 10,
        flexDirection: 'column',
        alignItems: 'center',
        justifyContent: 'center'
    },
    levelTitle: {
        fontSize: 21,
        color: 'white',
        fontFamily: 'LilitaOne'
    },
    levelNumber: {
        fontSize: 17,
        color: 'white',
        fontFamily: 'LilitaOne'
    },
    scoreIcon: {
        position: 'absolute',
        left: 0,
        width: Constants.YR * 40,
        height: Constants.YR * 40,
    },
    scoreBar: {
        height: Constants.YR * 25,
        position: 'absolute',
        left: 20,
        right: 5,
        backgroundColor: 'white',
        borderRadius: 13,
        justifyContent: 'center',
        alignItems: 'center'
    },
    scoreNumber: {
        fontSize: 17,
        color: 'black',
        fontFamily: 'LilitaOne',
    },
    timeIcon: {
        position: 'absolute',
        left: 0,
        width: Constants.YR * 40,
        height: Constants.YR * 40,
    },
    timeBar: {
        height: Constants.YR * 25,
        position: 'absolute',
        left: 20,
        right: 5,
        backgroundColor: 'white',
        borderRadius: 13,
        justifyContent: 'center',
        alignItems: 'center'
    },
    timeNumber: {
        fontSize: 17,
        color: 'black',
        fontFamily: 'LilitaOne',
    },
    healthBarContainer: {
        height: Constants.YR * 40,
        width: Constants.MAX_WIDTH - Constants.XR * 120,
        marginLeft: Constants.XR * 60
    },
    healthIcon: {
        position: 'absolute',
        top: 0,
        left: 0,
        width: Constants.YR * 46,
        height: Constants.YR * 40,
    },
    healthBar: {
        height: Constants.YR * 20,
        width: Constants.MAX_WIDTH - Constants.XR * 100 - Constants.XR * 60,
        marginLeft: Constants.XR * 40,
        marginTop: Constants.YR * 10,
        backgroundColor: 'white',
        borderRadius: Constants.YR * 10
    },
    healthBarInner: {
        position: 'absolute',
        backgroundColor: '#ff1a1a',
        left: Constants.XR * 3,

        top: Constants.YR * 3,
        bottom: Constants.YR * 3,
        borderRadius: Constants.YR * 8
    },
    playArea: {
        width: Constants.MAX_WIDTH,
        marginTop: Constants.YR * 250,
        height: Constants.MAX_HEIGHT - Constants.YR * 250 - Constants.YR * 112,
        flexDirection: 'column',
    },
    playRow: {
        height: (Constants.MAX_HEIGHT - Constants.YR * 250 - Constants.YR * 112) / 4,
        width: Constants.MAX_WIDTH,
        flexDirection: 'row',
    },
    playCell: {
        width: Constants.MAX_WIDTH / 3,
        height: (Constants.MAX_HEIGHT - Constants.YR * 250 - Constants.YR * 112) / 4,
        alignItems: 'center'
    }
});

Mole.js

import { View, StyleSheet, Button, Image, TouchableWithoutFeedback } from 'react-native';
import Images from './assets/Images';
import SpriteSheet from 'rn-sprite-sheet';
//import Constants from './Constants';

export default class Mole extends Component {
    constructor (props) {
        super(props);
        this.mole = null;
        this.actionTimeout = null;
        this.isPopping = false;
        this.isWacked = false; 
        this.isHealing = false; 
        this.isAttacking = false; 
        this.isFeisty = false; 
    }

    pop =()=>{
        this.isWacked = false;
        this.isPopping = true;
        this.isAttacking = false; 

        this.isFeisty = Math.random() < 0.4;
        if(!this.isFeisty){
            this.isHealing = Math.random() < 0.12;
        }

        if(this.isHealing){
            this.mole.play({
                type: "heal",
                onFinish :  ()=>{
                    this.actionTimeout = setTimeout(()=>{
                        this.mole.play({
                            type : "hide",
                            fps: 24, 
                            onFinish: ()=>{
                                this.isPopping = false; 
                                this.props.onFinishPopping(this.props.index);
                            }
                        })
                    }, 1000); 
                }
            })
        }
        else{
            this.mole.play({
                type : "appear",
                fps: 24,
                onFinish: ()=>{
                    if (this.isFeisty){ 
                        this.actionTimeout = setTimeout(() => { 
                            this.isAttacking = true;    
                            this.props.onDamage();  
                            this.mole.play({    
                                type: "attack", 
                                fps: 12,    
                                onFinish: () => {   
                                    this.mole.play({    
                                        type: "hide",   
                                        fps: 24,    
                                        onFinish: () => {   
                                            this.isPopping = false; 
                                            this.props.onFinishPopping(this.props.index);   
                                        }   
                                    })  
                                }   
                            })  
                        }, 1000)
                    }
                    else{
                        this.actionTimeout = setTimeout(()=>{
                            this.mole.play({
                                type : "hide",
                                fps: 24, 
                                onFinish: ()=>{
                                    this.isPopping = false; 
                                    this.props.onFinishPopping(this.props.index);
                                }
                            })
                        }, 1000);
                    }
                    
                }
            })
        }
    }

    whack = ()=>{
        if(!this.isPopping || this.isWacked || this.isAttacking){
            return;
        }
        if (this.actionTimeout){
            clearTimeout(this.actionTimeout);
        }
        this.isWacked = true;
        
        this.props.onScore ();
        if( this.isHealing){
            this.props.onHeal();
        }
        this.mole.play({
            type: "dizzy",
            fps: 24,
            onFinish: () => {
                this.mole.play({
                    type: "faint",
                    fps: 24,
                    onFinish: () => {
                        this.isPopping = false;
                        this.props.onFinishPopping(this.props.index);
                    }
                })
            }
        })
    }


    render() {
        return (
            <View style= {styles.container}>
                <SpriteSheet  
                ref= {ref=> {this.mole = ref}}
                source = {Images.sprites}
                columns = {6}
                rows = {8}
                width = {100} 
                animations = {{
                    idle: [0],
                    appear: [1,2,3,4],
                    hide: [4,3,2,1,0],
                    dizzy : [36,37,38],
                    faint: [42,43,44,0],
                    attack: [11,12,13,14,15,16],
                    heal: [24,25,26,27,28,29,30,31,32,33]
                }} />
                <TouchableWithoutFeedback onPress= {this.whack} style= {{position: 'absolute', top: 0 , bottom:0, left:0, right:0 }}>
                    <View style= {{position: 'absolute', top: 0 , bottom:0, left:0, right:0 }} />
                </TouchableWithoutFeedback>  
            </View>
        )
    }
}

const styles = StyleSheet.create({
    container: {
        flex: 1
    }
})

index.js (Navigation)

import React from "react";
import { NavigationContainer } from '@react-navigation/native';
import { createStackNavigator } from '@react-navigation/stack';
import MoleGame from './Game1/App.js'
import BirdGame from './Game2/App.js'
import HomeScreen from './Game3/screens/HomeScreen.js'
import GameScreen from './Game3/screens/GameScreen.js'
import Home from './Home.js'


const Stack = createStackNavigator();

export default App = () => {
    return (
        <NavigationContainer>
            <Stack.Navigator screenOptions={{ headerShown: false, gestureEnabled: false, }}  >
                <Stack.Screen name="Home" component={Home} />
                <Stack.Screen name="MoleGame" component={MoleGame} />
                <Stack.Screen name="BirdGame" component={BirdGame} />
                <Stack.Screen name="MineSweeperHome" component={HomeScreen} />
                <Stack.Screen name="MineSweeperGame" component={GameScreen} />
            </Stack.Navigator>
        </NavigationContainer>
    );
};

Home.js

import { View, Text, TouchableOpacity, Dimensions, StyleSheet } from 'react-native';

const GameButton = ({ label, onPress, style }) => {
    return (
        <TouchableOpacity
            style={style}
            onPress={onPress}
        >
            <Text style={styles.text}>{label}</Text>
        </TouchableOpacity>
    );
};

export default Home = ({ navigation }) => {
    return (
        <View style={styles.container}>
            <View style={styles.welcome}>
                <Text style={styles.text}> Welcome! </Text>
            </View>
            <View style={styles.buttonContainer}>
                <GameButton
                    label={'Play Bird Game'}
                    onPress={() => navigation.navigate('BirdGame')}
                    style={styles.gameButton}
                />
                <GameButton
                    label={'Play Mole Game'}
                    onPress={() => navigation.navigate('MoleGame')}
                    style={{ ...styles.gameButton, backgroundColor: 'green' }}
                />
                <GameButton
                    label={'Play MineSweeper Game'}
                    onPress={() => navigation.navigate('MineSweeperHome')}
                    style={{ ...styles.gameButton, backgroundColor: 'grey' }}
                />
            </View>
        </View>
    );
};

const styles = StyleSheet.create({
    container: {
        flex: 1,
        backgroundColor: '#FFCB43'
    },
    welcome: {
        alignItems: 'center',
        justifyContent: 'center',
        padding: 10,
        marginTop: 80,
    },
    buttonContainer: {
        flex: 1,
        alignItems: 'center',
        justifyContent: 'space-evenly'
    },
    gameButton: {
        height: 70,
        width: Dimensions.get("screen").width / 1.4,
        backgroundColor: 'red',
        borderColor: 'red',
        alignItems: 'center',
        justifyContent: 'center',
        borderRadius: 20,
        borderWidth: 2,
        borderColor: 'black',
    },
    text: {
        fontSize: 22,
    }
})

Some context and notices

  • The project was started on my friend's mac machine. According to him, he doesn't receive those errors. And he also was able to build the app into an .apk for Android
  • We are both using the same node.js version (15.11.0)
  • There are times when my metro fails to run, and when that happens, the app runs without any errors.
  • I have tried to build it into an .apk, but the build failed.
  • Before the app was combined into 3 games. They were individual games. The mole game did run and was built successfully into an .apk.
  • The mole game was forked from here The code is pretty much the same.

UPDATE :

After adding

componentWillUnmount(){
        clearInterval(this.interval);
        clearInterval(this.timerInterval);
    }

to the App.js. I no longer get continuous "TypeError: null is not an object (evaluating '_this.moles[randomIndex].isPopping')". Unfortunately, I still get TypeError: null is not an object (evaluating '_this.moles.play) error. This happens when I go back to the main menu. I've tried adding clearTimeout(this.actionTimeout) on mole.js, but that didn't give any effect.

Screenshot :

TypeError: null is not an object (evaluating '_this.moles.play)

  • Do you do a cleanup of `setupTicks` on unmount? – ksav Dec 11 '21 at 00:45
  • Hi ksav, I don't think so. Apologies, I'm quite new to mobile dev in general. The mole game was forked from https://github.com/lepunk/react-native-videos/tree/master/WhackAMole If that is required, how do I apply that in my case? I'll test it and see if that fixes it. – Arîston Giltedged Dec 11 '21 at 00:52
  • I think the difference is that in the linked repo, the mole component is _always_ mounted. Now that you have a Navigator to choose which game component to mount, you need to cleanup processes like `setInterval` on `componentWillUnmount` when user navigates back to the menu. – ksav Dec 11 '21 at 00:59
  • See https://stackoverflow.com/questions/54709680/how-to-use-componentwillunmount-to-remove-setinterval-in-react-js and https://stackoverflow.com/a/39426527/5385381 for ideas on where to start – ksav Dec 11 '21 at 00:59
  • I tried putting ```componentWillUnmount(){ if(this.setupTicks) clearInterval(this.setupTicks) } ``` right before ```render ()``` but didn't seem to give any effect – Arîston Giltedged Dec 11 '21 at 01:18

2 Answers2

1

The setInterval() function is commonly used to set a delay for functions that are executed again and again, such as animations. You can cancel the interval using clearInterval().

When you navigate from MoleGame to Home, the MoleGame route is popped off the navigation stack and its component is unmounted. But the intervals from the setupTicks method are still executing, and trying to set state on the MoleGame component and to access this (neither of which are possible).

Try clearInterval on componentWillUnmount to stop the intervals that are set in the setupTicks method.

// App.js
export default class MoleGame extends Component {
    constructor(props) {
        super(props);
        this.interval = null;
        this.timeInterval = null;
        ...
    }

    componentDidMount = () => {
        this.setupTicks(DEFAULT_STATE, this.pause);
    }

    componentWillUnmount() {
        clearInterval(this.interval);
        clearInterval(this.timeInterval);
    }
}

Similarly in Mole.js you would have to handle the same scenario for any setTimeout()s you have by using clearTimeout()


Further reading: Plugging memory leaks in your app

ksav
  • 20,015
  • 6
  • 46
  • 66
  • I think there is improvement after putting ```componentWillUnmount(){ clearInterval(this.interval); clearInterval(this.timerInterval); } ``` it removed the constant isPopping error. Now the only error I receive is "TypeError: null is not an object (evaluating '_this.mole.play')" For the mole.js I tried ```componentWillUnmount(){ clearInterval(this.actionTimeout); } ``` – Arîston Giltedged Dec 11 '21 at 01:33
  • Without digging into that `mole.js` code, it looks like it just keeps calling `this.mole.play()` forever? You could try replacing instances of that with `this?.mole?.play?.()` – ksav Dec 11 '21 at 01:59
  • Unlike the `this.mole.[randomIndex].isPopping` error that pops up every second, I only get the `this.mole.play` once; every time I enter and go back to the main menu. – Arîston Giltedged Dec 11 '21 at 02:19
  • Yes because `this` no longer exists by the time the `setTimeout` callback executes (or no longer refers to what you think it does). You probably need to check for the existence of `this` or `this.mole` before attempting to get or set nested properties. – ksav Dec 11 '21 at 05:36
0
  1. By adding:
componentWillUnmount(){
        clearInterval(this.interval);
        clearInterval(this.timerInterval);
    }

it fixed the continuous TypeError: null is not an object (evaluating '_this.moles[randomIndex].isPopping')

  1. To remove the "TypeError: null is not an object (evaluating '_this.moles.play)" error, I had to put this.mole.play into a variable within Mole.js because this no longer refer to this.mole.play when I go back to the menu. There is still memory leak warnings since it is finishing the animation as I go to the menu, but there are no more critical errors.